Skip to content

Kill Chain Agent Development Guide

Audience: Developer building kill chain agents for bedrock-hub. Open this file when starting agent #1. Read it end-to-end before writing a line of code.


Contents

  1. Agent Architecture
  2. Kill Chain Agent Specifics
  3. Building Each Agent
  4. Testing Locally
  5. Deploying
  6. Tuning and Known-Good Rules
  7. Cross-Environment Differences

1. Agent Architecture

How the Platform Works End-to-End

Container Apps Job (4-hour cycle)
  └── orchestrator.py
        └── ThreadPoolExecutor — all mini-agents run in parallel
              │  Each agent:
              │    1. Queries LAW / AWS / API
              │    2. Evaluates results
              │    3. Constructs finding dicts
              │    4. POSTs to POST /api/ingest/findings
              │    5. Returns result dict to orchestrator


        SecurityAgent (LLM)
              │  Reads all findings, decides which become incidents
              │  Creates incidents via POST /api/incidents

        AnalystAgent (rules engine, NO LLM)
              │  Fetches known-good rules via GET /api/known-good
              │  NEW → CLOSED  (matches a known-good rule)
              │  NEW → OPEN    (no match, needs human review)

        DetectionAgent (LLM enrichment of remaining OPEN tickets)

The kill chain agents you are building are mini-agents — they slot into the parallel pool, post findings, and return. They do not touch the analyst pipeline; that runs after all mini-agents complete.

Base Class Pattern

Every agent is a standalone Python module with a run() function. There is no formal base class — the contract is enforced by the orchestrator expecting a specific return shape. The secops_client module handles all HTTP calls to the SecOps API.

bedrock-hub/
  agents/
    entra/               # Entra identity agents
    network/             # Palo Alto CEF, DNS
    aws/                 # CloudTrail, Security Hub, IAM
    cortex/              # Cortex XDR
    supply_chain/        # GitHub Actions SHA pinning
    operational/         # backup, certs, VM status
    inventory/           # CMDB sync
    kill_chain/          # ← NEW — all 6 agents go here
      __init__.py
      execution_agent.py
      persistence_agent.py
      credential_dumping_agent.py
      lateral_movement_agent.py
      exfiltration_agent.py
      defense_evasion_agent.py

Create the kill_chain/ subdirectory and an empty __init__.py. Place all six agents inside it.

The Findings Schema

Every finding dict posted to POST /api/ingest/findings must match this shape exactly. Missing required fields cause the API to reject the finding silently — always validate locally before opening a PR.

python
{
    # --- Required ---
    "severity":           "critical",         # critical | high | medium | low (lowercase)
    "resource":           "WS-HELPDESK-01",   # short human-readable identifier
    "description":        "...",              # 1–3 sentences, specific, what happened
    "evidence":           "...",              # raw log line, KQL row dict, or API JSON
    "hipaa_risk":         "...",              # which HIPAA safeguard is implicated
    "recommended_action": "...",             # concrete, actionable steps
    "source_system":      "PROD",            # PROD | DDE | AWS  ← never omit

    # --- Strongly recommended for kill chain agents ---
    "mitre_technique":    "T1059",           # ATT&CK technique ID
    "mitre_tactic":       "Execution",       # ATT&CK tactic name
    "ioc_key":            "PROD:host:WS-HELPDESK-01:lolbin_execution",
                                             # dedup key — format below
}

The ioc_key Format

ioc_key is the deduplication key. If SecurityAgent creates an incident and the next run produces the same ioc_key, the platform updates the existing incident rather than creating a duplicate. Get this wrong and you either flood the system with duplicates or suppress genuinely new events.

"{source_system}:{entity_type}:{entity_value}:{detection_type}"

Examples:
  PROD:host:WS-HELPDESK-01:lolbin_execution
  PROD:account:jsmith@ciriusgroup.com:lsass_access
  DDE:host:DDE-TS-01:admin_share_access
  AWS:account:040067931468:cloudtrail_anomaly

Rules:

  • source_system must be the first segment — always. PROD and DDE incidents sharing an ioc_key would incorrectly suppress each other.
  • entity_type is the category of the thing being flagged: host, account, ip, process, url.
  • entity_value is normalized (uppercase for hostnames, lowercase for accounts/IPs).
  • detection_type is a stable snake_case string that identifies what fired. Use the same string consistently across runs — changing it breaks dedup.

The Result Dict

After posting findings to the API, run() returns a summary dict to the orchestrator. This is separate from the findings themselves; SecurityAgent reads it for context.

python
{
    "findings":                [],      # list of finding dicts — NEVER None
    "iocs":                    [],      # list of ioc_key strings from findings
    "confidence":              90,      # int 0–100
    "summary":                 "...",   # what was checked, what was found
    "immediate_action_required": False, # True only when severity is critical
}

Never raise an uncaught exception from run(). An uncaught exception kills the agent thread and produces zero findings — including for real attacks. Always wrap the main detection block in try/except Exception.


2. Kill Chain Agent Specifics

Severity Tiers

Kill chain agents use a two-tier severity model. Do not downgrade these — false positive CRITICALs are annoying, missed CRITICALs are ransomware incidents.

StageDefault SeverityRationale
ExecutionCRITICALAttacker code is running on a host
PersistenceHIGHFoothold established; not yet lateral
Credential DumpingCRITICALCredentials in hand = lateral movement imminent
Lateral MovementCRITICALAttacker is actively spreading
ExfiltrationHIGHData leaving network
Defense EvasionCRITICAL for 1102; HIGH for Cortex silenceLog cleared = active cleanup = attacker present

No Analyst Gate for CRITICAL Kill Chain Findings

Normal flow: mini-agent → SecurityAgent → AnalystAgent → OPEN/CLOSED.

Kill chain CRITICAL findings skip the analyst hold — they go directly to OPEN and trigger immediate notification. This is enforced by setting immediate_action_required: True in the result dict whenever any CRITICAL finding is present. SecurityAgent reads this flag and bypasses the analyst queue.

python
"immediate_action_required": any(f["severity"] == "critical" for f in findings)

Break-Glass Account Rule

Break-glass accounts fire CRITICAL regardless of any other logic. Never add them to known-good rules. The API hard-blocks suppression for break-glass accounts, but your agent should not attempt it in the first place. The accounts to treat as permanently unsuppressible:

  • cirius-breakglass@ciriusgroup.com
  • cirius-breakglass@ciriusdde.com
  • cirius-breakglass@dr.ciriusgroup.internal
  • All 7 AWS root accounts
  • All 4 Palo Alto local admin accounts

If any detection logic touches these accounts, set severity to critical unconditionally and do not route through any threshold check.

Fail-Open Behavior

If POST /api/ingest/findings fails (SecOps unreachable, auth error, 5xx), the agent should log the error and return a degraded result with confidence: 0. Do not silently drop findings. The orchestrator will log the degraded run and the heartbeat email will surface it.

python
except requests.RequestException as exc:
    log.error("SecOps API unreachable: %s", exc)
    return {
        "findings": [],
        "iocs": [],
        "confidence": 0,
        "summary": f"execution_agent: SecOps API unreachable — {exc}. Findings not delivered.",
        "immediate_action_required": False,
    }

