security operations

Implementing SIEM Correlation Rules for APT

Write multi-event correlation rules that detect APT lateral movement by chaining Windows authentication events, process execution telemetry, and network connection logs across hosts. Uses Splunk SPL and Sigma rule format to correlate Event IDs 4624, 4648, 4688, and Sysmon Events 1/3 within sliding time windows to surface attack sequences invisible to single-event detections.

apt-detectioncorrelation-ruleslateral-movementsecurity-operationssiemwindows-event-logs
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

When to Use

  • When deploying or configuring implementing siem correlation rules for apt capabilities in your environment
  • When establishing security controls aligned to compliance requirements
  • When building or improving security architecture for this domain
  • When conducting security assessments that require this implementation

Prerequisites

  • Familiarity with security operations concepts and tools
  • Access to a test or lab environment for safe execution
  • Python 3.8+ with required dependencies installed
  • Appropriate authorization for any testing activities

Instructions

  1. Install dependencies: pip install requests pyyaml sigma-cli
  2. Connect to the Splunk REST API and define correlation searches that chain multiple event types across hosts.
  3. Build Sigma rules in YAML that express multi-step detection logic for lateral movement patterns:
    • RDP logon (4624 LogonType=10) followed by service installation (7045) on same target within 15 minutes
    • Pass-the-Hash: NTLM logon (4624 LogonType=3) followed by process creation (4688) of admin tools
    • PsExec-style: Named pipe creation (Sysmon 17/18) correlated with remote service creation (7045)
  4. Convert Sigma rules to Splunk SPL using sigma-cli convert.
  5. Deploy correlation searches to Splunk ES via the REST API.
  6. Run the agent to generate and install correlation rules, then audit existing rules for coverage gaps.
python scripts/agent.py --splunk-url https://localhost:8089 --username admin --password changeme --output correlation_report.json

Examples

Detect RDP Lateral Movement Chain

index=wineventlog (EventCode=4624 Logon_Type=10) OR (EventCode=7045)
| transaction Computer maxspan=15m startswith=(EventCode=4624) endswith=(EventCode=7045)
| where eventcount >= 2
| table _time Computer Account_Name ServiceName

Sigma Rule for PsExec Lateral Movement

title: PsExec Lateral Movement Detection
logsource:
  product: windows
  service: sysmon
detection:
  pipe_created:
    EventID: 17
    PipeName|startswith: '\PSEXESVC'
  service_installed:
    EventID: 7045
    ServiceFileName|contains: 'PSEXESVC'
  timeframe: 5m
  condition: pipe_created | near service_installed
level: high
Source materials

References and resources

Everything below is rendered for inspection. Script files are read-only and never run.

References 1

api-reference.md1.7 KB

API Reference: SIEM Correlation Rules for APT

Splunk REST API

Authentication

POST /services/auth/login
Body: username=<user>&password=<pass>
Returns: { "sessionKey": "<token>" }
Header: Authorization: Splunk <sessionKey>

Saved Searches (Correlation Rules)

POST /services/saved/searches
Parameters: name, search, cron_schedule, dispatch.earliest_time,
  dispatch.latest_time, alert.severity, action.notable (1=enabled),
  action.notable.param.severity, action.notable.param.security_domain
GET /services/saved/searches?output_mode=json&count=0

Search Jobs

POST /services/search/jobs
Body: search=<SPL>, earliest_time, latest_time, output_mode=json
Returns: { "sid": "<job_id>" }
GET /services/search/jobs/<sid>?output_mode=json
GET /services/search/jobs/<sid>/results?output_mode=json&count=<n>

Sigma Rule Format (YAML)

title: <string>
status: experimental|test|stable
logsource:
  product: windows
  service: sysmon|security
detection:
  selection: { EventID: [1,3] }
  condition: selection
level: low|medium|high|critical
tags: [attack.t1021.001]

sigma-cli Conversion

sigma convert -t splunk -p sysmon rule.yml
sigma convert -t elastic-eql -p sysmon rule.yml

Key Windows Event IDs for Lateral Movement

Event ID Source Description
4624 Security Logon event (Type 3=Network, 10=RDP)
4648 Security Explicit credential logon
4688 Security Process creation
7045 System Service installation
1 Sysmon Process creation with hashes
3 Sysmon Network connection
10 Sysmon Process access (LSASS)
17/18 Sysmon Named pipe created/connected

Scripts 1

