Skip to content

Kill Chain Phase 2 — Agent Development and Deployment Runbook

Purpose

This is the complete build-and-deploy guide for the six kill chain Phase 2 detection agents at Cirius Group. It assumes Phase 1 (event collection) is complete and events are flowing to cirius-logging-law-central. If you're starting here, verify Phase 1 first — see Kill Chain Phase 1 Enablement.

This document is the bus-test document for this workstream. Someone who has not touched this codebase before should be able to build, test, and deploy all six agents by following it end to end.

HIPAA note: All six agents generate findings that become part of the Cirius Group audit record under §164.312(b) (Audit Controls). Every incident payload must include ioc_key, raw_data, and a description that a reasonable auditor can read to understand what was detected, when, and on which host.


Architecture Overview

How Phase 1 and Phase 2 Relate

Phase 1: Windows endpoints → AMA → DCR → SecurityEvent (LAW cirius-logging-law-central)
Phase 2: LAW ← KQL query ← Kill chain agents → POST /api/incidents → SecOps platform

Phase 1 gets the events into the workspace. Phase 2 agents query the workspace, detect patterns, and fire incidents to SecOps. The SecurityAgent on the platform then makes the final incident determination — it checks for CM tickets, maintenance windows, and known-good rules before opening an alert for analyst review.

The Six Agents and Kill Chain Mapping

AgentFileKill Chain StagePrimary EventIDsDefault Severity
Executionagents/kill_chain/execution_agent.pyExecution4688CRITICAL
Persistenceagents/kill_chain/persistence_agent.pyPersistence7045, 4698HIGH
Credential Dumpingagents/kill_chain/credential_agent.pyCredential Access4688, 4624, 4625CRITICAL
Lateral Movementagents/kill_chain/lateral_movement_agent.pyLateral Movement4624, 4648, 5140CRITICAL
Exfiltrationagents/kill_chain/exfiltration_agent.pyExfiltrationPalo Alto traffic via LAWHIGH
Defense Evasionagents/kill_chain/defense_evasion_agent.pyDefense Evasion1102 + cross-agentCRITICAL/HIGH

Infrastructure Reference

ResourceValue
Container Apps Jobca-secops-prod in rg-logging-logs, logging subscription
Container registryciriusagentsprod.azurecr.io
LAW workspace ID5d76d1f2
LAW workspace resource ID/subscriptions/{logging-sub}/resourceGroups/rg-logging-logs/providers/Microsoft.OperationalInsights/workspaces/cirius-logging-law-central
Key Vaultcirius-openai-kv-prod
SecOps APIhttps://secops.bedrockcybersecurity.org
SecOps API key secret namesecops-api-key (in cirius-openai-kv-prod)
Source system (Azure PROD)PROD

Authentication Model

Agents running in Container Apps use a managed identity automatically via DefaultAzureCredential. No credentials are stored in code or environment variables. The managed identity needs two permissions:

  1. Log Analytics Reader on cirius-logging-law-central — to run KQL queries
  2. Key Vault Secrets User on cirius-openai-kv-prod — to fetch the SecOps API key

Both should already be set on the Container Apps Job managed identity from existing agents. Verify before deploying if the job's managed identity changes.

Lookback Window and Overlap

Each agent queries the last 4 hours and 15 minutes. The 15-minute overlap prevents gaps at schedule boundaries — if a run is delayed, events at the edge of the previous window are still captured.

Deduplication is handled via ioc_key. If the same event fires in two consecutive runs, the SecOps /api/incidents endpoint ignores the second POST (the ioc_key is already in the database).


Shared Agent Pattern

Before reading individual agent implementations, understand the base pattern everything inherits from. All six agents follow this structure.

Directory Structure

agents/
  kill_chain/
    __init__.py
    execution_agent.py
    persistence_agent.py
    credential_agent.py
    lateral_movement_agent.py
    exfiltration_agent.py
    defense_evasion_agent.py

Base Dependencies

These are already in the requirements.txt for the container image from existing agents. Confirm before adding:

azure-monitor-query>=1.2.0
azure-identity>=1.15.0
azure-keyvault-secrets>=4.7.0
requests>=2.31.0
python-dateutil>=2.8.2

Shared Utility: agents/kill_chain/__init__.py

python
"""
Kill chain Phase 2 agents — shared utilities.
All agents import from here.
"""
import os
import json
import logging
from datetime import datetime, timezone, timedelta
from typing import Optional

import requests
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
from azure.monitor.query import LogsQueryClient, LogsQueryStatus

logger = logging.getLogger(__name__)

SECOPS_API_URL = "https://secops.bedrockcybersecurity.org"
KEY_VAULT_URL = "https://cirius-openai-kv-prod.vault.azure.net"
LAW_WORKSPACE_ID = "5d76d1f2"
LOOKBACK_HOURS = 4
LOOKBACK_OVERLAP_MINUTES = 15
SOURCE_SYSTEM = "PROD"


def get_credential() -> DefaultAzureCredential:
    """Returns Azure credential. Works in Container Apps via managed identity."""
    return DefaultAzureCredential()


def get_secops_api_key() -> str:
    """Fetch SecOps API key from Key Vault. Cached at module level per run."""
    credential = get_credential()
    client = SecretClient(vault_url=KEY_VAULT_URL, credential=credential)
    secret = client.get_secret("secops-api-key")
    return secret.value


def get_query_window() -> tuple[datetime, datetime]:
    """
    Returns (start, end) for the LAW query window.
    4 hours + 15 minutes overlap to prevent gaps at run boundaries.
    """
    end = datetime.now(timezone.utc)
    start = end - timedelta(hours=LOOKBACK_HOURS, minutes=LOOKBACK_OVERLAP_MINUTES)
    return start, end


def run_kql_query(query: str) -> list[dict]:
    """
    Execute a KQL query against cirius-logging-law-central.
    Returns list of row dicts. Returns [] on query error (fail-open).
    """
    credential = get_credential()
    client = LogsQueryClient(credential)
    start, end = get_query_window()

    try:
        response = client.query_workspace(
            workspace_id=LAW_WORKSPACE_ID,
            query=query,
            timespan=(start, end),
        )
    except Exception as exc:
        logger.error("LAW query failed: %s", exc)
        return []

    if response.status == LogsQueryStatus.PARTIAL:
        logger.warning("LAW query returned partial results — treating as complete")

    if response.status == LogsQueryStatus.FAILURE:
        logger.error("LAW query returned failure status")
        return []

    rows = []
    if response.tables:
        table = response.tables[0]
        columns = [col.name for col in table.columns]
        for row in table.rows:
            rows.append(dict(zip(columns, row)))

    return rows


def post_incident(
    title: str,
    severity: str,
    ioc_key: str,
    description: str,
    raw_data: dict,
    api_key: Optional[str] = None,
) -> bool:
    """
    POST a finding to SecOps /api/incidents.
    Returns True on success (201 or 409 duplicate), False on error.
    409 is expected for deduplicated ioc_keys — treat as success.
    """
    if api_key is None:
        api_key = get_secops_api_key()

    payload = {
        "title": title,
        "severity": severity,
        "source_system": SOURCE_SYSTEM,
        "ioc_key": ioc_key,
        "description": description,
        "raw_data": json.dumps(raw_data),
    }

    try:
        resp = requests.post(
            f"{SECOPS_API_URL}/api/incidents",
            json=payload,
            headers={"X-API-Key": api_key},
            timeout=10,
        )
        if resp.status_code in (201, 409):
            return True
        logger.error(
            "Incident POST failed: status=%d body=%s ioc_key=%s",
            resp.status_code,
            resp.text[:200],
            ioc_key,
        )
        return False
    except requests.RequestException as exc:
        logger.error("Incident POST exception: %s ioc_key=%s", exc, ioc_key)
        return False

Agent 1: Execution Agent

File: agents/kill_chain/execution_agent.py

What it detects:

  • Processes with attack tool names in the command line (mimikatz, cobalt, mshta, etc.)
  • LOLBins (wscript, rundll32, cscript, certutil) spawned from Office or Explorer
  • Any process executing from %TEMP%, %AppData%, or user profile paths

Fires: CRITICAL, no analyst triage delay

KQL Query

kql
// Execution agent — suspicious process creation (EventID 4688)
// Lookback window injected by Python agent
SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4688
| where isnotempty(CommandLine)
| extend ProcessNameLower = tolower(NewProcessName)
| extend CommandLineLower = tolower(CommandLine)
| extend ParentProcessLower = tolower(ParentProcessName)
// --- Known-bad tool names ---
| extend ToolNameHit = (
    CommandLineLower has "mimikatz"
    or CommandLineLower has "sekurlsa"
    or CommandLineLower has "lsadump"
    or CommandLineLower has "cobalt strike"
    or CommandLineLower has "beacon.exe"
    or CommandLineLower has "cobaltstrike"
    or ProcessNameLower has "mshta.exe"
    or ProcessNameLower has "wscript.exe"
    or ProcessNameLower has "cscript.exe"
    or ProcessNameLower has "rundll32.exe"
    or ProcessNameLower has "certutil.exe"
    or ProcessNameLower has "regsvr32.exe"
    or ProcessNameLower has "msiexec.exe"
    or CommandLineLower has "invoke-expression"
    or CommandLineLower has "iex("
    or CommandLineLower has "-enc "
    or CommandLineLower has "downloadstring"
    or CommandLineLower has "webclient"
)
// --- Unexpected parent processes for LOLBins ---
| extend UnexpectedParent = (
    ParentProcessLower has_any ("explorer.exe", "winword.exe", "excel.exe", "outlook.exe", "powerpnt.exe", "onenote.exe", "msaccess.exe")
    and ProcessNameLower has_any ("wscript.exe", "cscript.exe", "rundll32.exe", "mshta.exe", "certutil.exe", "regsvr32.exe", "msiexec.exe", "cmd.exe", "powershell.exe")
)
// --- Execution from user-writable paths ---
| extend SuspiciousPath = (
    NewProcessName matches regex @"(?i)\\(temp|tmp|appdata|users\\[^\\]+\\(downloads|desktop|documents|appdata))\\.*\.(exe|dll|bat|ps1|vbs|js|hta|cmd)"
)
| where ToolNameHit or UnexpectedParent or SuspiciousPath
| project
    TimeGenerated,
    Computer,
    NewProcessName,
    ParentProcessName,
    CommandLine,
    SubjectUserName,
    ToolNameHit,
    UnexpectedParent,
    SuspiciousPath