3. Building Each Agent

3.1 Execution Agent

File: agents/kill_chain/execution_agent.pyMITRE: T1059 (Command and Scripting Interpreter), T1204 (User Execution) Severity: CRITICAL

What to Detect

  • LOLBins (mshta, wscript, cscript, rundll32, regsvr32, certutil) spawned from unexpected parent processes (Word, Excel, Outlook, browser processes)
  • Known offensive tools (mimikatz, cobalt, beacon, meterpreter, procdump) appearing anywhere in NewProcessName or CommandLine
  • Any process executing from %TEMP%, %APPDATA%, %USERPROFILE%\Downloads, or %PUBLIC%
  • PowerShell with encoded commands (EventID 4104 script block logging), long base64 strings, or IEX/Invoke-Expression + download cradles

KQL Query (EventID 4688)

kql
SecurityEvent
| where TimeGenerated > ago(4h)
| where EventID == 4688
| where
    // Known offensive tool names
    NewProcessName has_any ("mimikatz", "cobalt", "beacon", "meterpreter", "procdump", "psexec", "wce")
    or CommandLine has_any ("mimikatz", "sekurlsa", "lsadump", "kerberoast")
    // LOLBins from suspicious parents
    or (
        NewProcessName has_any ("mshta.exe", "wscript.exe", "cscript.exe", "rundll32.exe", "regsvr32.exe", "certutil.exe")
        and ParentProcessName has_any ("winword.exe", "excel.exe", "outlook.exe", "powerpnt.exe", "msedge.exe", "chrome.exe", "firefox.exe")
    )
    // Processes spawned from user-writable paths
    or NewProcessName matches regex @"(?i)(\\temp\\|\\appdata\\|\\downloads\\|\\public\\|\\users\\[^\\]+\\[^\\]+\.exe)"
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| project TimeGenerated, Host, Account, NewProcessName, ParentProcessName, CommandLine, SubjectUserName
| order by TimeGenerated desc

KQL Query (PowerShell 4104 Script Block)

kql
SecurityEvent
| where TimeGenerated > ago(4h)
| where EventID == 4104
| where
    ScriptBlockText has_any ("IEX", "Invoke-Expression", "DownloadString", "DownloadFile", "WebClient", "Net.WebClient")
    or ScriptBlockText matches regex @"(?i)[A-Za-z0-9+/]{100,}={0,2}"  // long base64
    or ScriptBlockText has_any ("mimikatz", "Invoke-Mimikatz", "Invoke-Kerberoast", "Get-GPPPassword")
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| project TimeGenerated, Host, ScriptBlockText, Path
| order by TimeGenerated desc

ioc_key Format

PROD:host:{Host}:lolbin_execution
PROD:host:{Host}:offensive_tool_detected
PROD:host:{Host}:suspicious_path_execution
PROD:host:{Host}:powershell_download_cradle

Code Skeleton

python
"""
execution_agent.py — Detect malicious process execution (kill chain stage 2)

Checks: EventID 4688 process creation, EventID 4104 PowerShell script blocks
Data source: SecurityEvent in LAW_WORKSPACE_ID (Sentinel-enabled workspace only)
Fires on: LOLBins from Office parents, known offensive tools, processes from
          user-writable paths, PowerShell download cradles
MITRE: T1059 (Command and Scripting Interpreter), T1204 (User Execution)
Severity: CRITICAL — attacker code is executing
"""

import logging
import os

import secops_client
from kql_utils import run_kql_query

log = logging.getLogger(__name__)

SOURCE_SYSTEM = os.environ.get("SOURCE_SYSTEM", "PROD")  # PROD | DDE

AGENT_QUERIES = [
    {
        "description": "EventID 4688: LOLBins from Office/browser parents, offensive tools, suspicious path execution",
        "mitre_technique": "T1059",
        "mitre_tactic": "Execution",
        "source_system": SOURCE_SYSTEM,
    },
    {
        "description": "EventID 4104: PowerShell download cradles and encoded commands",
        "mitre_technique": "T1059.001",
        "mitre_tactic": "Execution",
        "source_system": SOURCE_SYSTEM,
    },
]

# Tools that are always offensive — no exception list for these
ALWAYS_MALICIOUS = {
    "mimikatz", "cobalt", "beacon", "meterpreter", "procdump.exe",
    "psexec.exe", "wce.exe", "gsecdump", "pwdump",
}

LOLBINS = {
    "mshta.exe", "wscript.exe", "cscript.exe", "rundll32.exe",
    "regsvr32.exe", "certutil.exe", "msiexec.exe",
}

SUSPICIOUS_PARENTS = {
    "winword.exe", "excel.exe", "outlook.exe", "powerpnt.exe",
    "msedge.exe", "chrome.exe", "firefox.exe", "acrord32.exe",
}

SUSPICIOUS_PATH_PATTERNS = [
    r"\\temp\\", r"\\appdata\\", r"\\downloads\\", r"\\public\\",
]

PROCESS_QUERY = """
SecurityEvent
| where TimeGenerated > ago(4h)
| where EventID == 4688
| where
    NewProcessName has_any ({lolbin_list})
    or CommandLine has_any ("mimikatz", "sekurlsa", "lsadump", "kerberoast", "invoke-mimikatz")
    or NewProcessName has_any ({malicious_list})
    or NewProcessName matches regex @"(?i)(\\\\temp\\\\|\\\\appdata\\\\|\\\\downloads\\\\|\\\\public\\\\)"
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| project TimeGenerated, Host, Account, NewProcessName, ParentProcessName, CommandLine, SubjectUserName
| order by TimeGenerated desc
| take 200
"""

POWERSHELL_QUERY = """
SecurityEvent
| where TimeGenerated > ago(4h)
| where EventID == 4104
| where
    ScriptBlockText has_any ("IEX", "Invoke-Expression", "DownloadString", "DownloadFile", "Net.WebClient", "WebClient")
    or ScriptBlockText has_any ("mimikatz", "Invoke-Mimikatz", "Invoke-Kerberoast", "Get-GPPPassword")
    or ScriptBlockText matches regex @"(?i)[A-Za-z0-9+/]{100,}={0,2}"
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| project TimeGenerated, Host, ScriptBlockText, Path
| order by TimeGenerated desc
| take 50
"""


