Appearance
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
- Agent Architecture
- Kill Chain Agent Specifics
- Building Each Agent
- Testing Locally
- Deploying
- Tuning and Known-Good Rules
- 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.pyCreate 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_anomalyRules:
source_systemmust be the first segment — always. PROD and DDE incidents sharing anioc_keywould incorrectly suppress each other.entity_typeis the category of the thing being flagged:host,account,ip,process,url.entity_valueis normalized (uppercase for hostnames, lowercase for accounts/IPs).detection_typeis 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.
| Stage | Default Severity | Rationale |
|---|---|---|
| Execution | CRITICAL | Attacker code is running on a host |
| Persistence | HIGH | Foothold established; not yet lateral |
| Credential Dumping | CRITICAL | Credentials in hand = lateral movement imminent |
| Lateral Movement | CRITICAL | Attacker is actively spreading |
| Exfiltration | HIGH | Data leaving network |
| Defense Evasion | CRITICAL for 1102; HIGH for Cortex silence | Log 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.comcirius-breakglass@ciriusdde.comcirius-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 inNewProcessNameorCommandLine - 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 descKQL 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 descioc_key Format
PROD:host:{Host}:lolbin_execution
PROD:host:{Host}:offensive_tool_detected
PROD:host:{Host}:suspicious_path_execution
PROD:host:{Host}:powershell_download_cradleCode 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\RunHKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnceHKCU\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 descKQL 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 descKQL 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 descCM 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 Falseioc_key Format
PROD:host:{Host}:new_service_outside_cm
PROD:host:{Host}:new_scheduled_task
PROD:host:{Host}:registry_run_key_modifiedCode 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
NewProcessNameorCommandLinereferenceslsass.exeas 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, TargetUserNameioc_key Format
PROD:host:{Host}:lsass_access
PROD:ip:{IpAddress}:password_sprayKey Implementation Notes
- LSASS access = CRITICAL, no threshold — one row is enough
- Password spray: the
joinKQL 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
ShareNameisC$orADMIN$
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, AccessMaskioc_key Format
PROD:account:{TargetUserName}:multi_machine_logon
PROD:account:{SubjectUserName}:explicit_cred_lateral
PROD:host:{Host}:admin_share_accessKey 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 whereSentBytesexceeds 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) — queryApplicationProtocolin 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, ApplicationProtocolioc_key Format
PROD:ip:{SourceIP}:large_transfer_new_destination
PROD:ip:{SourceIP}:exfil_tool_detected
PROD:ip:{SourceIP}:after_hours_large_transferKey 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 descCortex 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 Noneioc_key Format
PROD:host:{Host}:audit_log_cleared
PROD:tool:cortex-xdr:silence_during_elevated_activityKey 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 DDEDo 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 PRMocking 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:8999The 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.pyThis calls run(), captures the result, and validates:
findingsis a list (not None, not a dict)- Each finding has all required keys
severityis one ofcritical|high|medium|lowsource_systemis present on every findingioc_keystarts with the correctSOURCE_SYSTEMprefixconfidenceis an integer 0–100immediate_action_requiredis 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.pyExit 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_agentStep 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_resultStep 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 deploysCI steps on every PR:
ruff check agents/— lintingpython -m pytest tests/— unit tests including schema validationpython tests/validate_finding_schema.py— format check for all agents- Build Docker image (no push on PRs)
On merge to main:
- Docker image built and pushed to
ciriusagentsprod.azurecr.io - Container Apps Job (
job-orchestrator-cirius) updated to the new image revision - 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 nowOr via CLI:
bash
az containerapp job start \
--name job-orchestrator-cirius \
--resource-group rg-logging-logsVerifying the Deploy
After the job runs, check:
- Heartbeat email received (every run sends one)
- SecOps platform shows new findings from kill chain agents
- No
confidence: 0results 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:
- 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.
- 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.
- 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_IDenv 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 injob-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_IDenv var →ciriusdde-logging-law-central - Orchestrator:
mini_orchestrator_dde.py→ deployed injob-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
DeviceCustomString3for 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 injob-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 — seeaws/cloudtrail_agent.pyfor 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 internallyDeploy 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)Related Documents
- Agent Development Guide — general agent architecture, base template, orchestrator registration, deployment pipeline
- Security Monitoring Architecture — full system overview, data flow, SecOps platform API reference
- Key Learnings — KQL gotchas (especially the Sentinel workspace requirement for SecurityEvent), LAW hostname normalization
- Kill Chain Coverage — what is covered vs. what has gaps (maintained separately)
Document History
| Date | Change |
|---|---|
| May 2026 | Initial 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. |