| order by TimeGenerated desc

Python Implementation

python
"""
Execution Agent — Kill Chain Phase 2
Detects suspicious process creation (EventID 4688).

Detection logic:
  1. Known attack tool names in process name or command line
  2. LOLBins (wscript, rundll32, etc.) spawning from Office/Explorer
  3. Processes executing from user-writable paths (TEMP, AppData)

Fires CRITICAL — no analyst triage delay.

ioc_key format: EXEC:{hostname}:{process_name}:{parent_process}:{timestamp_minute}
"""
import logging
import sys
from datetime import datetime, timezone

from agents.kill_chain import (
    get_secops_api_key,
    post_incident,
    run_kql_query,
    SOURCE_SYSTEM,
)

logger = logging.getLogger(__name__)

EXECUTION_QUERY = """
SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4688
| where isnotempty(CommandLine)
| extend ProcessNameLower = tolower(NewProcessName)
| extend CommandLineLower = tolower(CommandLine)
| extend ParentProcessLower = tolower(ParentProcessName)
| extend ToolNameHit = (
    CommandLineLower has "mimikatz"
    or CommandLineLower has "sekurlsa"
    or CommandLineLower has "lsadump"
    or CommandLineLower has "cobalt strike"
    or CommandLineLower has "beacon.exe"
    or ProcessNameLower has "mshta.exe"
    or ProcessNameLower has "wscript.exe"
    or ProcessNameLower has "cscript.exe"
    or ProcessNameLower has "rundll32.exe"
    or ProcessNameLower has "certutil.exe"
    or ProcessNameLower has "regsvr32.exe"
    or CommandLineLower has "invoke-expression"
    or CommandLineLower has "iex("
    or CommandLineLower has "-enc "
    or CommandLineLower has "downloadstring"
)
| extend UnexpectedParent = (
    ParentProcessLower has_any ("explorer.exe", "winword.exe", "excel.exe", "outlook.exe", "powerpnt.exe", "onenote.exe")
    and ProcessNameLower has_any ("wscript.exe", "cscript.exe", "rundll32.exe", "mshta.exe", "certutil.exe", "regsvr32.exe", "cmd.exe", "powershell.exe")
)
| extend SuspiciousPath = (
    NewProcessName matches regex @"(?i)\\\\(temp|tmp|appdata|users\\\\[^\\\\]+\\\\(downloads|desktop|documents|appdata))\\\\.*\\.(exe|dll|bat|ps1|vbs|js|hta|cmd)"
)
| where ToolNameHit or UnexpectedParent or SuspiciousPath
| project TimeGenerated, Computer, NewProcessName, ParentProcessName, CommandLine, SubjectUserName, ToolNameHit, UnexpectedParent, SuspiciousPath
| order by TimeGenerated desc
"""


def _make_ioc_key(hostname: str, process_name: str, parent: str, ts: str) -> str:
    """
    Truncate timestamp to minute boundary so repeated detections of the same
    event in the overlap window do not create duplicate incidents.
    """
    proc = process_name.split("\\")[-1].lower() if process_name else "unknown"
    par = parent.split("\\")[-1].lower() if parent else "unknown"
    # Truncate ISO timestamp to minute: 2026-05-25T14:32:00
    try:
        dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
        ts_minute = dt.strftime("%Y-%m-%dT%H:%M:00")
    except (ValueError, TypeError):
        ts_minute = str(ts)[:16]
    host = hostname.split(".")[0].upper() if hostname else "UNKNOWN"
    return f"EXEC:{host}:{proc}:{par}:{ts_minute}"


def _detect_technique(row: dict) -> str:
    if row.get("ToolNameHit"):
        proc = (row.get("NewProcessName") or "").lower()
        cmd = (row.get("CommandLine") or "").lower()
        if "mimikatz" in cmd or "sekurlsa" in cmd or "lsadump" in cmd:
            return "mimikatz/credential-tool"
        if "cobalt" in cmd or "beacon" in cmd:
            return "cobalt-strike-beacon"
        if any(t in proc for t in ["mshta", "wscript", "cscript"]):
            return "script-host-execution"
        if "invoke-expression" in cmd or "iex(" in cmd or "-enc " in cmd:
            return "powershell-obfuscation"
        if "downloadstring" in cmd:
            return "in-memory-download"
        return "known-attack-tool"
    if row.get("UnexpectedParent"):
        return "office-spawned-lolbin"
    if row.get("SuspiciousPath"):
        return "execution-from-user-writable-path"
    return "suspicious-execution"


def run() -> int:
    """Returns number of incidents posted."""
    logger.info("Execution agent starting")
    api_key = get_secops_api_key()

    rows = run_kql_query(EXECUTION_QUERY)
    logger.info("Execution agent: %d raw hits from LAW", len(rows))

    posted = 0
    seen_ioc_keys: set[str] = set()

    for row in rows:
        hostname = row.get("Computer", "UNKNOWN")
        process_name = row.get("NewProcessName", "")
        parent_process = row.get("ParentProcessName", "")
        command_line = row.get("CommandLine", "")
        username = row.get("SubjectUserName", "")
        ts = row.get("TimeGenerated", "")

        ioc_key = _make_ioc_key(hostname, process_name, parent_process, ts)
        if ioc_key in seen_ioc_keys:
            continue
        seen_ioc_keys.add(ioc_key)

        technique = _detect_technique(row)
        proc_short = process_name.split("\\")[-1] if process_name else "unknown"
        par_short = parent_process.split("\\")[-1] if parent_process else "unknown"

        title = f"Suspicious process execution: {proc_short} on {hostname.split('.')[0]}"
        description = (
            f"Kill chain execution stage alert on {hostname}. "
            f"Technique: {technique}. "
            f"Process: {proc_short} spawned by {par_short}. "
            f"User: {username}. "
            f"Command line: {command_line[:300]}. "
            f"EventID 4688. Source: {SOURCE_SYSTEM}. "
            f"HIPAA audit note: process creation event captured for audit trail per §164.312(b)."
        )

        raw_data = {
            "event_id": 4688,
            "hostname": hostname,
            "process_name": process_name,
            "parent_process": parent_process,
            "command_line": command_line,
            "username": username,
            "timestamp": str(ts),
            "technique": technique,
            "ioc_key": ioc_key,
        }

        success = post_incident(
            title=title,
            severity="CRITICAL",
            ioc_key=ioc_key,
            description=description,
            raw_data=raw_data,
            api_key=api_key,
        )
        if success:
            posted += 1
            logger.info("Execution incident posted: %s", ioc_key)

    logger.info("Execution agent done: %d incidents posted from %d hits", posted, len(rows))
    return posted


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    sys.exit(0 if run() >= 0 else 1)

Agent 2: Persistence Agent

File: agents/kill_chain/persistence_agent.py

What it detects:

  • EventID 7045: new Windows service installed (System log, collected via Sentinel DCR)
  • EventID 4698: new scheduled task created
  • Note: FIM registry run key monitoring is handled by the Cortex XDR FIM module, not this agent. This agent covers the Windows event-based persistence signals.

Fires: HIGH (any persistence mechanism outside a CM window)

KQL Query

kql
// Persistence agent — new service installs and scheduled task creation
// Run both sub-queries and union results

// 7045 — new service installed
let new_services = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 7045
| project
    TimeGenerated,
    Computer,
    EventID,
    ServiceName,
    ServiceFileName = CommandLine,
    AccountName = SubjectUserName,
    MechanismType = "new_service";

// 4698 — new scheduled task
let new_tasks = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4698
| project
    TimeGenerated,
    Computer,
    EventID,
    ServiceName = TaskName,
    ServiceFileName = CommandLine,
    AccountName = SubjectUserName,
    MechanismType = "scheduled_task";

union new_services, new_tasks
| order by TimeGenerated desc

Python Implementation

python
"""
Persistence Agent — Kill Chain Phase 2
Detects new service installs (7045) and scheduled task creation (4698).

Any persistence mechanism created outside an approved CM window fires HIGH.
The SecurityAgent on the SecOps platform checks CM windows — this agent
fires unconditionally and lets SecOps suppress if a CM ticket matches.

ioc_key format: PERSIST:{hostname}:{mechanism_type}:{name}:{timestamp_minute}
"""
import logging
import sys
from datetime import datetime

from agents.kill_chain import (
    get_secops_api_key,
    post_incident,
    run_kql_query,
    SOURCE_SYSTEM,
)

logger = logging.getLogger(__name__)

PERSISTENCE_QUERY = """
let new_services = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 7045
| project TimeGenerated, Computer, EventID, ServiceName, ServiceFileName = CommandLine, AccountName = SubjectUserName, MechanismType = "new_service";
let new_tasks = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4698
| project TimeGenerated, Computer, EventID, ServiceName = TaskName, ServiceFileName = CommandLine, AccountName = SubjectUserName, MechanismType = "scheduled_task";
union new_services, new_tasks
| order by TimeGenerated desc
"""

MECHANISM_LABELS = {
    "new_service": "New Windows service installed",
    "scheduled_task": "New scheduled task created",
}

EVENT_NAMES = {
    7045: "New service installed",
    4698: "New scheduled task",
}


def _make_ioc_key(hostname: str, mechanism_type: str, name: str, ts) -> str:
    try:
        dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
        ts_minute = dt.strftime("%Y-%m-%dT%H:%M:00")
    except (ValueError, TypeError):
        ts_minute = str(ts)[:16]
    host = hostname.split(".")[0].upper() if hostname else "UNKNOWN"
    safe_name = (name or "unknown").replace(" ", "_").replace("\\", "_")[:60]
    return f"PERSIST:{host}:{mechanism_type}:{safe_name}:{ts_minute}"