def run() -> dict:
    secops_client.register_queries(AGENT_QUERIES, "kill_chain_execution")
    log.info("Starting execution_agent (source_system=%s)...", SOURCE_SYSTEM)

    findings: list[dict] = []

    try:
        process_rows = run_kql_query(PROCESS_QUERY)
        for row in process_rows:
            finding = _evaluate_process_row(row)
            if finding:
                findings.append(finding)

        ps_rows = run_kql_query(POWERSHELL_QUERY)
        for row in ps_rows:
            finding = _evaluate_powershell_row(row)
            if finding:
                findings.append(finding)

    except Exception as exc:
        log.exception("Unhandled error in execution_agent: %s", exc)
        return {
            "findings": [],
            "iocs": [],
            "confidence": 0,
            "summary": f"execution_agent failed: {exc}. Check agent logs.",
            "immediate_action_required": False,
        }

    # Deduplicate by ioc_key — a single host may match multiple rows
    seen_keys: set[str] = set()
    unique_findings = []
    for f in findings:
        key = f.get("ioc_key", "")
        if key not in seen_keys:
            seen_keys.add(key)
            unique_findings.append(f)
    findings = unique_findings

    iocs = [f["ioc_key"] for f in findings]

    if not findings:
        summary = (
            f"execution_agent ({SOURCE_SYSTEM}): No malicious process execution detected. "
            f"Checked 4688 process creation and 4104 PowerShell script blocks for the last 4h."
        )
    else:
        hosts = sorted({f["resource"] for f in findings})
        summary = (
            f"execution_agent ({SOURCE_SYSTEM}): {len(findings)} CRITICAL finding(s) on "
            f"{len(hosts)} host(s): {', '.join(hosts)}. Immediate investigation required."
        )

    return {
        "findings": findings,
        "iocs": iocs,
        "confidence": 95,
        "summary": summary,
        "immediate_action_required": any(f["severity"] == "critical" for f in findings),
    }


def _evaluate_process_row(row: dict) -> dict | None:
    host = row.get("Host", "UNKNOWN").upper()
    process = (row.get("NewProcessName") or "").lower()
    parent = (row.get("ParentProcessName") or "").lower()
    cmdline = (row.get("CommandLine") or "").lower()
    account = row.get("Account") or row.get("SubjectUserName") or "unknown"
    ts = str(row.get("TimeGenerated", ""))

    # Always-malicious tool names — no threshold check
    for tool in ALWAYS_MALICIOUS:
        if tool in process or tool in cmdline:
            return {
                "severity": "critical",
                "resource": host,
                "description": (
                    f"Known offensive tool detected on {host}: process '{row.get('NewProcessName')}' "
                    f"launched by account '{account}' at {ts}."
                ),
                "evidence": str(row),
                "hipaa_risk": (
                    "HIPAA §164.312(a)(1) — Access Control. Offensive tooling indicates "
                    "unauthorized access attempt or active breach."
                ),
                "recommended_action": (
                    "1. Isolate host immediately from network. "
                    "2. Preserve memory dump before shutdown. "
                    "3. Engage Arctic Wolf MDR. "
                    "4. Escalate to Rory."
                ),
                "mitre_technique": "T1003",
                "mitre_tactic": "Credential Access",
                "source_system": SOURCE_SYSTEM,
                "ioc_key": f"{SOURCE_SYSTEM}:host:{host}:offensive_tool_detected",
            }

    # LOLBin launched from Office or browser parent
    proc_name = process.split("\\")[-1] if "\\" in process else process
    parent_name = parent.split("\\")[-1] if "\\" in parent else parent
    if proc_name in LOLBINS and parent_name in SUSPICIOUS_PARENTS:
        return {
            "severity": "critical",
            "resource": host,
            "description": (
                f"LOLBin execution detected on {host}: '{row.get('NewProcessName')}' "
                f"spawned by '{row.get('ParentProcessName')}' (account: {account}) at {ts}. "
                f"Office/browser launching a scripting engine is a primary phishing indicator."
            ),
            "evidence": str(row),
            "hipaa_risk": (
                "HIPAA §164.312(a)(1) — Access Control. Phishing-initiated LOLBin execution "
                "may indicate PHI system compromise."
            ),
            "recommended_action": (
                "1. Check Cortex for related behavioral alerts on this host. "
                "2. Review user's recent email/attachments. "
                "3. Isolate host if additional indicators present. "
                "4. Escalate to Rory."
            ),
            "mitre_technique": "T1218",
            "mitre_tactic": "Defense Evasion",
            "source_system": SOURCE_SYSTEM,
            "ioc_key": f"{SOURCE_SYSTEM}:host:{host}:lolbin_execution",
        }

    # Process from user-writable path
    for path_fragment in SUSPICIOUS_PATH_PATTERNS:
        if path_fragment in process:
            return {
                "severity": "critical",
                "resource": host,
                "description": (
                    f"Process executing from user-writable path on {host}: "
                    f"'{row.get('NewProcessName')}' (account: {account}) at {ts}. "
                    f"Legitimate software does not execute from Temp or AppData."
                ),
                "evidence": str(row),
                "hipaa_risk": (
                    "HIPAA §164.312(c)(1) — Integrity. Unsigned executables in user paths "
                    "indicate staged malware."
                ),
                "recommended_action": (
                    "1. Identify the file and compute SHA256. "
                    "2. Check VirusTotal and Cortex. "
                    "3. Isolate host if file is unsigned or unknown. "
                    "4. Escalate to Rory."
                ),
                "mitre_technique": "T1204.002",
                "mitre_tactic": "Execution",
                "source_system": SOURCE_SYSTEM,
                "ioc_key": f"{SOURCE_SYSTEM}:host:{host}:suspicious_path_execution",
            }

    return None


def _evaluate_powershell_row(row: dict) -> dict | None:
    host = row.get("Host", "UNKNOWN").upper()
    script_text = row.get("ScriptBlockText", "")
    ts = str(row.get("TimeGenerated", ""))

    # Truncate evidence to 500 chars — evidence field is not a log dump
    evidence_preview = script_text[:500] + ("..." if len(script_text) > 500 else "")

    return {
        "severity": "critical",
        "resource": host,
        "description": (
            f"Suspicious PowerShell script block detected on {host} at {ts}. "
            f"Script contains download cradle, encoded payload, or known offensive function."
        ),
        "evidence": evidence_preview,
        "hipaa_risk": (
            "HIPAA §164.312(a)(1) — Access Control. PowerShell download cradles are the "
            "primary delivery mechanism for fileless malware targeting PHI systems."
        ),
        "recommended_action": (
            "1. Review full script block in LAW (EventID 4104). "
            "2. Identify the PowerShell process parent. "
            "3. Check Cortex for related behavioral detections. "
            "4. Escalate to Rory."
        ),
        "mitre_technique": "T1059.001",
        "mitre_tactic": "Execution",
        "source_system": SOURCE_SYSTEM,
        "ioc_key": f"{SOURCE_SYSTEM}:host:{host}:powershell_download_cradle",
    }


if __name__ == "__main__":
    import json
    import sys

    logging.basicConfig(level=logging.INFO)
    result = run()
    print(json.dumps(result, indent=2, default=str))
    if result.get("immediate_action_required"):
        sys.exit(1)

3.2 Persistence Agent

File: agents/kill_chain/persistence_agent.pyMITRE: T1543.003 (Windows Service), T1053.005 (Scheduled Task) Severity: HIGH (CRITICAL if combined with other kill chain findings in the same run)

What to Detect

  • EventID 7045: new Windows service installed outside an approved CM window
  • EventID 4698: new scheduled task created — flag all, correlate against CM window
  • FIM alerts for registry run keys:
    • HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
    • HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
    • HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
  • New services with service binary paths pointing to user-writable locations