agent.py10.4 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""SIEM Correlation Rules Agent - Builds and deploys multi-event APT detection rules via Splunk and Sigma."""

import json
import os
import time
import logging
import argparse
from datetime import datetime

import yaml
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

LATERAL_MOVEMENT_RULES = [
    {
        "name": "RDP Lateral Movement Chain",
        "description": "RDP logon followed by service installation on same host within 15 minutes",
        "spl": (
            'index=wineventlog (EventCode=4624 Logon_Type=10) OR (EventCode=7045) '
            '| transaction Computer maxspan=15m startswith=(EventCode=4624) endswith=(EventCode=7045) '
            '| where eventcount >= 2 '
            '| table _time Computer Account_Name ServiceName'
        ),
        "severity": "high",
        "mitre": "T1021.001",
    },
    {
        "name": "PsExec Service Installation",
        "description": "Named pipe PSEXESVC created followed by remote service install",
        "spl": (
            'index=sysmon (EventCode=17 PipeName="\\\\PSEXESVC*") OR '
            '(index=wineventlog EventCode=7045 ServiceFileName="*PSEXESVC*") '
            '| transaction Computer maxspan=5m '
            '| where eventcount >= 2 '
            '| table _time Computer User Image ServiceName'
        ),
        "severity": "high",
        "mitre": "T1021.002",
    },
    {
        "name": "NTLM Pass-the-Hash Followed by Admin Tool",
        "description": "NTLM network logon followed by admin tool execution within 10 minutes",
        "spl": (
            'index=wineventlog EventCode=4624 Logon_Type=3 Authentication_Package=NTLM '
            '| join Computer maxspan=10m [search index=sysmon EventCode=1 '
            '(Image="*\\\\net.exe" OR Image="*\\\\net1.exe" OR Image="*\\\\wmic.exe" '
            'OR Image="*\\\\psexec.exe" OR Image="*\\\\powershell.exe")] '
            '| table _time Computer Account_Name Image CommandLine'
        ),
        "severity": "critical",
        "mitre": "T1550.002",
    },
    {
        "name": "WMI Remote Execution Chain",
        "description": "WMI process creation on remote host correlated with network logon",
        "spl": (
            'index=sysmon EventCode=1 ParentImage="*\\\\WmiPrvSE.exe" '
            '| join Computer [search index=wineventlog EventCode=4624 Logon_Type=3] '
            '| where Account_Name!="-" '
            '| stats count by Computer, Account_Name, Image, CommandLine '
            '| where count > 0'
        ),
        "severity": "high",
        "mitre": "T1047",
    },
    {
        "name": "Credential Dumping After Lateral Move",
        "description": "Network logon followed by LSASS access within 30 minutes",
        "spl": (
            'index=wineventlog EventCode=4624 Logon_Type=3 '
            '| join Computer maxspan=30m [search index=sysmon EventCode=10 '
            'TargetImage="*\\\\lsass.exe" GrantedAccess=0x1010] '
            '| table _time Computer Account_Name SourceImage GrantedAccess'
        ),
        "severity": "critical",
        "mitre": "T1003.001",
    },
]


def authenticate_splunk(base_url, username, password):
    """Authenticate to Splunk and return session headers."""
    resp = requests.post(
        f"{base_url}/services/auth/login",
        data={"username": username, "password": password},
        verify=not os.environ.get("SKIP_TLS_VERIFY", "").lower() == "true",  # Set SKIP_TLS_VERIFY=true for self-signed certs in lab environments
        timeout=30,
    )
    resp.raise_for_status()
    session_key = resp.json()["sessionKey"]
    logger.info("Authenticated to Splunk")
    return {"Authorization": f"Splunk {session_key}"}


def deploy_correlation_search(base_url, headers, rule):
    """Deploy a correlation search to Splunk ES."""
    search_payload = {
        "search": f"search {rule['spl']}",
        "name": rule["name"],
        "description": rule["description"],
        "cron_schedule": "*/15 * * * *",
        "dispatch.earliest_time": "-15m",
        "dispatch.latest_time": "now",
        "alert_type": "always",
        "alert.severity": "4" if rule["severity"] == "critical" else "3",
        "action.notable": "1",
        "action.notable.param.security_domain": "threat",
        "action.notable.param.severity": rule["severity"],
        "action.notable.param.rule_title": rule["name"],
    }
    resp = requests.post(
        f"{base_url}/services/saved/searches",
        headers=headers,
        data=search_payload,
        verify=not os.environ.get("SKIP_TLS_VERIFY", "").lower() == "true",  # Set SKIP_TLS_VERIFY=true for self-signed certs in lab environments
        timeout=30,
    )
    if resp.status_code in (200, 201):
        logger.info("Deployed correlation search: %s", rule["name"])
        return True
    logger.warning("Deploy failed for %s: %d %s", rule["name"], resp.status_code, resp.text[:100])
    return False


def generate_sigma_rule(rule):
    """Generate a Sigma-format YAML rule from a correlation definition."""
    sigma = {
        "title": rule["name"],
        "id": None,
        "status": "experimental",
        "description": rule["description"],
        "references": [f"https://attack.mitre.org/techniques/{rule['mitre']}/"],
        "logsource": {"product": "windows", "service": "sysmon"},
        "detection": {
            "selection": {"EventID": [1, 3, 17, 18]},
            "condition": "selection",
        },
        "level": rule["severity"],
        "tags": [f"attack.{rule['mitre'].lower()}"],
    }
    return yaml.dump(sigma, default_flow_style=False, sort_keys=False)


def audit_existing_searches(base_url, headers):
    """Audit existing Splunk saved searches for coverage gaps."""
    resp = requests.get(
        f"{base_url}/services/saved/searches",
        headers=headers,
        params={"output_mode": "json", "count": 0},
        verify=not os.environ.get("SKIP_TLS_VERIFY", "").lower() == "true",  # Set SKIP_TLS_VERIFY=true for self-signed certs in lab environments
        timeout=30,
    )
    if resp.status_code != 200:
        return []
    searches = resp.json().get("entry", [])
    mitre_covered = set()
    for s in searches:
        content = s.get("content", {})
        search_text = content.get("search", "").lower()
        for technique in ["t1021", "t1047", "t1053", "t1550", "t1003"]:
            if technique in search_text or technique in s.get("name", "").lower():
                mitre_covered.add(technique)
    lateral_techniques = {"t1021", "t1047", "t1053", "t1550", "t1003", "t1059", "t1570"}
    gaps = lateral_techniques - mitre_covered
    logger.info("Coverage: %d/%d lateral movement techniques covered", len(mitre_covered), len(lateral_techniques))
    return list(gaps)


def run_test_search(base_url, headers, spl, earliest="-24h"):
    """Execute a correlation search and return matching events."""
    resp = requests.post(
        f"{base_url}/services/search/jobs",
        headers=headers,
        data={"search": f"search {spl}", "earliest_time": earliest, "output_mode": "json"},
        verify=not os.environ.get("SKIP_TLS_VERIFY", "").lower() == "true",  # Set SKIP_TLS_VERIFY=true for self-signed certs in lab environments
        timeout=30,
    )
    resp.raise_for_status()
    sid = resp.json()["sid"]
    for _ in range(60):
        status = requests.get(
            f"{base_url}/services/search/jobs/{sid}",
            headers=headers, params={"output_mode": "json"},
            verify=not os.environ.get("SKIP_TLS_VERIFY", "").lower() == "true",  # Set SKIP_TLS_VERIFY=true for self-signed certs in lab environments
            timeout=30,
        ).json()
        if status["entry"][0]["content"]["isDone"]:
            break
        time.sleep(2)
    results = requests.get(
        f"{base_url}/services/search/jobs/{sid}/results",
        headers=headers, params={"output_mode": "json", "count": 50},
        verify=not os.environ.get("SKIP_TLS_VERIFY", "").lower() == "true",  # Set SKIP_TLS_VERIFY=true for self-signed certs in lab environments
        timeout=30,
    ).json()
    return results.get("results", [])


def generate_report(deployed, gaps, test_results):
    """Generate correlation rules deployment report."""
    report = {
        "timestamp": datetime.utcnow().isoformat(),
        "rules_deployed": deployed,
        "coverage_gaps": gaps,
        "test_results_summary": {r["name"]: len(r.get("hits", [])) for r in test_results},
    }
    print(f"CORRELATION RULES REPORT: {len(deployed)} deployed, {len(gaps)} gaps")
    return report


def main():
    parser = argparse.ArgumentParser(description="SIEM Correlation Rules Agent")
    parser.add_argument("--splunk-url", default=os.environ.get("SPLUNK_URL", "https://localhost:8089"))
    parser.add_argument("--username", default="admin")
    parser.add_argument("--password", required=True)
    parser.add_argument("--deploy", action="store_true", help="Deploy rules to Splunk")
    parser.add_argument("--test", action="store_true", help="Test rules against recent data")
    parser.add_argument("--sigma-export", help="Export rules as Sigma YAML to directory")
    parser.add_argument("--output", default="correlation_report.json")
    args = parser.parse_args()

    headers = authenticate_splunk(args.splunk_url, args.username, args.password)
    deployed = []
    test_results = []

    if args.deploy:
        for rule in LATERAL_MOVEMENT_RULES:
            if deploy_correlation_search(args.splunk_url, headers, rule):
                deployed.append(rule["name"])

    if args.test:
        for rule in LATERAL_MOVEMENT_RULES:
            hits = run_test_search(args.splunk_url, headers, rule["spl"])
            test_results.append({"name": rule["name"], "hits": hits})
            logger.info("Rule '%s': %d hits", rule["name"], len(hits))

    if args.sigma_export:
        import os
        os.makedirs(args.sigma_export, exist_ok=True)
        for rule in LATERAL_MOVEMENT_RULES:
            sigma_yaml = generate_sigma_rule(rule)
            fname = rule["name"].lower().replace(" ", "_") + ".yml"
            with open(os.path.join(args.sigma_export, fname), "w") as f:
                f.write(sigma_yaml)
        logger.info("Exported %d Sigma rules", len(LATERAL_MOVEMENT_RULES))

    gaps = audit_existing_searches(args.splunk_url, headers)
    report = generate_report(deployed, gaps, test_results)
    with open(args.output, "w") as f:
        json.dump(report, f, indent=2)
    logger.info("Report saved to %s", args.output)


if __name__ == "__main__":
    main()
Keep exploring