def run() -> int:
    logger.info("Persistence agent starting")
    api_key = get_secops_api_key()

    rows = run_kql_query(PERSISTENCE_QUERY)
    logger.info("Persistence agent: %d raw hits from LAW", len(rows))

    posted = 0
    seen_ioc_keys: set[str] = set()

    for row in rows:
        hostname = row.get("Computer", "UNKNOWN")
        mechanism_type = row.get("MechanismType", "unknown")
        name = row.get("ServiceName", "")
        filename = row.get("ServiceFileName", "")
        account = row.get("AccountName", "")
        event_id = row.get("EventID", 0)
        ts = row.get("TimeGenerated", "")

        ioc_key = _make_ioc_key(hostname, mechanism_type, name, ts)
        if ioc_key in seen_ioc_keys:
            continue
        seen_ioc_keys.add(ioc_key)

        mechanism_label = MECHANISM_LABELS.get(mechanism_type, mechanism_type)
        host_short = hostname.split(".")[0]

        title = f"Persistence mechanism: {mechanism_label} on {host_short}"
        description = (
            f"Kill chain persistence stage alert on {hostname}. "
            f"Mechanism: {mechanism_label}. "
            f"Name: {name}. "
            f"Executable path: {filename}. "
            f"Account: {account}. "
            f"EventID: {event_id}. "
            f"Any new persistence outside an approved CM window is HIGH severity. "
            f"SecOps will suppress if a matching CM ticket exists. "
            f"Source: {SOURCE_SYSTEM}. "
            f"HIPAA audit note: persistence event recorded per §164.312(b) audit controls."
        )

        raw_data = {
            "event_id": event_id,
            "hostname": hostname,
            "mechanism_type": mechanism_type,
            "name": name,
            "filename": filename,
            "account": account,
            "timestamp": str(ts),
            "ioc_key": ioc_key,
        }

        success = post_incident(
            title=title,
            severity="HIGH",
            ioc_key=ioc_key,
            description=description,
            raw_data=raw_data,
            api_key=api_key,
        )
        if success:
            posted += 1
            logger.info("Persistence incident posted: %s", ioc_key)

    logger.info("Persistence agent done: %d incidents posted from %d hits", posted, len(rows))
    return posted


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    sys.exit(0 if run() >= 0 else 1)

Agent 3: Credential Dumping Agent

File: agents/kill_chain/credential_agent.py

What it detects:

  • LSASS access by unexpected processes (4688 where NewProcessName accesses lsass via command line patterns)
  • Credential spraying: multiple 4625 failures followed by 4624 success against 3+ different machines within 15 minutes from the same account
  • Note: DCSync detection (4662) requires Domain Controller auditing enabled per Phase 1 GPO — include if that GPO is deployed

Fires: CRITICAL

KQL Query

kql
// Credential dumping agent — LSASS access and credential spraying
// Query 1: LSASS access via suspicious processes (4688)
let lsass_access = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4688
| where tolower(CommandLine) has "lsass"
      or (tolower(NewProcessName) has_any ("procdump", "tasklist", "processhacker", "wce.exe", "fgdump", "pwdump"))
      or (tolower(CommandLine) has_any ("minidump", "sekurlsa", "lsadump", "hashdump"))
| where not(tolower(NewProcessName) has_any (
      "mssense.exe",       // MDE Sense
      "cyserver.exe",      // Cylance
      "xagt.exe",          // Cortex XDR
      "mcshield.exe",      // McAfee
      "mssecflt.sys"       // MDE filter
  ))
| project
    TimeGenerated,
    Computer,
    AttackType = "lsass_access",
    AttackingProcess = NewProcessName,
    TargetInfo = CommandLine,
    AccountName = SubjectUserName,
    TargetCount = 1;

// Query 2: Credential spraying — same account, failures then success, 3+ machines, 15 min window
let failures = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4625
| project FailTime = TimeGenerated, FailAccount = TargetUserName, FailHost = Computer;
let successes = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4624
| where LogonType in (3, 10)
| project SuccessTime = TimeGenerated, SuccessAccount = TargetUserName, SuccessHost = Computer;
let spray = failures
| join kind=inner successes on $left.FailAccount == $right.SuccessAccount
| where SuccessTime > FailTime
| where SuccessTime - FailTime < 15m
| summarize
    FailCount = count(),
    UniqueTargets = dcount(FailHost),
    FirstEvent = min(FailTime),
    AttackingProcess = "credential-spray",
    TargetInfo = strcat("Success on ", SuccessHost, " after ", tostring(count()), " failures")
    by AccountName = FailAccount, Computer = SuccessHost
| where UniqueTargets >= 3
| project
    TimeGenerated = FirstEvent,
    Computer,
    AttackType = "credential_spray",
    AttackingProcess,
    TargetInfo,
    AccountName,
    TargetCount = UniqueTargets;

union lsass_access, spray
| order by TimeGenerated desc

Python Implementation

python
"""
Credential Dumping Agent — Kill Chain Phase 2
Detects LSASS access by unexpected processes and credential spraying.

LSASS access: EventID 4688 with command line indicating LSASS targeting.
Credential spray: same account, 3+ failed auth targets, then success, within 15 min.

Fires CRITICAL unconditionally.

ioc_key format: CRED:{hostname}:{technique}:{target}:{timestamp_minute}
"""
import logging
import sys
from datetime import datetime

from agents.kill_chain import (
    get_secops_api_key,
    post_incident,
    run_kql_query,
    SOURCE_SYSTEM,
)

logger = logging.getLogger(__name__)

CREDENTIAL_QUERY = """
let lsass_access = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4688
| where tolower(CommandLine) has "lsass"
    or (tolower(NewProcessName) has_any ("procdump", "tasklist", "processhacker", "wce.exe", "fgdump", "pwdump"))
    or (tolower(CommandLine) has_any ("minidump", "sekurlsa", "lsadump", "hashdump"))
| where not(tolower(NewProcessName) has_any ("mssense.exe", "cyserver.exe", "xagt.exe", "mcshield.exe"))
| project TimeGenerated, Computer, AttackType = "lsass_access", AttackingProcess = NewProcessName, TargetInfo = CommandLine, AccountName = SubjectUserName, TargetCount = 1;
let failures = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4625
| project FailTime = TimeGenerated, FailAccount = TargetUserName, FailHost = Computer;
let successes = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4624
| where LogonType in (3, 10)
| project SuccessTime = TimeGenerated, SuccessAccount = TargetUserName, SuccessHost = Computer;
let spray = failures
| join kind=inner successes on $left.FailAccount == $right.SuccessAccount
| where SuccessTime > FailTime and SuccessTime - FailTime < 15m
| summarize FailCount = count(), UniqueTargets = dcount(FailHost), FirstEvent = min(FailTime), TargetInfo = strcat("Success on ", any(SuccessHost), " after failures") by AccountName = FailAccount, Computer = SuccessHost
| where UniqueTargets >= 3
| project TimeGenerated = FirstEvent, Computer, AttackType = "credential_spray", AttackingProcess = "credential-spray", TargetInfo, AccountName, TargetCount = UniqueTargets;
union lsass_access, spray
| order by TimeGenerated desc
"""

TECHNIQUE_TITLES = {
    "lsass_access": "LSASS memory access by unexpected process",
    "credential_spray": "Credential spraying — failures then success across 3+ machines",
}


def _make_ioc_key(hostname: str, technique: str, target: str, ts) -> str:
    try:
        dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
        ts_minute = dt.strftime("%Y-%m-%dT%H:%M:00")
    except (ValueError, TypeError):
        ts_minute = str(ts)[:16]
    host = hostname.split(".")[0].upper() if hostname else "UNKNOWN"
    safe_target = (target or "unknown").split("\\")[-1][:40].replace(" ", "_")
    return f"CRED:{host}:{technique}:{safe_target}:{ts_minute}"


def run() -> int:
    logger.info("Credential dumping agent starting")
    api_key = get_secops_api_key()

    rows = run_kql_query(CREDENTIAL_QUERY)
    logger.info("Credential agent: %d raw hits from LAW", len(rows))

    posted = 0
    seen_ioc_keys: set[str] = set()

    for row in rows:
        hostname = row.get("Computer", "UNKNOWN")
        attack_type = row.get("AttackType", "unknown")
        attacking_process = row.get("AttackingProcess", "")
        target_info = row.get("TargetInfo", "")
        account = row.get("AccountName", "")
        target_count = row.get("TargetCount", 1)
        ts = row.get("TimeGenerated", "")

        ioc_key = _make_ioc_key(hostname, attack_type, attacking_process, ts)
        if ioc_key in seen_ioc_keys:
            continue
        seen_ioc_keys.add(ioc_key)

        technique_title = TECHNIQUE_TITLES.get(attack_type, attack_type)
        host_short = hostname.split(".")[0]

        title = f"Credential dumping: {technique_title} on {host_short}"
        description = (
            f"Kill chain credential access stage alert on {hostname}. "
            f"Technique: {technique_title}. "
            f"Process involved: {attacking_process}. "
            f"Account: {account}. "
            f"Target detail: {str(target_info)[:300]}. "
            f"Unique targets involved: {target_count}. "
            f"Source: {SOURCE_SYSTEM}. "
            f"Fires CRITICAL — do not defer investigation. "
            f"HIPAA audit note: credential access event recorded per §164.312(b)."
        )

        raw_data = {
            "hostname": hostname,
            "attack_type": attack_type,
            "attacking_process": attacking_process,
            "target_info": str(target_info)[:500],
            "account": account,
            "target_count": target_count,
            "timestamp": str(ts),
            "ioc_key": ioc_key,
        }

        success = post_incident(
            title=title,
            severity="CRITICAL",
            ioc_key=ioc_key,
            description=description,
            raw_data=raw_data,
            api_key=api_key,
        )
        if success:
            posted += 1
            logger.info("Credential incident posted: %s", ioc_key)

    logger.info("Credential agent done: %d incidents posted from %d hits", posted, len(rows))
    return posted


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    sys.exit(0 if run() >= 0 else 1)

Agent 4: Lateral Movement Agent

File: agents/kill_chain/lateral_movement_agent.py

What it detects:

  • Same account authenticating to 3+ different machines within 30 minutes (4624/4648)
  • Explicit credential logon workstation-to-server (4648)
  • Admin share access: C$, ADMIN$, IPC$ (5140)

Fires: CRITICAL

KQL Query

kql
// Lateral movement agent — logon chains and admin share access

// Pattern 1: Same account hitting 3+ machines in 30 minutes (4624 network logons)
let logon_chains = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID in (4624, 4648)
| where LogonType in (3, 10)
| where not(TargetUserName endswith "$")
| where not(TargetUserName in ("ANONYMOUS LOGON", ""))
| summarize
    UniqueHosts = dcount(Computer),
    HostList = make_set(Computer, 20),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated),
    EventCount = count()
    by TargetUserName, bin(TimeGenerated, 30m)