KQL Query (New Service — 7045)

kql
SecurityEvent
| where TimeGenerated > ago(4h)
| where EventID == 7045
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| extend ServiceName = tostring(EventData.ServiceName)
| extend ServiceFileName = tostring(EventData.ImagePath)
| extend ServiceType = tostring(EventData.ServiceType)
| extend StartType = tostring(EventData.StartType)
| project TimeGenerated, Host, ServiceName, ServiceFileName, ServiceType, StartType, Account
| order by TimeGenerated desc

KQL Query (New Scheduled Task — 4698)

kql
SecurityEvent
| where TimeGenerated > ago(4h)
| where EventID == 4698
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| extend TaskName = tostring(EventData.TaskName)
| extend TaskContent = tostring(EventData.TaskContent)
| project TimeGenerated, Host, Account, TaskName, TaskContent
| order by TimeGenerated desc

KQL Query (FIM Registry Run Keys)

kql
ConfigurationChange
| where TimeGenerated > ago(4h)
| where ConfigChangeType == "Registry"
| where RegistryKey has_any (
    "\\Microsoft\\Windows\\CurrentVersion\\Run",
    "\\Microsoft\\Windows\\CurrentVersion\\RunOnce",
    "\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon"
)
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| project TimeGenerated, Host, RegistryKey, RegistryValueName, RegistryValueData, ChangeCategory
| order by TimeGenerated desc

CM Window Check

Before raising a finding, check whether a CM ticket covers the change:

python
import secops_client

def _in_cm_window(host: str, service_name: str, ts: str) -> bool:
    """Returns True if an approved CM ticket covers this host/time combination."""
    try:
        cms = secops_client.get_recent_changes()  # GET /api/changes/recent
        for cm in cms:
            if (
                cm.get("status") == "approved"
                and cm.get("host", "").upper() == host
                and cm.get("start_time") <= ts <= cm.get("end_time")
            ):
                log.info("Service on %s covered by CM ticket %s — skipping", host, cm.get("id"))
                return True
    except Exception as exc:
        log.warning("CM check failed for %s — treating as outside window: %s", host, exc)
        # Fail-open: if we can't confirm CM, raise the finding
    return False

ioc_key Format

PROD:host:{Host}:new_service_outside_cm
PROD:host:{Host}:new_scheduled_task
PROD:host:{Host}:registry_run_key_modified

Code Skeleton (abbreviated)

python
"""
persistence_agent.py — Detect persistence mechanisms (kill chain stage 3)

Checks: EventID 7045 new services, 4698 scheduled tasks, FIM registry run keys
Data source: SecurityEvent and ConfigurationChange in LAW_WORKSPACE_ID
Fires on: New persistence outside CM window
MITRE: T1543.003, T1053.005
Severity: HIGH (outside CM window), skip if CM-covered
"""

import logging
import os

import secops_client
from kql_utils import run_kql_query

log = logging.getLogger(__name__)

SOURCE_SYSTEM = os.environ.get("SOURCE_SYSTEM", "PROD")

SERVICE_QUERY = """
SecurityEvent
| where TimeGenerated > ago(4h)
| where EventID == 7045
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| extend ServiceName = tostring(EventData.ServiceName)
| extend ServiceFileName = tostring(EventData.ImagePath)
| project TimeGenerated, Host, ServiceName, ServiceFileName, Account
| order by TimeGenerated desc
"""

TASK_QUERY = """
SecurityEvent
| where TimeGenerated > ago(4h)
| where EventID == 4698
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| extend TaskName = tostring(EventData.TaskName)
| extend TaskContent = tostring(EventData.TaskContent)
| project TimeGenerated, Host, Account, TaskName, TaskContent
| order by TimeGenerated desc
"""

FIM_QUERY = """
ConfigurationChange
| where TimeGenerated > ago(4h)
| where ConfigChangeType == "Registry"
| where RegistryKey has_any (
    "\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run",
    "\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnce",
    "\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Winlogon"
)
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| project TimeGenerated, Host, RegistryKey, RegistryValueName, RegistryValueData, ChangeCategory
| order by TimeGenerated desc
"""

# Benign service names from known software deployments.
# Keep this list SHORT — see section 6 for policy.
KNOWN_GOOD_SERVICES = {
    "cortexagent", "cydaxagent", "twingateservice",
}


def run() -> dict:
    secops_client.register_queries(
        [
            {"description": "7045 new service outside CM", "mitre_technique": "T1543.003",
             "mitre_tactic": "Persistence", "source_system": SOURCE_SYSTEM},
            {"description": "4698 new scheduled task", "mitre_technique": "T1053.005",
             "mitre_tactic": "Persistence", "source_system": SOURCE_SYSTEM},
            {"description": "FIM registry run key modification", "mitre_technique": "T1547.001",
             "mitre_tactic": "Persistence", "source_system": SOURCE_SYSTEM},
        ],
        "kill_chain_persistence",
    )
    log.info("Starting persistence_agent (source_system=%s)...", SOURCE_SYSTEM)

    findings: list[dict] = []

    try:
        for row in run_kql_query(SERVICE_QUERY):
            host = row.get("Host", "UNKNOWN").upper()
            svc = (row.get("ServiceName") or "").lower()
            svc_path = (row.get("ServiceFileName") or "").lower()
            ts = str(row.get("TimeGenerated", ""))

            if svc in KNOWN_GOOD_SERVICES:
                continue
            if _in_cm_window(host, svc, ts):
                continue

            is_suspicious_path = any(p in svc_path for p in ["\\temp\\", "\\appdata\\", "\\users\\"])

            findings.append({
                "severity": "critical" if is_suspicious_path else "high",
                "resource": host,
                "description": (
                    f"New service '{row.get('ServiceName')}' installed on {host} at {ts} "
                    f"outside a CM window. Binary path: {row.get('ServiceFileName')}."
                ),
                "evidence": str(row),
                "hipaa_risk": (
                    "HIPAA §164.312(a)(1) — Access Control. Unauthorized service installation "
                    "establishes persistent attacker foothold."
                ),
                "recommended_action": (
                    "1. Verify service legitimacy with the installing account owner. "
                    "2. Check service binary hash against threat intel. "
                    "3. If unauthorized, disable service and isolate host. "
                    "4. Create retrospective CM ticket if legitimate."
                ),
                "mitre_technique": "T1543.003",
                "mitre_tactic": "Persistence",
                "source_system": SOURCE_SYSTEM,
                "ioc_key": f"{SOURCE_SYSTEM}:host:{host}:new_service_outside_cm",
            })

        for row in run_kql_query(TASK_QUERY):
            host = row.get("Host", "UNKNOWN").upper()
            task = row.get("TaskName") or ""
            ts = str(row.get("TimeGenerated", ""))

            if _in_cm_window(host, task, ts):
                continue

            findings.append({
                "severity": "high",
                "resource": host,
                "description": (
                    f"New scheduled task '{task}' created on {host} at {ts} by "
                    f"'{row.get('Account')}' outside a CM window."
                ),
                "evidence": (row.get("TaskContent") or str(row))[:500],
                "hipaa_risk": (
                    "HIPAA §164.312(a)(1) — Access Control. Unauthorized scheduled tasks "
                    "are a primary persistence mechanism."
                ),
                "recommended_action": (
                    "1. Review task trigger and action in Task Scheduler or LAW. "
                    "2. Identify the account that created it. "
                    "3. Disable if unauthorized. "
                    "4. Escalate to Rory."
                ),
                "mitre_technique": "T1053.005",
                "mitre_tactic": "Persistence",
                "source_system": SOURCE_SYSTEM,
                "ioc_key": f"{SOURCE_SYSTEM}:host:{host}:new_scheduled_task",
            })

        for row in run_kql_query(FIM_QUERY):
            host = row.get("Host", "UNKNOWN").upper()
            key = row.get("RegistryKey") or ""
            value = row.get("RegistryValueName") or ""
            ts = str(row.get("TimeGenerated", ""))

            findings.append({
                "severity": "high",
                "resource": host,
                "description": (
                    f"Registry run key modified on {host} at {ts}: "
                    f"key '{key}', value '{value}'."
                ),
                "evidence": str(row),
                "hipaa_risk": (
                    "HIPAA §164.312(c)(1) — Integrity. Run key modifications establish "
                    "persistence that survives reboots."
                ),
                "recommended_action": (
                    "1. Identify what executable the key points to. "
                    "2. Check hash against threat intel. "
                    "3. Remove key if unauthorized. "
                    "4. Escalate to Rory."
                ),
                "mitre_technique": "T1547.001",
                "mitre_tactic": "Persistence",
                "source_system": SOURCE_SYSTEM,
                "ioc_key": f"{SOURCE_SYSTEM}:host:{host}:registry_run_key_modified",
            })

    except Exception as exc:
        log.exception("Unhandled error in persistence_agent: %s", exc)
        return {
            "findings": [], "iocs": [], "confidence": 0,
            "summary": f"persistence_agent failed: {exc}. Check agent logs.",
            "immediate_action_required": False,
        }

    iocs = [f["ioc_key"] for f in findings]
    if not findings:
        summary = (
            f"persistence_agent ({SOURCE_SYSTEM}): No unauthorized persistence detected. "
            f"Checked 7045 (services), 4698 (scheduled tasks), FIM registry run keys for last 4h."
        )
    else:
        summary = (
            f"persistence_agent ({SOURCE_SYSTEM}): {len(findings)} finding(s). "
            f"Review immediately — new persistence outside CM window."
        )

    return {
        "findings": findings,
        "iocs": iocs,
        "confidence": 90,
        "summary": summary,
        "immediate_action_required": any(f["severity"] == "critical" for f in findings),
    }


def _in_cm_window(host: str, item_name: str, ts: str) -> bool:
    try:
        cms = secops_client.get_recent_changes()
        for cm in cms:
            if (
                cm.get("status") == "approved"
                and cm.get("start_time", "") <= ts <= cm.get("end_time", "")
            ):
                log.info(
                    "Item '%s' on %s covered by CM %s — suppressing",
                    item_name, host, cm.get("id"),
                )
                return True
    except Exception as exc:
        log.warning("CM window check failed — fail-open (raising finding): %s", exc)
    return False


if __name__ == "__main__":
    import json
    import sys

    logging.basicConfig(level=logging.INFO)
    result = run()
    print(json.dumps(result, indent=2, default=str))
    if result.get("immediate_action_required"):
        sys.exit(1)

3.3 Credential Dumping Agent

File: agents/kill_chain/credential_dumping_agent.pyMITRE: T1003.001 (LSASS Memory), T1110.003 (Password Spraying) Severity: CRITICAL

What to Detect

  • EventID 4688 where NewProcessName or CommandLine references lsass.exe as a target (not as a parent) — procdump, tasklist, comsvcs.dll, nanodump patterns
  • Password spray pattern: 5+ failed auth events (EventID 4625) against 3+ distinct target accounts from the same source, followed by a success (EventID 4624) within 60 minutes

KQL Queries

kql
// LSASS access via 4688
SecurityEvent
| where TimeGenerated > ago(4h)
| where EventID == 4688
| where
    CommandLine has "lsass"
    or (NewProcessName has_any ("procdump", "tasklist", "comsvcs") and CommandLine has "lsass")
    or CommandLine has "MiniDump"
    or CommandLine matches regex @"(?i)rundll32.*comsvcs.*minidump"
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| project TimeGenerated, Host, Account, NewProcessName, CommandLine

// Password spray: failed auth storm then success
let fails = SecurityEvent
    | where TimeGenerated > ago(4h)
    | where EventID == 4625
    | summarize FailCount = count(), TargetAccounts = make_set(TargetUserName)
        by IpAddress, bin(TimeGenerated, 10m)
    | where FailCount >= 5 and array_length(TargetAccounts) >= 3;
let successes = SecurityEvent
    | where TimeGenerated > ago(4h)
    | where EventID == 4624
    | project SuccessTime = TimeGenerated, IpAddress, TargetUserName;
fails
| join kind=inner successes on IpAddress
| where SuccessTime > TimeGenerated
| project IpAddress, FailCount, TargetAccounts, SuccessTime, TargetUserName

ioc_key Format

PROD:host:{Host}:lsass_access
PROD:ip:{IpAddress}:password_spray

Key Implementation Notes

  • LSASS access = CRITICAL, no threshold — one row is enough
  • Password spray: the join KQL above is expensive; cache the result and call it once per run rather than in a loop
  • If the spray source is an internal IP, elevate to CRITICAL (internal spray = already compromised host). External IP = HIGH.

3.4 Lateral Movement Agent

File: agents/kill_chain/lateral_movement_agent.pyMITRE: T1021.002 (SMB/Windows Admin Shares), T1550.002 (Pass the Hash) Severity: CRITICAL

What to Detect

  • Same account authenticating to 3+ distinct machines within 30 minutes (EventID 4624 — Logon Type 3 network logon)
  • EventID 4648: explicit credential use from a workstation to a server (Logon Type 9 or NewCredentials logon)
  • EventID 5140: network share object accessed where ShareName is C$ or ADMIN$

KQL Queries

kql
// Same account on 3+ machines in 30 min (EventID 4624)
SecurityEvent
| where TimeGenerated > ago(4h)
| where EventID == 4624
| where LogonType in (3)
| where TargetUserName !endswith "$"   // exclude computer accounts
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| summarize MachineCount = dcount(Host), Machines = make_set(Host)
    by TargetUserName, bin(TimeGenerated, 30m)
| where MachineCount >= 3
| order by MachineCount desc

// Explicit credential use workstation-to-server (EventID 4648)
SecurityEvent
| where TimeGenerated > ago(4h)
| where EventID == 4648
| where LogonType == 9
| extend SourceHost = toupper(tostring(split(Computer, ".")[0]))
| extend TargetHost = toupper(tostring(split(TargetServerName, ".")[0]))
| where SourceHost != TargetHost
| project TimeGenerated, SourceHost, TargetHost, SubjectUserName, TargetUserName