| where UniqueHosts >= 3
| project
    TimeGenerated = FirstSeen,
    Computer = "multiple",
    DetectionType = "logon_chain",
    AccountName = TargetUserName,
    UniqueHosts,
    HostList = tostring(HostList),
    EventCount;

// Pattern 2: Explicit credential logon (4648) — workstation using stored creds to reach server
let explicit_creds = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4648
| where not(TargetUserName endswith "$")
| where TargetServerName != Computer
| project
    TimeGenerated,
    Computer,
    DetectionType = "explicit_credential_logon",
    AccountName = SubjectUserName,
    UniqueHosts = 1,
    HostList = TargetServerName,
    EventCount = 1;

// Pattern 3: Admin share access (5140) C$, ADMIN$, IPC$
let admin_shares = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 5140
| where ShareName in ("\\\\*\\C$", "\\\\*\\ADMIN$", "\\\\*\\IPC$")
      or ShareName matches regex @"(?i)\\\\[^\\]+\\(C\$|ADMIN\$|IPC\$)"
| project
    TimeGenerated,
    Computer,
    DetectionType = "admin_share_access",
    AccountName = SubjectUserName,
    UniqueHosts = 1,
    HostList = ShareName,
    EventCount = 1;

union logon_chains, explicit_creds, admin_shares
| order by TimeGenerated desc

Python Implementation

python
"""
Lateral Movement Agent — Kill Chain Phase 2
Detects logon chains, explicit credential reuse, and admin share access.

Detection patterns:
  1. Same account on 3+ machines in 30 minutes (4624/4648)
  2. Explicit credential logon (4648) crossing machine boundaries
  3. Admin share access: C$, ADMIN$, IPC$ (5140)

Fires CRITICAL.

ioc_key format: LATERAL:{source_host}:{dest_host}:{account}:{timestamp_minute}
"""
import logging
import sys
from datetime import datetime

from agents.kill_chain import (
    get_secops_api_key,
    post_incident,
    run_kql_query,
    SOURCE_SYSTEM,
)

logger = logging.getLogger(__name__)

LATERAL_QUERY = """
let logon_chains = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID in (4624, 4648)
| where LogonType in (3, 10)
| where not(TargetUserName endswith "$") and isnotempty(TargetUserName) and TargetUserName != "ANONYMOUS LOGON"
| summarize UniqueHosts = dcount(Computer), HostList = make_set(Computer, 20), FirstSeen = min(TimeGenerated), EventCount = count() by TargetUserName, bin(TimeGenerated, 30m)
| where UniqueHosts >= 3
| project TimeGenerated = FirstSeen, Computer = "multiple-hosts", DetectionType = "logon_chain", AccountName = TargetUserName, UniqueHosts, HostList = tostring(HostList), EventCount;
let explicit_creds = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4648 and not(TargetUserName endswith "$") and TargetServerName != Computer
| project TimeGenerated, Computer, DetectionType = "explicit_credential_logon", AccountName = SubjectUserName, UniqueHosts = 1, HostList = TargetServerName, EventCount = 1;
let admin_shares = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 5140
| where ShareName matches regex @"(?i)\\\\\\\\[^\\\\]+\\\\(C\\$|ADMIN\\$|IPC\\$)"
| project TimeGenerated, Computer, DetectionType = "admin_share_access", AccountName = SubjectUserName, UniqueHosts = 1, HostList = ShareName, EventCount = 1;
union logon_chains, explicit_creds, admin_shares
| order by TimeGenerated desc
"""

DETECTION_TITLES = {
    "logon_chain": "Same account authenticated to 3+ machines in 30 minutes",
    "explicit_credential_logon": "Explicit credential logon crossing machine boundary",
    "admin_share_access": "Admin share access (C$, ADMIN$, or IPC$)",
}


def _make_ioc_key(source_host: str, dest_host: str, account: str, ts) -> str:
    try:
        dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
        ts_minute = dt.strftime("%Y-%m-%dT%H:%M:00")
    except (ValueError, TypeError):
        ts_minute = str(ts)[:16]
    src = source_host.split(".")[0].upper() if source_host else "UNKNOWN"
    dst = str(dest_host)[:40].replace(" ", "_").replace("\\", "_")
    acct = (account or "unknown").split("\\")[-1][:30]
    return f"LATERAL:{src}:{dst}:{acct}:{ts_minute}"


def run() -> int:
    logger.info("Lateral movement agent starting")
    api_key = get_secops_api_key()

    rows = run_kql_query(LATERAL_QUERY)
    logger.info("Lateral movement agent: %d raw hits from LAW", len(rows))

    posted = 0
    seen_ioc_keys: set[str] = set()

    for row in rows:
        hostname = row.get("Computer", "UNKNOWN")
        detection_type = row.get("DetectionType", "unknown")
        account = row.get("AccountName", "")
        host_list = row.get("HostList", "")
        unique_hosts = row.get("UniqueHosts", 1)
        event_count = row.get("EventCount", 1)
        ts = row.get("TimeGenerated", "")

        ioc_key = _make_ioc_key(hostname, host_list, account, ts)
        if ioc_key in seen_ioc_keys:
            continue
        seen_ioc_keys.add(ioc_key)

        detection_title = DETECTION_TITLES.get(detection_type, detection_type)
        host_short = hostname.split(".")[0]

        title = f"Lateral movement: {detection_title}"
        description = (
            f"Kill chain lateral movement alert. "
            f"Detection: {detection_title}. "
            f"Source host: {host_short}. "
            f"Account: {account}. "
            f"Targets or share: {str(host_list)[:200]}. "
            f"Unique hosts involved: {unique_hosts}. "
            f"Authentication events in window: {event_count}. "
            f"Source: {SOURCE_SYSTEM}. "
            f"HIPAA audit note: lateral movement events recorded per §164.312(b). "
            f"If corroborated by execution or credential agent alerts in same window, "
            f"call Arctic Wolf immediately per kill chain incident response playbook."
        )

        raw_data = {
            "hostname": hostname,
            "detection_type": detection_type,
            "account": account,
            "host_list": str(host_list)[:500],
            "unique_hosts": unique_hosts,
            "event_count": event_count,
            "timestamp": str(ts),
            "ioc_key": ioc_key,
        }

        success = post_incident(
            title=title,
            severity="CRITICAL",
            ioc_key=ioc_key,
            description=description,
            raw_data=raw_data,
            api_key=api_key,
        )
        if success:
            posted += 1
            logger.info("Lateral movement incident posted: %s", ioc_key)

    logger.info("Lateral movement agent done: %d incidents posted from %d hits", posted, len(rows))
    return posted


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    sys.exit(0 if run() >= 0 else 1)

Agent 5: Exfiltration Agent

File: agents/kill_chain/exfiltration_agent.py

What it detects:

  • Outbound bytes significantly above the 30-day per-host baseline (>3x p95)
  • Destinations not seen in the past 30 days (new IPs or FQDNs)
  • App-ID identifying exfiltration tools: RClone, WinSCP, Mega.nz, WeTransfer, cloud file-sharing
  • Large transfers (>500MB) outside business hours (before 07:00 or after 20:00 Pacific)

Data source: Palo Alto traffic logs forwarded to LAW via Sentinel CEF connector (table: CommonSecurityLog)

Fires: HIGH

Note on baseline: The 30-day baseline query runs on each agent invocation. It is a scan-heavy query. If LAW query costs become a concern, pre-compute baselines to a custom table via a separate daily job (not in scope for Phase 2 initial build — revisit after traffic patterns are characterized).

KQL Query

kql
// Exfiltration agent — outbound traffic anomalies via Palo Alto logs in CommonSecurityLog

// Baseline: 30-day median and p95 outbound bytes per source host
let baseline = CommonSecurityLog
| where TimeGenerated > ago(30d) and TimeGenerated < ago(4h15m)
| where DeviceVendor == "Palo Alto Networks"
| where CommunicationDirection == "Outbound" or DeviceAction has_any ("allow", "Allow")
| where SentBytes > 0
| summarize
    p50_bytes = percentile(SentBytes, 50),
    p95_bytes = percentile(SentBytes, 95)
    by SourceHostName;

// Current window: outbound transfers
let current = CommonSecurityLog
| where TimeGenerated > ago(4h15m)
| where DeviceVendor == "Palo Alto Networks"
| where CommunicationDirection == "Outbound" or DeviceAction has_any ("allow", "Allow")
| where SentBytes > 0
| summarize
    TotalBytes = sum(SentBytes),
    UniqueDestinations = dcount(DestinationHostName),
    DestList = make_set(DestinationHostName, 10),
    FirstSeen = min(TimeGenerated)
    by SourceHostName, ApplicationProtocol, DestinationHostName;

// Known exfil app IDs
let exfil_apps = dynamic(["rclone", "winscp", "megasync", "mega-nz", "wetransfer", "dropbox", "onedrive-personal", "box", "google-drive-upload", "sharefile"]);
let exfil_app_hits = CommonSecurityLog
| where TimeGenerated > ago(4h15m)
| where DeviceVendor == "Palo Alto Networks"
| where tolower(ApplicationProtocol) has_any (exfil_apps)
| project TimeGenerated, SourceHostName, ApplicationProtocol, DestinationHostName, SentBytes,
    DetectionType = "exfil_app_id";

// New destinations not seen in 30 days
let seen_30d = CommonSecurityLog
| where TimeGenerated > ago(30d) and TimeGenerated < ago(4h15m)
| where DeviceVendor == "Palo Alto Networks"
| summarize by DestinationHostName;
let new_dest = CommonSecurityLog
| where TimeGenerated > ago(4h15m)
| where DeviceVendor == "Palo Alto Networks"
| where SentBytes > 10485760  // Only flag new dests with >10MB sent
| join kind=leftanti seen_30d on DestinationHostName
| project TimeGenerated, SourceHostName, ApplicationProtocol, DestinationHostName, SentBytes,
    DetectionType = "new_destination";

// Large transfers outside business hours (Pacific = UTC-7/UTC-8)
let after_hours = CommonSecurityLog
| where TimeGenerated > ago(4h15m)
| where DeviceVendor == "Palo Alto Networks"
| where SentBytes > 524288000  // 500MB
| extend HourPacific = hourofday(TimeGenerated - 7h)
| where HourPacific < 7 or HourPacific >= 20
| project TimeGenerated, SourceHostName, ApplicationProtocol, DestinationHostName, SentBytes,
    DetectionType = "large_transfer_after_hours";

// Volume anomaly vs baseline
let volume_anomaly = current
| join kind=inner baseline on SourceHostName
| where TotalBytes > p95_bytes * 3  // 3x p95 threshold
| project TimeGenerated = FirstSeen, SourceHostName, ApplicationProtocol, DestinationHostName,
    SentBytes = TotalBytes, DetectionType = "volume_anomaly";

union exfil_app_hits, new_dest, after_hours, volume_anomaly
| order by SentBytes desc

Python Implementation

python
"""
Exfiltration Agent — Kill Chain Phase 2
Detects outbound traffic anomalies via Palo Alto logs in CommonSecurityLog.

Detection patterns:
  1. Exfiltration App-ID: RClone, WinSCP, Mega, WeTransfer, etc.
  2. New destination not seen in 30 days with significant data sent
  3. Large transfer (>500MB) outside business hours (before 07:00 or after 20:00 Pacific)
  4. Volume anomaly: outbound bytes > 3x 30-day p95 per host

Data source: Palo Alto traffic logs in CommonSecurityLog (not SecurityEvent).

Fires HIGH.

ioc_key format: EXFIL:{source_host}:{dest_fqdn}:{bytes_transferred}:{timestamp_minute}
"""
import logging
import sys
from datetime import datetime

from agents.kill_chain import (
    get_secops_api_key,
    post_incident,
    run_kql_query,
    SOURCE_SYSTEM,
)

logger = logging.getLogger(__name__)

EXFIL_QUERY = """
let exfil_apps = dynamic(["rclone", "winscp", "megasync", "mega-nz", "wetransfer", "dropbox", "onedrive-personal", "box", "google-drive-upload", "sharefile"]);
let seen_30d = CommonSecurityLog
| where TimeGenerated > ago(30d) and TimeGenerated < ago(4h15m)
| where DeviceVendor == "Palo Alto Networks"
| summarize by DestinationHostName;
let exfil_app_hits = CommonSecurityLog
| where TimeGenerated > ago(4h15m) and DeviceVendor == "Palo Alto Networks"
| where tolower(ApplicationProtocol) has_any (exfil_apps)
| project TimeGenerated, SourceHostName, ApplicationProtocol, DestinationHostName, SentBytes, DetectionType = "exfil_app_id";
let new_dest = CommonSecurityLog
| where TimeGenerated > ago(4h15m) and DeviceVendor == "Palo Alto Networks" and SentBytes > 10485760
| join kind=leftanti seen_30d on DestinationHostName
| project TimeGenerated, SourceHostName, ApplicationProtocol, DestinationHostName, SentBytes, DetectionType = "new_destination";
let after_hours = CommonSecurityLog
| where TimeGenerated > ago(4h15m) and DeviceVendor == "Palo Alto Networks" and SentBytes > 524288000
| extend HourPacific = hourofday(TimeGenerated - 7h)
| where HourPacific < 7 or HourPacific >= 20
| project TimeGenerated, SourceHostName, ApplicationProtocol, DestinationHostName, SentBytes, DetectionType = "large_transfer_after_hours";
let baseline = CommonSecurityLog
| where TimeGenerated > ago(30d) and TimeGenerated < ago(4h15m) and DeviceVendor == "Palo Alto Networks" and SentBytes > 0
| summarize p95_bytes = percentile(SentBytes, 95) by SourceHostName;
let current = CommonSecurityLog
| where TimeGenerated > ago(4h15m) and DeviceVendor == "Palo Alto Networks" and SentBytes > 0
| summarize TotalBytes = sum(SentBytes), FirstSeen = min(TimeGenerated) by SourceHostName, ApplicationProtocol, DestinationHostName;
let volume_anomaly = current
| join kind=inner baseline on SourceHostName
| where TotalBytes > p95_bytes * 3
| project TimeGenerated = FirstSeen, SourceHostName, ApplicationProtocol, DestinationHostName, SentBytes = TotalBytes, DetectionType = "volume_anomaly";
union exfil_app_hits, new_dest, after_hours, volume_anomaly
| order by SentBytes desc
"""

DETECTION_TITLES = {
    "exfil_app_id": "Exfiltration tool (App-ID) detected",
    "new_destination": "Large transfer to new destination not seen in 30 days",
    "large_transfer_after_hours": "Large transfer (>500MB) outside business hours",
    "volume_anomaly": "Outbound volume 3x above 30-day p95 baseline",
}


def _human_bytes(n: int) -> str:
    if n >= 1_073_741_824:
        return f"{n / 1_073_741_824:.1f} GB"
    if n >= 1_048_576:
        return f"{n / 1_048_576:.1f} MB"
    if n >= 1024:
        return f"{n / 1024:.1f} KB"
    return f"{n} bytes"