// Admin share access (EventID 5140)
SecurityEvent
| where TimeGenerated > ago(4h)
| where EventID == 5140
| where ShareName in ("\\\\*\\C$", "\\\\*\\ADMIN$", "\\\\*\\IPC$")
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| project TimeGenerated, Host, IpAddress, Account, ShareName, AccessMask

ioc_key Format

PROD:account:{TargetUserName}:multi_machine_logon
PROD:account:{SubjectUserName}:explicit_cred_lateral
PROD:host:{Host}:admin_share_access

Key Implementation Notes

  • Exclude service accounts (names ending in $) from the multi-machine logon check — computer accounts legitimately authenticate to many hosts
  • For admin share access, check whether the source IP belongs to a known IT admin workstation (get that list from the CMDB or maintain a short hardcoded allowlist). Unknown source = CRITICAL.
  • Break-glass accounts appearing in 4648 or 5140 = CRITICAL immediately, no allowlist check.

3.5 Exfiltration Agent

File: agents/kill_chain/exfiltration_agent.pyMITRE: T1048 (Exfiltration Over Alternative Protocol), T1567 (Exfiltration Over Web Service) Severity: HIGH

What to Detect

  • Palo Alto CEF logs (CommonSecurityLog): outbound traffic where SentBytes exceeds a threshold (start with 500 MB per session) to destinations not seen in last 30 days
  • App-ID matches for known exfil tools: rclone, winscp, megaupload, mega-nz, dropbox, onedrive (if not a sanctioned tool) — query ApplicationProtocol in CEF
  • Large transfers outside business hours (06:00–20:00 PST)
  • DNS Security alerts for known data staging domains

KQL Query

kql
// Palo Alto CEF — large outbound transfers to new destinations
CommonSecurityLog
| where TimeGenerated > ago(4h)
| where DeviceVendor == "Palo Alto Networks"
| where CommunicationDirection == "Outbound"
| where SentBytes > 524288000   // 500 MB
| extend DestinationKey = strcat(DestinationIP, "|", DestinationPort)
// Check if this destination has been seen in the last 30 days
| join kind=leftanti (
    CommonSecurityLog
    | where TimeGenerated > ago(30d) and TimeGenerated < ago(4h)
    | where DeviceVendor == "Palo Alto Networks"
    | distinct DestinationIP
) on DestinationIP
| project TimeGenerated, SourceIP, DestinationIP, DestinationPort,
    SentBytes, ApplicationProtocol, FileName

// App-ID exfil tools
CommonSecurityLog
| where TimeGenerated > ago(4h)
| where DeviceVendor == "Palo Alto Networks"
| where ApplicationProtocol has_any ("rclone", "winscp", "mega", "dropbox", "box")
| project TimeGenerated, SourceIP, DestinationIP, SentBytes, ApplicationProtocol

// After-hours large transfers (adjust for UTC offset — PST is UTC-8)
CommonSecurityLog
| where TimeGenerated > ago(4h)
| where DeviceVendor == "Palo Alto Networks"
| where SentBytes > 104857600   // 100 MB
| extend HourUTC = hourofday(TimeGenerated)
| where HourUTC < 14 or HourUTC > 4   // outside 06:00-20:00 PST in UTC
| project TimeGenerated, SourceIP, DestinationIP, SentBytes, ApplicationProtocol

ioc_key Format

PROD:ip:{SourceIP}:large_transfer_new_destination
PROD:ip:{SourceIP}:exfil_tool_detected
PROD:ip:{SourceIP}:after_hours_large_transfer

Key Implementation Notes

  • The 500 MB threshold is a starting point — tune it after the first few weeks based on observed legitimate transfer sizes (backup jobs, software deployments).
  • DDE environment exfil is particularly sensitive — DDE hosts Medicare/PHI app. Lower the threshold to 100 MB for DDE.
  • Exfiltration is HIGH by default. Elevate to CRITICAL if App-ID matches a known exfil tool (rclone, mega.nz) — those are not legitimate in this environment.

3.6 Defense Evasion Agent

File: agents/kill_chain/defense_evasion_agent.pyMITRE: T1070.001 (Clear Windows Event Logs), T1562 (Impair Defenses) Severity: CRITICAL for log clearing; HIGH for Cortex silence

What to Detect

  • EventID 1102: Security audit log cleared — CRITICAL, no threshold, immediate page
  • Cortex returning 0 findings while other kill chain agents have elevated findings in the same run — suggests Cortex is being evaded or disabled
  • Security tool process termination (if Cortex process heartbeat goes silent)

KQL Query (EventID 1102)

kql
SecurityEvent
| where TimeGenerated > ago(4h)
| where EventID == 1102
| extend Host = toupper(tostring(split(Computer, ".")[0]))
| project TimeGenerated, Host, Account, SubjectUserName
| order by TimeGenerated desc

Cortex Silence Detection

The Cortex silence check reads from the Cortex agent's own result dict. This is an inter-agent check — the defense evasion agent needs to access what Cortex returned this cycle. The orchestrator collects all results before calling SecurityAgent; defense evasion runs last and can read the other results from a shared dict.

python
# In orchestrator.py, pass the collected results to defense_evasion_agent:
defense_evasion_result = defense_evasion_agent.run(peer_results=all_results)
python
# In defense_evasion_agent.run():
def _check_cortex_silence(peer_results: dict) -> dict | None:
    cortex_result = peer_results.get("cortex_agent", {})
    cortex_findings = cortex_result.get("findings", [])
    cortex_confidence = cortex_result.get("confidence", 100)

    # Any kill chain agent with elevated findings?
    kill_chain_elevated = any(
        any(f.get("severity") in ("critical", "high") for f in peer_results.get(ag, {}).get("findings", []))
        for ag in ("execution_agent", "lateral_movement_agent", "credential_dumping_agent")
    )

    if cortex_confidence > 0 and len(cortex_findings) == 0 and kill_chain_elevated:
        return {
            "severity": "high",
            "resource": "CORTEX-XDR",
            "description": (
                "Cortex XDR returned zero findings this cycle while other kill chain agents "
                "reported elevated detections. This may indicate Cortex is being evaded, "
                "disabled, or its sensor is unhealthy."
            ),
            "evidence": f"Cortex findings: 0. Kill chain agents elevated: {kill_chain_elevated}",
            "hipaa_risk": (
                "HIPAA §164.312(a)(1) — Access Control. EDR silence during active attack "
                "indicators means critical visibility loss."
            ),
            "recommended_action": (
                "1. Verify Cortex sensor status on all hosts in Cortex console. "
                "2. Check for Cortex service termination (EventID 4688 on Cortex processes). "
                "3. Cross-reference with Arctic Wolf MDR. "
                "4. Escalate to Rory."
            ),
            "mitre_technique": "T1562.001",
            "mitre_tactic": "Defense Evasion",
            "source_system": SOURCE_SYSTEM,
            "ioc_key": f"{SOURCE_SYSTEM}:tool:cortex-xdr:silence_during_elevated_activity",
        }
    return None

ioc_key Format

PROD:host:{Host}:audit_log_cleared
PROD:tool:cortex-xdr:silence_during_elevated_activity

Key Implementation Notes

  • EventID 1102 is the single highest-priority finding in the entire agent fleet. One row = CRITICAL, immediate page, no suppression. Do not add 1102 to known-good rules under any circumstances.
  • If 1102 appears on a break-glass account — that is a double CRITICAL. Treat as active incident.
  • The Cortex silence check requires orchestrator cooperation. If the orchestrator does not pass peer_results, the check degrades gracefully (returns None).

4. Testing Locally

Prerequisites

bash
# From the bedrock-hub repo root
cd agents/

# Required environment variables — pull from Key Vault or use dev values
export LAW_WORKSPACE_ID=5d76d1f2-XXXX-XXXX-XXXX-XXXXXXXXXXXX   # cirius-logging-law-central
export DDE_LAW_WORKSPACE_ID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
export SECOPS_API_URL=https://secops.bedrockcybersecurity.org
export INGEST_API_KEY=<key from Key Vault cirius-openai-kv-prod>
export SOURCE_SYSTEM=PROD   # or DDE

Do not hardcode these. If you need to test against dev data, ask Rory for a dev API key.

Running Against the Dev LAW Workspace

The dev LAW workspace has limited data. For kill chain agents, you need real SecurityEvent data — that only flows to cirius-logging-law-central (the Sentinel-enabled workspace). Query against PROD LAW in read-only mode: run_kql_query is always read-only (KQL queries never mutate data).

For an agent that only has 4h lookback, backfill the time window for testing:

python
# Temporarily override the lookback in your query for testing:
# Change "ago(4h)" → "ago(7d)" to catch historical events
# Revert before opening the PR

Mocking the SecOps API

Use the mock client when you want to test finding generation without hitting the real API:

bash
# Start the mock server (included in bedrock-hub/tests/)
python tests/mock_secops_server.py &

export SECOPS_API_URL=http://localhost:8999

The mock server accepts any POST /api/ingest/findings, validates the schema, and returns 201. It logs every payload to stdout so you can inspect what your agent posts.

Alternatively, stub secops_client inline for fast iteration:

python
# In your test script (NOT in the agent itself):
import unittest.mock as mock
import kill_chain.execution_agent as agent

with mock.patch("secops_client.post_findings") as mock_post, \
     mock.patch("secops_client.register_queries"):
    mock_post.return_value = {"status": "ok", "count": 0}
    result = agent.run()

assert isinstance(result["findings"], list), "findings must be a list"
assert isinstance(result["confidence"], int), "confidence must be int"
assert 0 <= result["confidence"] <= 100, "confidence out of range"
assert "summary" in result, "summary required"
print(result)

Verifying Finding Format

Before opening a PR, run the format checker:

bash
python tests/validate_finding_schema.py kill_chain/execution_agent.py

This calls run(), captures the result, and validates:

  • findings is a list (not None, not a dict)
  • Each finding has all required keys
  • severity is one of critical|high|medium|low
  • source_system is present on every finding
  • ioc_key starts with the correct SOURCE_SYSTEM prefix
  • confidence is an integer 0–100
  • immediate_action_required is a bool

Fix every validation error before opening the PR. The CI gate runs the same check.

Standalone Run

Every agent has an if __name__ == "__main__": block. Run it directly to see full output:

bash
python -m kill_chain.execution_agent
# or
python kill_chain/execution_agent.py

Exit code 1 means immediate_action_required was True — a sign the query hit something real. Inspect the JSON output to confirm findings look correct before posting to the API.

Common Test Scenarios

No data in LAW (clean environment): Agent should return findings: [] and a descriptive summary. Exit 0.

Synthetic test event: Insert a test EventID 4688 row into LAW using the Data Collection Endpoint (DCE). This is the only valid way to test against real LAW. Ask Rory for the DCE endpoint URL — do not create test events in production SecurityEvent.

API unreachable: Kill the mock server and run the agent. It should return confidence: 0 and a clear error summary. It must not raise.

Break-glass account in results: Inject a result row with a break-glass account name and confirm the agent fires CRITICAL with no threshold check.


5. Deploying

Adding a New Agent to the Orchestrator

Kill chain agents register in the PROD orchestrator (orchestrator.py). DDE kill chain agents (DDE environment variants) register in mini_orchestrator_dde.py.

Step 1: Import the module

python
# orchestrator.py — at the top, with the other imports
from kill_chain import execution_agent
from kill_chain import persistence_agent
from kill_chain import credential_dumping_agent
from kill_chain import lateral_movement_agent
from kill_chain import exfiltration_agent
from kill_chain import defense_evasion_agent

Step 2: Add to the parallel pool

python
# orchestrator.py — in run_mini_agents()
futures = {
    executor.submit(execution_agent.run):          "kill_chain_execution",
    executor.submit(persistence_agent.run):        "kill_chain_persistence",
    executor.submit(credential_dumping_agent.run): "kill_chain_credential_dumping",
    executor.submit(lateral_movement_agent.run):   "kill_chain_lateral_movement",
    executor.submit(exfiltration_agent.run):       "kill_chain_exfiltration",
    # Defense evasion runs LAST — needs peer results
    # Handled separately after pool completes
    ...
}

Because defense_evasion_agent.run() takes peer_results as an argument, it cannot run in the parallel pool with the others. Add it as a sequential step after the pool joins:

python
# After all futures resolve:
all_results = {name: future.result() for future, name in futures.items()}
defense_result = defense_evasion_agent.run(peer_results=all_results)
all_results["kill_chain_defense_evasion"] = defense_result

Step 3: Register in run_agent.py

python
AGENTS = [
    ...
    ("kill_chain_execution",          "kill_chain.execution_agent"),
    ("kill_chain_persistence",        "kill_chain.persistence_agent"),
    ("kill_chain_credential_dumping", "kill_chain.credential_dumping_agent"),
    ("kill_chain_lateral_movement",   "kill_chain.lateral_movement_agent"),
    ("kill_chain_exfiltration",       "kill_chain.exfiltration_agent"),
    ("kill_chain_defense_evasion",    "kill_chain.defense_evasion_agent"),
]

Container Apps Job Build/Deploy Pipeline

Never deploy locally. All deployments go through GitHub Actions.

Branch → PR → CI runs → Rory reviews → Merge → GitHub Actions deploys

CI steps on every PR:

  1. ruff check agents/ — linting
  2. python -m pytest tests/ — unit tests including schema validation
  3. python tests/validate_finding_schema.py — format check for all agents
  4. Build Docker image (no push on PRs)

On merge to main:

  1. Docker image built and pushed to ciriusagentsprod.azurecr.io
  2. Container Apps Job (job-orchestrator-cirius) updated to the new image revision
  3. Next scheduled run picks up the new agents automatically

To force an immediate run after deploy (do not wait for the 4-hour cycle):

Azure Portal → rg-logging-logs → job-orchestrator-cirius → Run now

Or via CLI:

bash
az containerapp job start \
    --name job-orchestrator-cirius \
    --resource-group rg-logging-logs

Verifying the Deploy