def _make_ioc_key(source_host: str, dest_fqdn: str, bytes_sent: int, ts) -> str:
    try:
        dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
        ts_minute = dt.strftime("%Y-%m-%dT%H:%M:00")
    except (ValueError, TypeError):
        ts_minute = str(ts)[:16]
    src = (source_host or "UNKNOWN").split(".")[0].upper()
    dst = (dest_fqdn or "unknown")[:50].replace(" ", "_")
    # Bucket bytes to nearest 100MB to reduce duplicate churn on volume anomalies
    bytes_bucket = (bytes_sent // 104_857_600) * 104_857_600
    return f"EXFIL:{src}:{dst}:{bytes_bucket}:{ts_minute}"


def run() -> int:
    logger.info("Exfiltration agent starting")
    api_key = get_secops_api_key()

    rows = run_kql_query(EXFIL_QUERY)
    logger.info("Exfiltration agent: %d raw hits from LAW", len(rows))

    posted = 0
    seen_ioc_keys: set[str] = set()

    for row in rows:
        source_host = row.get("SourceHostName", "UNKNOWN")
        dest_fqdn = row.get("DestinationHostName", "")
        bytes_sent = int(row.get("SentBytes", 0))
        app_protocol = row.get("ApplicationProtocol", "")
        detection_type = row.get("DetectionType", "unknown")
        ts = row.get("TimeGenerated", "")

        ioc_key = _make_ioc_key(source_host, dest_fqdn, bytes_sent, ts)
        if ioc_key in seen_ioc_keys:
            continue
        seen_ioc_keys.add(ioc_key)

        detection_title = DETECTION_TITLES.get(detection_type, detection_type)
        src_short = source_host.split(".")[0]
        bytes_human = _human_bytes(bytes_sent)

        title = f"Exfiltration: {detection_title} from {src_short}"
        description = (
            f"Kill chain exfiltration alert. "
            f"Detection type: {detection_title}. "
            f"Source host: {source_host}. "
            f"Destination: {dest_fqdn}. "
            f"Data transferred: {bytes_human}. "
            f"Application: {app_protocol}. "
            f"Source: {SOURCE_SYSTEM}. "
            f"HIPAA audit note: outbound transfer event recorded per §164.312(b). "
            f"If this is active exfiltration, call Arctic Wolf immediately — "
            f"do not wait for next analyst review cycle."
        )

        raw_data = {
            "source_host": source_host,
            "dest_fqdn": dest_fqdn,
            "bytes_sent": bytes_sent,
            "bytes_human": bytes_human,
            "app_protocol": app_protocol,
            "detection_type": detection_type,
            "timestamp": str(ts),
            "ioc_key": ioc_key,
        }

        success = post_incident(
            title=title,
            severity="HIGH",
            ioc_key=ioc_key,
            description=description,
            raw_data=raw_data,
            api_key=api_key,
        )
        if success:
            posted += 1
            logger.info("Exfiltration incident posted: %s", ioc_key)

    logger.info("Exfiltration agent done: %d incidents posted from %d hits", posted, len(rows))
    return posted


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    sys.exit(0 if run() >= 0 else 1)

Agent 6: Defense Evasion Agent

File: agents/kill_chain/defense_evasion_agent.py

What it detects:

  • EventID 1102: Security audit log cleared — CRITICAL, no window, no suppression
  • Cortex XDR returning 0 findings for 8+ consecutive hours while other kill chain agents have elevated findings — HIGH (cross-agent correlation)
  • Security tool process termination (MsMpEng.exe, CylanceSvc.exe, etc.) via 4688

Fires: CRITICAL for 1102 and tool termination, HIGH for Cortex silence

Cross-agent correlation: The Cortex silence detection queries the SecOps incidents table via the API, not LAW. It checks whether any CRITICAL/HIGH incidents from the kill chain agents exist in the last 8 hours while Cortex is quiet. This is the only agent that makes an API GET call in addition to the POST.

KQL Query

kql
// Defense evasion agent

// 1102 — Security log cleared (CRITICAL, no exceptions)
let log_cleared = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 1102
| project
    TimeGenerated,
    Computer,
    AccountName = SubjectUserName,
    DetectionType = "security_log_cleared",
    DetailInfo = strcat("Security log cleared by ", SubjectUserName);

// Security tool process termination via 4688
let tool_kill = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4688
| extend ProcessLower = tolower(NewProcessName)
| extend CommandLower = tolower(CommandLine)
| where (
    // taskkill or net stop targeting security tools
    (ProcessLower has_any ("taskkill.exe", "net.exe", "sc.exe")
     and CommandLower has_any ("msmpeng", "cylancesvc", "xagt", "csc.exe", "mssense", "sfc.exe", "wdfilter", "sense"))
    // Or direct security process as the new process being killed
    or (CommandLower has "stop" and CommandLower has_any ("windefend", "securityhealthservice", "wscsvc"))
)
| project
    TimeGenerated,
    Computer,
    AccountName = SubjectUserName,
    DetectionType = "security_tool_termination",
    DetailInfo = strcat("Security tool targeted: ", CommandLine);

union log_cleared, tool_kill
| order by TimeGenerated desc

Python Implementation

python
"""
Defense Evasion Agent — Kill Chain Phase 2
Detects anti-forensics and security tool disabling.

Detection patterns:
  1. EventID 1102: Security audit log cleared — CRITICAL, no exceptions
  2. Security tool process termination (4688 targeting MsMpEng, Cylance, XDR agents)
  3. Cortex XDR silence: 0 findings for 8+ hours while other agents are elevated

Pattern 3 (Cortex silence) uses cross-agent correlation via the SecOps incidents API.
It queries recent OPEN/NEW incidents from other kill chain agents to determine if the
environment is "active but Cortex is blind."

Fires CRITICAL for 1102 and tool termination, HIGH for Cortex silence.

ioc_key format: EVASION:{hostname}:{technique}:{timestamp_minute}
"""
import logging
import sys
from datetime import datetime, timezone, timedelta
from typing import Optional

import requests

from agents.kill_chain import (
    get_secops_api_key,
    post_incident,
    run_kql_query,
    SECOPS_API_URL,
    SOURCE_SYSTEM,
)

logger = logging.getLogger(__name__)

EVASION_QUERY = """
let log_cleared = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 1102
| project TimeGenerated, Computer, AccountName = SubjectUserName, DetectionType = "security_log_cleared", DetailInfo = strcat("Security log cleared by ", SubjectUserName);
let tool_kill = SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID == 4688
| extend ProcessLower = tolower(NewProcessName)
| extend CommandLower = tolower(CommandLine)
| where (
    (ProcessLower has_any ("taskkill.exe", "net.exe", "sc.exe")
     and CommandLower has_any ("msmpeng", "cylancesvc", "xagt", "mssense", "windefend", "securityhealthservice"))
    or (CommandLower has "stop" and CommandLower has_any ("windefend", "securityhealthservice", "wscsvc"))
)
| project TimeGenerated, Computer, AccountName = SubjectUserName, DetectionType = "security_tool_termination", DetailInfo = strcat("Security tool targeted: ", CommandLine);
union log_cleared, tool_kill
| order by TimeGenerated desc
"""

# Cortex Silence: query SecOps API for recent kill chain incidents
# while checking that Cortex posted 0 findings
CORTEX_SILENCE_HOURS = 8
CORTEX_AGENT_SOURCE = "cortex"  # The Cortex XDR agent identifier in SecOps

DETECTION_TITLES = {
    "security_log_cleared": "Security audit log cleared (EventID 1102)",
    "security_tool_termination": "Security tool process termination attempt",
    "cortex_silence": "Cortex XDR returning 0 findings while other agents are elevated",
}

DETECTION_SEVERITIES = {
    "security_log_cleared": "CRITICAL",
    "security_tool_termination": "CRITICAL",
    "cortex_silence": "HIGH",
}


def _make_ioc_key(hostname: str, technique: str, ts) -> str:
    try:
        dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
        ts_minute = dt.strftime("%Y-%m-%dT%H:%M:00")
    except (ValueError, TypeError):
        ts_minute = str(ts)[:16]
    host = (hostname or "UNKNOWN").split(".")[0].upper()
    return f"EVASION:{host}:{technique}:{ts_minute}"


def _check_cortex_silence(api_key: str) -> Optional[dict]:
    """
    Returns a synthetic hit dict if Cortex XDR appears to be silent while
    other kill chain agents have CRITICAL/HIGH incidents in the last 8 hours.
    Returns None if Cortex silence is not detected.
    """
    window_start = (datetime.now(timezone.utc) - timedelta(hours=CORTEX_SILENCE_HOURS)).isoformat()

    # Check for recent kill chain CRITICAL/HIGH incidents (any kill chain agent)
    try:
        resp = requests.get(
            f"{SECOPS_API_URL}/api/incidents",
            params={
                "severity": "CRITICAL,HIGH",
                "source_system": SOURCE_SYSTEM,
                "after": window_start,
                "limit": 10,
            },
            headers={"X-API-Key": api_key},
            timeout=10,
        )
        if resp.status_code != 200:
            logger.warning("SecOps incidents API returned %d — skipping Cortex silence check", resp.status_code)
            return None

        incidents = resp.json()
        # Only count kill chain incidents (ioc_key prefixes from Phase 2 agents)
        kc_prefixes = ("EXEC:", "PERSIST:", "CRED:", "LATERAL:", "EXFIL:")
        kill_chain_elevated = [
            i for i in incidents
            if any(i.get("ioc_key", "").startswith(p) for p in kc_prefixes)
        ]

        if not kill_chain_elevated:
            logger.debug("No elevated kill chain incidents — Cortex silence is not meaningful")
            return None

        # Check if Cortex XDR agent has posted any findings in the window
        resp2 = requests.get(
            f"{SECOPS_API_URL}/api/incidents",
            params={
                "source_system": SOURCE_SYSTEM,
                "after": window_start,
                "limit": 5,
                "agent": CORTEX_AGENT_SOURCE,
            },
            headers={"X-API-Key": api_key},
            timeout=10,
        )

        if resp2.status_code == 200:
            cortex_incidents = resp2.json()
            if cortex_incidents:
                logger.debug("Cortex XDR has findings — no silence condition")
                return None

        # Cortex is silent while others are elevated
        logger.warning(
            "Cortex silence detected: %d kill chain incidents elevated, 0 Cortex findings",
            len(kill_chain_elevated),
        )
        return {
            "Computer": "CORTEX-XDR-GLOBAL",
            "AccountName": "system",
            "DetectionType": "cortex_silence",
            "DetailInfo": (
                f"Cortex XDR returned 0 findings for {CORTEX_SILENCE_HOURS}+ hours "
                f"while {len(kill_chain_elevated)} kill chain incidents are elevated. "
                f"Elevated incident IDs: {[i.get('id') for i in kill_chain_elevated[:5]]}"
            ),
            "TimeGenerated": datetime.now(timezone.utc).isoformat(),
        }

    except requests.RequestException as exc:
        logger.error("Cortex silence check failed: %s", exc)
        return None


def run() -> int:
    logger.info("Defense evasion agent starting")
    api_key = get_secops_api_key()

    # LAW-based detections
    rows = run_kql_query(EVASION_QUERY)
    logger.info("Defense evasion agent: %d raw hits from LAW", len(rows))

    # Cross-agent Cortex silence check
    cortex_hit = _check_cortex_silence(api_key)
    if cortex_hit:
        rows.append(cortex_hit)

    posted = 0
    seen_ioc_keys: set[str] = set()

    for row in rows:
        hostname = row.get("Computer", "UNKNOWN")
        detection_type = row.get("DetectionType", "unknown")
        account = row.get("AccountName", "")
        detail_info = row.get("DetailInfo", "")
        ts = row.get("TimeGenerated", "")

        ioc_key = _make_ioc_key(hostname, detection_type, ts)
        if ioc_key in seen_ioc_keys:
            continue
        seen_ioc_keys.add(ioc_key)

        detection_title = DETECTION_TITLES.get(detection_type, detection_type)
        severity = DETECTION_SEVERITIES.get(detection_type, "HIGH")
        host_short = hostname.split(".")[0]

        title = f"Defense evasion: {detection_title} on {host_short}"
        hipaa_note = (
            "HIPAA audit note: security log clearance recorded — audit trail integrity event per §164.312(b). "
            "This event must be reviewed and documented regardless of other findings."
            if detection_type == "security_log_cleared"
            else "HIPAA audit note: security control tampering event recorded per §164.312(b)."
        )

        description = (
            f"Kill chain defense evasion alert. "
            f"Detection: {detection_title}. "
            f"Host: {hostname}. "
            f"Account: {account}. "
            f"Detail: {str(detail_info)[:300]}. "
            f"Severity: {severity}. "
            f"Source: {SOURCE_SYSTEM}. "
            f"{hipaa_note}"
        )

        raw_data = {
            "hostname": hostname,
            "detection_type": detection_type,
            "account": account,
            "detail_info": str(detail_info)[:500],
            "timestamp": str(ts),
            "ioc_key": ioc_key,
        }

        success = post_incident(
            title=title,
            severity=severity,
            ioc_key=ioc_key,
            description=description,
            raw_data=raw_data,
            api_key=api_key,
        )
        if success:
            posted += 1
            logger.info("Defense evasion incident posted: %s (severity=%s)", ioc_key, severity)

    logger.info("Defense evasion agent done: %d incidents posted from %d hits", posted, len(rows))
    return posted


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    sys.exit(0 if run() >= 0 else 1)

Wiring Agents into the Orchestrator

Existing agents are invoked from a central orchestrator script. Add the six new agents to that orchestrator in the kill_chain section.

Addition to orchestrator (pattern — match to existing structure)

python
# In the existing orchestrator — add to the agent list
from agents.kill_chain.execution_agent import run as run_execution
from agents.kill_chain.persistence_agent import run as run_persistence
from agents.kill_chain.credential_agent import run as run_credential
from agents.kill_chain.lateral_movement_agent import run as run_lateral
from agents.kill_chain.exfiltration_agent import run as run_exfiltration
from agents.kill_chain.defense_evasion_agent import run as run_defense_evasion

KILL_CHAIN_AGENTS = [
    ("execution", run_execution),
    ("persistence", run_persistence),
    ("credential_dumping", run_credential),
    ("lateral_movement", run_lateral),
    ("exfiltration", run_exfiltration),
    ("defense_evasion", run_defense_evasion),
]

# In the main run loop — the defense evasion agent must run last
# because it performs cross-agent correlation
for agent_name, agent_fn in KILL_CHAIN_AGENTS[:-1]:
    try:
        count = agent_fn()
        logger.info("Agent %s: %d incidents", agent_name, count)
    except Exception as exc:
        logger.error("Agent %s failed: %s", agent_name, exc)

# Defense evasion runs after all others
try:
    count = run_defense_evasion()
    logger.info("Agent defense_evasion: %d incidents", count)
except Exception as exc:
    logger.error("Agent defense_evasion failed: %s", exc)

Deployment

Prerequisites

Before deploying:

  • Phase 1 events are confirmed flowing (run the Phase 1 verification KQL in kill-chain-phase1-enablement.md)
  • You have write access to ciriusagentsprod.azurecr.io
  • Your local Docker daemon is running
  • az CLI is authenticated to the logging subscription

Step 1 — Verify agent code locally

bash
cd ops-automation
python -m py_compile agents/kill_chain/__init__.py
python -m py_compile agents/kill_chain/execution_agent.py
python -m py_compile agents/kill_chain/persistence_agent.py
python -m py_compile agents/kill_chain/credential_agent.py
python -m py_compile agents/kill_chain/lateral_movement_agent.py
python -m py_compile agents/kill_chain/exfiltration_agent.py
python -m py_compile agents/kill_chain/defense_evasion_agent.py

All should exit with no output. Any syntax error blocks deploy.

Step 2 — Run tests

bash
cd ops-automation
pytest tests/kill_chain/ -v

There should be test files for each agent under tests/kill_chain/. If they don't exist yet, write them before deploying — see Testing Procedures section below.

Step 3 — Build Docker image

bash
cd ops-automation
docker build -t ciriusagentsprod.azurecr.io/secops-agents:latest .
docker build -t ciriusagentsprod.azurecr.io/secops-agents:$(git rev-parse --short HEAD) .

The second tag pins the image to the git commit so you can roll back to an exact image.

Step 4 — Push to ACR

bash
az acr login --name ciriusagentsprod
docker push ciriusagentsprod.azurecr.io/secops-agents:latest
docker push ciriusagentsprod.azurecr.io/secops-agents:$(git rev-parse --short HEAD)

Step 5 — Update Container Apps Job to use new image

bash
az containerapp job update \
  --name ca-secops-prod \
  --resource-group rg-logging-logs \
  --image ciriusagentsprod.azurecr.io/secops-agents:latest

Or pin to the specific SHA for a safe deploy:

bash
az containerapp job update \
  --name ca-secops-prod \
  --resource-group rg-logging-logs \
  --image ciriusagentsprod.azurecr.io/secops-agents:$(git rev-parse --short HEAD)

Step 6 — Trigger a manual run to verify

bash
az containerapp job start \
  --name ca-secops-prod \
  --resource-group rg-logging-logs

Step 7 — Check execution logs

bash
az monitor log-analytics query \
  --workspace 5d76d1f2 \
  --analytics-query "ContainerAppConsoleLogs_CL | where ContainerAppName_s == 'ca-secops-prod' | where Log_s contains 'kill_chain' | sort by TimeGenerated desc | take 100" \
  --output table

Expect to see log lines like:

Execution agent starting
Execution agent: N raw hits from LAW
Execution agent done: N incidents posted from N hits

If you see exception tracebacks, read them — most common causes are LAW permission errors (managed identity needs Log Analytics Reader) or Key Vault permission errors (managed identity needs Key Vault Secrets User).

Step 8 — Check SecOps for new incidents

Log into secops.bedrockcybersecurity.org → Incidents → filter by source PROD and time Last 1 hour. You should see any kill chain incidents the agents found. If the environment is clean, you may see 0 — that is expected. Trigger test events (see Testing Procedures) to confirm the pipeline is end-to-end functional.


Testing Procedures

Test Framework

Each agent should have a test file in tests/kill_chain/. The tests mock the LAW query and SecOps POST so they run without real Azure connectivity.

python
# tests/kill_chain/test_execution_agent.py (example structure)
from unittest.mock import patch, MagicMock
from agents.kill_chain.execution_agent import run, _make_ioc_key, _detect_technique

def test_ioc_key_format():
    key = _make_ioc_key("DESKTOP-ABC.corp", "C:\\Windows\\Temp\\evil.exe", "explorer.exe", "2026-05-25T14:32:00Z")
    assert key.startswith("EXEC:DESKTOP-ABC:")
    assert "evil.exe" in key

def test_no_incidents_on_empty_query():
    with patch("agents.kill_chain.execution_agent.run_kql_query", return_value=[]):
        with patch("agents.kill_chain.execution_agent.get_secops_api_key", return_value="test-key"):
            result = run()
    assert result == 0

def test_mimikatz_detection():
    fake_row = {
        "TimeGenerated": "2026-05-25T14:32:00Z",
        "Computer": "SRV-PROD-01",
        "NewProcessName": "C:\\Users\\user\\AppData\\Local\\Temp\\m.exe",
        "ParentProcessName": "C:\\Windows\\explorer.exe",
        "CommandLine": "m.exe sekurlsa::logonpasswords",
        "SubjectUserName": "domain\\user",
        "ToolNameHit": True,
        "UnexpectedParent": False,
        "SuspiciousPath": True,
    }
    with patch("agents.kill_chain.execution_agent.run_kql_query", return_value=[fake_row]):
        with patch("agents.kill_chain.execution_agent.get_secops_api_key", return_value="test-key"):
            with patch("agents.kill_chain.execution_agent.post_incident", return_value=True) as mock_post:
                result = run()
    assert result == 1
    assert mock_post.call_args.kwargs["severity"] == "CRITICAL"
    assert "sekurlsa" in mock_post.call_args.kwargs["description"].lower() or \
           "mimikatz" in mock_post.call_args.kwargs["description"].lower() or \
           "credential-tool" in mock_post.call_args.kwargs["description"]

Triggering Real Test Events in LAW

These commands generate real Windows events that the agents will detect on the next run. Run them on a non-production machine if possible. If you must test on production, create a CM ticket first so SecOps suppresses the resulting incidents.

Execution agent — trigger a suspicious process creation:

powershell
# Run on a test Windows machine — uses certutil (LOLBin) from explorer context
# This will generate EventID 4688 with certutil.exe in NewProcessName
Start-Process certutil.exe -ArgumentList "-decode test.b64 test.out" -Wait

For a more controlled test that avoids false alarm churn in production, use a PowerShell obfuscation pattern:

powershell
# 4688 with encoded command — triggers "-enc " pattern
powershell.exe -EncodedCommand JABhAD0AMQAgAA==

Persistence agent — trigger a new service and scheduled task:

powershell
# 7045 — new service
sc.exe create KCTestSvc binPath= "C:\Windows\System32\cmd.exe" start= disabled
sc.exe delete KCTestSvc

# 4698 — new scheduled task
Register-ScheduledTask -TaskName "KCTestTask" -Action (New-ScheduledTaskAction -Execute "calc.exe") -Trigger (New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(30))
Unregister-ScheduledTask -TaskName "KCTestTask" -Confirm:$false

Credential agent — trigger a spray simulation (use test accounts only):

powershell
# Simulate failed auth attempts against multiple machines
# WARNING: do not use real accounts — use test accounts only
# The agent looks for 3+ unique TargetHostName values for the same SubjectUserName
# with failures (4625) followed by success (4624) within 15 minutes.
# Most practical test: review LAW for existing 4625 patterns from service accounts.

For credential agent end-to-end test without generating real failed logons, mock the LAW query in a unit test with a synthetic spray dataset.

Lateral movement agent — trigger admin share access:

powershell
# Access an admin share on a test server (you need rights)
# Generates EventID 5140 for C$ access
net use \\TEST-SERVER\C$ /user:DOMAIN\youraccount
net use \\TEST-SERVER\C$ /delete

Defense evasion agent — trigger 1102:

powershell
# WARNING: This clears the Security event log on the local machine.
# Only run on a test machine or in a dedicated test window.
wevtutil cl Security

After clearing the log, wait 5 minutes and check LAW:

kql
SecurityEvent
| where TimeGenerated > ago(30m)
| where EventID == 1102
| project TimeGenerated, Computer, SubjectUserName

Exfiltration agent — cannot be meaningfully triggered manually. The agent relies on Palo Alto traffic logs in CommonSecurityLog. To test:

  1. Verify CommonSecurityLog is populated with Palo Alto data:
kql
CommonSecurityLog
| where DeviceVendor == "Palo Alto Networks"
| where TimeGenerated > ago(1h)
| summarize count() by DeviceVendor
  1. If data is flowing, adjust the volume anomaly threshold temporarily for a test run. Set p95_bytes * 3 to p95_bytes * 0.01 in a test branch to confirm the detection and POST pipeline works.

  2. Revert the threshold before merging.


KQL Quick Reference for Operations

These are the ad-hoc queries to run when triaging a live incident from one of these agents.

Pivot on a hostname from any kill chain alert

kql
// All kill chain events on a specific host in the last 24h
let target_host = "HOSTNAME";
SecurityEvent
| where TimeGenerated > ago(24h)
| where Computer has target_host
| where EventID in (1102, 4624, 4625, 4648, 4688, 4698, 5140, 7045)
| project TimeGenerated, EventID, Computer, SubjectUserName, TargetUserName, NewProcessName, CommandLine, ParentProcessName
| order by TimeGenerated desc

Pivot on an account from a lateral movement alert

kql
// All logon events for a specific account in the last 24h
let target_account = "DOMAIN\\username";
SecurityEvent
| where TimeGenerated > ago(24h)
| where EventID in (4624, 4625, 4648)
| where SubjectUserName == target_account or TargetUserName == target_account
| project TimeGenerated, EventID, Computer, SubjectUserName, TargetUserName, LogonType, IpAddress
| order by TimeGenerated desc

Timeline reconstruction for an incident

kql
// Full timeline for a hostname and account across all Phase 1 event types
let target_host = "HOSTNAME";
let target_account = "domain\\account";
SecurityEvent
| where TimeGenerated > ago(48h)
| where (Computer has target_host) or (SubjectUserName == target_account) or (TargetUserName == target_account)
| where EventID in (1102, 4624, 4625, 4648, 4688, 4698, 5140, 7045)
| project TimeGenerated, EventID, Computer, SubjectUserName, TargetUserName, NewProcessName, CommandLine, IpAddress
| order by TimeGenerated asc

Cross-Agent Correlation

The kill chain agents are designed to fire independently and let SecOps correlate. The exception is the defense evasion agent's Cortex silence check, which explicitly reads other agents' output.

When to Escalate Without Waiting for Correlation

Per the Kill Chain Incident Response Playbook: call Arctic Wolf immediately if two or more kill chain stage agents fire in the same 4-hour window. This is a correlated attack signal. Execution + Lateral Movement is enough. Do not wait for the full picture.

Manual Correlation Query

When a CRITICAL incident arrives, run this to see if other kill chain stages are also elevated:

kql
// Check SecOps for all kill chain incidents in last 8 hours
// (Run against SecOps PostgreSQL or use the /api/incidents endpoint)
// In LAW: check for kill chain events across all relevant event IDs in a single window
SecurityEvent
| where TimeGenerated > ago(8h)
| where EventID in (1102, 4624, 4625, 4648, 4688, 4698, 5140, 7045)
| summarize
    EventCount = count(),
    UniqueHosts = dcount(Computer),
    UniqueUsers = dcount(SubjectUserName)
    by EventID
| order by EventID asc

Defense Evasion Agent SecOps API Requirements

The defense evasion agent calls GET /api/incidents to check Cortex silence. That endpoint must support the following query parameters for this to work:

GET /api/incidents?severity=CRITICAL,HIGH&source_system=PROD&after=<iso-timestamp>&limit=10
GET /api/incidents?source_system=PROD&after=<iso-timestamp>&limit=5&agent=cortex

If the agent filter is not implemented in the current SecOps version, the Cortex silence check will return None (silently skipped per the fail-open design). Add the filter to the incidents endpoint and redeploy SecOps when available.


Tuning Guidance

Execution Agent

Causes of false positives:

  • Legitimate IT tooling (PSExec, remote management scripts) spawning LOLBins from unexpected parents. Add those tool names to the parent process allow list or create a known-good rule in SecOps with the specific ioc_key pattern.
  • Software deployment (SCCM, Intune) spawning msiexec from SYSTEM — exclude by checking SubjectUserName for NT AUTHORITY\SYSTEM combined with a known software distribution process as parent.

Thresholds to tune:

  • The parent process list (Office apps, Explorer) is intentionally narrow. If other applications legitimately spawn LOLBins in your environment, add them.
  • The suspicious path regex covers %TEMP%, %AppData%, Downloads, Desktop, Documents. If any of these paths are used by legitimate software at your site, add specific exclusions by process name.

Persistence Agent

Causes of false positives:

  • Patch Tuesday: Windows Update installs new services and scheduled tasks at scale. CM tickets created by the patch management process will auto-suppress most of these if the CM window is correctly set.
  • Software deployment creates scheduled tasks for update checks. Add product-specific task names to known-good rules after first observation.

Thresholds to tune:

  • Currently fires on all new services and tasks. If this generates too much noise after initial deployment, scope to specific high-risk service account patterns (accounts that should not be creating services) or specific task action executables from suspicious paths.

Credential Dumping Agent

Causes of false positives:

  • Vulnerability scanners authenticate to multiple machines in a short window — this is the most common false positive source for the spray detection. Add the scanner's service account to a known-good rule.
  • LSASS detection: legitimate EDR and AV tools access LSASS. The allow list (mssense.exe, xagt.exe, cyserver.exe) covers known tools. Add others as needed.

Thresholds to tune:

  • Spray threshold: 3 unique machines in 15 minutes. If your environment has legitimate services that authenticate broadly, raise the threshold or add an account-level exclusion. Do not raise it above 5 without documented justification.
  • The 15-minute window for spray detection is intentionally tight. Attacker spray is typically fast (seconds to minutes). Widening the window increases false positives.

Lateral Movement Agent

Causes of false positives:

  • Service accounts that legitimately touch many machines (backup agents, monitoring agents, SCCM) will trigger the logon chain pattern. These accounts should be in known-good rules with their specific ioc_key pattern.
  • Admin share access (5140) is common for IT staff doing remote work. Add IT admin accounts to a known-good rule for the admin_share_access pattern.

Thresholds to tune:

  • 3 machines in 30 minutes is the minimum meaningful threshold for lateral movement detection. Do not lower it. Raise it only if specific legitimate services require it, and document why.
  • Explicit credential logon (4648) fires on every legitimate RunAs and remote session. Consider scoping the explicit_credential_logon detection to: only when the source and destination are in different security tiers (user workstation → server), and suppress when the account is in a known-good list.

Exfiltration Agent

Causes of false positives:

  • Cloud backup (Azure Backup, Veeam cloud) sends large volumes to known destinations that may not have been seen in the baseline if backup was just onboarded. Add backup destination FQDNs to an exclusion list in the KQL.
  • Legitimate SaaS tools (Dropbox, OneDrive for Business) may trigger App-ID hits. Distinguish between personal cloud storage (flag) and corporate-approved tools (exclude). Maintain this list in the agent's known-good rule set.

Thresholds to tune:

  • p95_bytes * 3 is the volume anomaly multiplier. For environments with naturally variable traffic, raise to 5x. For tight environments, 2x is defensible.
  • 500MB after-hours threshold is intentionally high to avoid noise from legitimate large file operations. Lower to 100MB only if specific HIPAA data paths are known.
  • The 30-day baseline window is fixed. If the Palo Alto logs have less than 30 days in LAW, the baseline will be thin and the anomaly detection will be noisy. Wait for full 30 days of data before enabling volume anomaly detection in production.

Defense Evasion Agent

No tuning for 1102. EventID 1102 (Security log cleared) is CRITICAL regardless of context. There is no legitimate reason to clear the Security log outside a full system decommission. Do not add known-good rules for 1102.

Cortex silence detection:

  • 8 hours is the silence threshold. If Cortex has legitimate maintenance windows longer than 8 hours, either extend the threshold or add a maintenance window in SecOps to suppress the silence alert during that window.
  • The Cortex silence alert only fires when other kill chain agents are also elevated. If the environment is genuinely quiet, no alert fires even if Cortex is silent.

HIPAA Compliance Notes

Every agent produces findings that become part of the §164.312(b) audit record.

Required Fields for Audit Trail Completeness

Every incident payload posted to /api/incidents must include:

FieldRequirementReason
ioc_keyUnique, deterministic, includes hostname and timestampEnables correlation and deduplication across audit queries
raw_dataJSON blob with all source event fieldsPreserves original evidence — cannot be reconstructed from description alone
descriptionHuman-readable explanation of what was detectedAuditor-readable without technical context
source_systemPROD, DDE, or AWSRequired for HIPAA audit log completeness — identifies which environment the event came from
severityCRITICAL, HIGH, MEDIUM, or LOWRequired for incident classification under §164.308(a)(6)(ii)

What Each Agent's Findings Cover

AgentHIPAA ControlCoverage
Execution§164.312(b) — Audit ControlsCaptures unauthorized code execution events
Persistence§164.312(c)(1) — IntegrityDetects unauthorized system modifications
Credential Dumping§164.312(d) — Person AuthenticationDetects authentication bypass and credential theft attempts
Lateral Movement§164.308(a)(5)(ii)(C) — Log-in MonitoringDetects unauthorized access to multiple systems
Exfiltration§164.312(e)(1) — Transmission SecurityDetects unauthorized data transmission
Defense Evasion§164.312(b) — Audit Controls (integrity)Detects attempts to destroy the audit record itself

Incident Retention

SecOps incidents are stored in psql-secops-prod. Database backup and retention policy is covered in PostgreSQL Operations and Log Retention Verification. For HIPAA §164.312(b), audit records must be retained for 6 years. Verify that the PostgreSQL backup retention and the 6-year WORM archive policy covers the incidents table.

1102 Special Case

EventID 1102 (Security log cleared) is itself an attack on the audit trail. Under HIPAA §164.312(b), the fact that a log was cleared must itself be documented. The defense evasion agent ensures this event always generates an incident, even if a known-good rule exists for the host. Do not add 1102-based ioc_key patterns to known-good rules.


Troubleshooting

Agent runs but posts 0 incidents (environment is not clean)

  1. Check LAW for raw events:
kql
SecurityEvent
| where TimeGenerated > ago(4h15m)
| where EventID in (4688, 7045, 4698, 4624, 4625, 4648, 5140, 1102)
| summarize count() by EventID

If events are present but the agent is returning 0 hits, the KQL query's filters are too restrictive. The most common cause is a field name mismatch — check whether your LAW schema has NewProcessName vs ProcessName (Sentinel table normalization varies by DCR version). Run the KQL query directly in LAW to check.

  1. Check agent logs for the query execution:
bash
az monitor log-analytics query \
  --workspace 5d76d1f2 \
  --analytics-query "ContainerAppConsoleLogs_CL | where ContainerAppName_s == 'ca-secops-prod' | sort by TimeGenerated desc | take 200" \
  --output table

Look for LAW query failed or LAW query returned failure status — these indicate a permission problem, not a query logic problem.

LAW query permission error

The Container Apps Job managed identity needs Log Analytics Reader on the workspace.

bash
# Get the managed identity principal ID
az containerapp job show \
  --name ca-secops-prod \
  --resource-group rg-logging-logs \
  --query "identity.principalId" \
  --output tsv

# Assign Log Analytics Reader if missing
az role assignment create \
  --assignee <principal-id> \
  --role "Log Analytics Reader" \
  --scope "/subscriptions/{logging-sub}/resourceGroups/rg-logging-logs/providers/Microsoft.OperationalInsights/workspaces/cirius-logging-law-central"

Key Vault permission error

bash
# Assign Key Vault Secrets User if missing
az keyvault set-policy \
  --name cirius-openai-kv-prod \
  --object-id <principal-id> \
  --secret-permissions get

SecOps POST returning 401

The secops-api-key secret in Key Vault is either expired or missing. Fetch it and test manually:

bash
# Fetch the key (requires your own Key Vault access)
az keyvault secret show --vault-name cirius-openai-kv-prod --name secops-api-key --query value -o tsv

# Test the key
curl -s -o /dev/null -w "%{http_code}" \
  -X POST https://soc.bedrockcybersecurity.org/api/incidents \
  -H "X-API-Key: <key>" \
  -H "Content-Type: application/json" \
  -d '{"title":"test","severity":"LOW","source_system":"PROD","ioc_key":"TEST:manual-test","description":"manual test"}'

Expected: 201 (created) or 409 (duplicate key — also success). 401 means the key is wrong. Rotate the key in Key Vault and redeploy the Container Apps Job.

Defense evasion Cortex silence check always returns None

The GET /api/incidents?agent=cortex filter may not be implemented. This is a graceful degradation — the agent still detects 1102 and tool termination. Implement the agent filter on the SecOps incidents API and redeploy to enable Cortex silence detection.

Exfiltration agent: CommonSecurityLog is empty

Palo Alto traffic logs are not flowing to LAW. Check:

  1. Palo Alto CEF syslog is configured to forward to the LAW syslog ingestion endpoint
  2. The DCR for syslog includes the Palo Alto source
  3. CommonSecurityLog has recent data:
kql
CommonSecurityLog
| where TimeGenerated > ago(1h)
| summarize count() by DeviceVendor

If Palo Alto is not in the list, the CEF forwarding is broken at the Panorama/firewall level. See Cortex XDR Operations and Panorama Day-to-Day for firewall log forwarding configuration.



Document History

DateChangeAuthor
2026-05-25Initial — complete Phase 2 agent specs, full Python implementations, KQL queries, deployment, testing, tuning, HIPAA notesRory / Kobe

Internal use only — Cirius Group