After the job runs, check:

  1. Heartbeat email received (every run sends one)
  2. SecOps platform shows new findings from kill chain agents
  3. No confidence: 0 results in the heartbeat — that indicates a failed agent

If an agent fails silently (wrong import path, missing env var), the heartbeat email will show confidence: 0 and an error summary for that agent. Fix in a new PR.


6. Tuning and Known-Good Rules

How Known-Good Rules Work

The AnalystAgent fetches rules from GET /api/known-good at the start of each run. When a finding matches a known-good rule, the incident moves from NEW to CLOSED automatically. This reduces alert fatigue for recurring benign events.

Kill chain agents can have known-good rules, but the bar is very high.

When to Add a Known-Good Rule for Kill Chain Agents

Only add a known-good rule when all three conditions are met:

  1. You have independently verified the activity is legitimate — not just "the user said it's fine." You checked the binary hash, the account, the parent process.
  2. The activity is predictable and repeatable — it will recur on a known schedule or from a known source. One-off legitimate events should be closed manually, not suppressed.
  3. Rory has explicitly approved it — no kill chain known-good rule goes in without sign-off. This includes persistence, execution, and lateral movement rules.

What Never Gets a Known-Good Rule

  • EventID 1102 (audit log cleared) — never. Not ever. Not for any account.
  • Any finding involving a break-glass account — hard-blocked at the API level.
  • LSASS access — legitimate software does not access LSASS.
  • Multi-machine lateral movement patterns — even IT admins should be using PAM, not interactive lateral movement.
  • Execution from temp/AppData paths — no legitimate software self-installs this way in a managed environment.

Writing a Tight Known-Good Rule

Known-good rules match on ioc_key prefix, account, and optionally host. Be as specific as possible. Broad rules will suppress real attacks.

Bad rule (too broad):

json
{ "ioc_key_prefix": "PROD:host:", "detection_type": "new_service_outside_cm" }

Good rule (specific):

json
{
    "ioc_key": "PROD:host:SRV-BACKUP-01:new_service_outside_cm",
    "account": "svc-veeam@ciriusgroup.com",
    "service_name": "VeeamBackupSvc",
    "justification": "Veeam self-updates service binary on version upgrade. Approved 2026-05-10.",
    "approved_by": "Rory",
    "review_date": "2026-08-10"
}

Every rule must have approved_by and review_date. Rules expire every 90 days and require re-approval. The platform will surface expiring rules in the weekly digest.

Tuning Thresholds vs. Adding Rules

Prefer tuning detection thresholds over adding known-good rules. If the exfiltration agent fires on legitimate backup traffic, raise the byte threshold — don't add a known-good rule for the backup server. Threshold changes are in code, reviewed in PRs, and apply uniformly. Known-good rules are exceptions to detection, and exceptions rot.


7. Cross-Environment Differences

PROD vs. DDE vs. AWS

Kill chain detection applies to all three environments, but the context differs.

PROD (Azure Production — ciriusgroup.com)

  • Source system: SOURCE_SYSTEM = "PROD"
  • LAW workspace: LAW_WORKSPACE_ID env var → cirius-logging-law-central (5d76d1f2)
  • KQL tables available: SecurityEvent, SignInLogs, AuditLogs, Syslog, CommonSecurityLog (Palo Alto CEF), Twingate_CL, AzureActivity, ConfigurationChange
  • Orchestrator: orchestrator.py → deployed in job-orchestrator-cirius
  • Windows Event data (4688, 4698, 7045, 4648, 5140, 1102): available
  • Palo Alto CEF: available (all 4 firewalls, Panorama-managed)
  • Cortex XDR: available on all Windows VMs and managed devices

DDE (Azure DDE — ciriusdde.com)

  • Source system: SOURCE_SYSTEM = "DDE"
  • LAW workspace: DDE_LAW_WORKSPACE_ID env var → ciriusdde-logging-law-central
  • Orchestrator: mini_orchestrator_dde.py → deployed in job-orchestrator-cirius
  • DDE hosts Medicare/PHI apps (published via AVD) — exfil threshold lower (100 MB vs 500 MB)
  • DDE user accounts are @ciriusdde.com — ensure KQL filters match the correct UPN suffix
  • DDE break-glass: cirius-breakglass@ciriusdde.com — same unsuppressible rule applies
  • Windows Event data: same EventIDs available if DCR is correctly pointed at DDE LAW
  • Palo Alto: same firewall infrastructure, but DDE traffic may use different zones — verify CEF DeviceCustomString3 for DDE zone names before writing exfil queries

AWS (7-account org)

  • Source system: SOURCE_SYSTEM = "AWS"
  • No LAW — data sources are CloudTrail, Security Hub, GuardDuty, IAM Access Analyzer
  • Orchestrator: mini_orchestrator_aws.py → deployed in job-orchestrator-cirius
  • Kill chain detection in AWS focuses on IAM-level indicators:
    • Execution: Lambda/ECS task creation with unusual IAM roles
    • Persistence: new IAM user, access key creation, root console login
    • Credential Dumping: GetSecretValue calls at high rate, Secrets Manager anomalies
    • Lateral Movement: AssumeRole chains across accounts, unusual cross-account calls
    • Exfiltration: S3 GetObject from new external principals, large data egress in CloudTrail
    • Defense Evasion: CloudTrail disabled, GuardDuty suppressed, S3 access logging disabled
  • AWS agents use boto3, not KQL — see aws/cloudtrail_agent.py for the cross-account assume-role pattern
  • AWS root account activity = CRITICAL immediately, same as PROD break-glass

Writing Dual-Environment Agents (PROD + DDE)

When the detection logic is identical but the workspace differs, use SOURCE_SYSTEM to branch:

python
SOURCE_SYSTEM = os.environ.get("SOURCE_SYSTEM", "PROD")

# The kql_utils module reads the workspace from env based on SOURCE_SYSTEM:
# SOURCE_SYSTEM == "PROD" → uses LAW_WORKSPACE_ID
# SOURCE_SYSTEM == "DDE"  → uses DDE_LAW_WORKSPACE_ID
results = run_kql_query(QUERY)   # kql_utils handles workspace routing internally

Deploy the same agent module in both orchestrator.py and mini_orchestrator_dde.py. The Container Apps Jobs set SOURCE_SYSTEM as an environment variable — no code change needed to run the same agent in both environments.

For environment-specific threshold differences, use a dict keyed on SOURCE_SYSTEM:

python
EXFIL_THRESHOLD_BYTES = {
    "PROD": 524_288_000,   # 500 MB
    "DDE":  104_857_600,   # 100 MB — lower for PHI environment
    "AWS":  1_073_741_824, # 1 GB — S3 transfers are larger by nature
}

threshold = EXFIL_THRESHOLD_BYTES.get(SOURCE_SYSTEM, 524_288_000)


Document History

DateChange
May 2026Initial draft — kill chain Phase 2 build guide for 6 agents. Covers architecture, all 6 agent specs with KQL, code skeletons for execution and persistence agents, local testing, deploy, tuning policy, and cross-environment differences.

Internal use only — Cirius Group