merge(learn): features schema and framework priors (decision-engine tasks 1-2)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-30 18:53:15 +02:00
co-authored by Claude Opus 5.5
32 changed files with 2999 additions and 70 deletions
+2 -2
View File
@@ -5,13 +5,13 @@ Order follows the design's commit sequence. Every task is a commit gated on the
adopted model every path is today's. Tasks 5, 7 and 10 spend model money or host hours: each starts only after adopted model every path is today's. Tasks 5, 7 and 10 spend model money or host hours: each starts only after
the maintainer confirms its budget. the maintainer confirms its budget.
- [ ] 1. Schema and features - [x] 1. Schema and features
- `learn/schema.py` (closed, typed allow-list; no identities; missing != zero), `learn/features.py` (host - `learn/schema.py` (closed, typed allow-list; no identities; missing != zero), `learn/features.py` (host
builder), new memory `KINDS`, `plane/tasks/features.py` and the `remember` input; tests enforcing the builder), new memory `KINDS`, `plane/tasks/features.py` and the `remember` input; tests enforcing the
allow-list and the exclusion of label-carrying fields. allow-list and the exclusion of label-carrying fields.
- _Requirements: 1.1, 1.2, 1.3, 7.2_ - _Requirements: 1.1, 1.2, 1.3, 7.2_
- [ ] 2. Framework tags - [x] 2. Framework tags
- `ruleset/frameworks.toml`; tag and split every framework/library entry listed in design section 2; `priors=` - `ruleset/frameworks.toml`; tag and split every framework/library entry listed in design section 2; `priors=`
in the semantic and quick loaders (default `all`, so today's scan is unchanged -- the golden benchmark test in the semantic and quick loaders (default `all`, so today's scan is unchanged -- the golden benchmark test
stays byte-identical); a lint test that no untagged framework entry remains. stays byte-identical); a lint test that no untagged framework entry remains.
@@ -1,7 +1,7 @@
{ {
"modules": 135, "modules": 140,
"counts": { "counts": {
"load_bearing": 128, "load_bearing": 133,
"standalone_capability": 7 "standalone_capability": 7
}, },
"orphaned": [], "orphaned": [],
@@ -346,6 +346,33 @@
"skills" "skills"
] ]
}, },
{
"module": "learn",
"classification": "load_bearing",
"reason": "reachable from gate, map_gate, pair_gate or the CLI",
"importers": [
"learn.features",
"plane.tasks.features",
"plane.tasks.remember"
]
},
{
"module": "learn.features",
"classification": "load_bearing",
"reason": "imported by plane.tasks.features",
"importers": [
"plane.tasks.features"
]
},
{
"module": "learn.schema",
"classification": "load_bearing",
"reason": "reachable from gate, map_gate, pair_gate or the CLI",
"importers": [
"learn.features",
"plane.tasks.remember"
]
},
{ {
"module": "map_gate", "module": "map_gate",
"classification": "load_bearing", "classification": "load_bearing",
@@ -361,6 +388,7 @@
"gate", "gate",
"hunter_tools", "hunter_tools",
"improve.evolve", "improve.evolve",
"learn.features",
"map_gate", "map_gate",
"model.scan", "model.scan",
"pairs", "pairs",
@@ -386,6 +414,7 @@
"cli", "cli",
"cpg.backend", "cpg.backend",
"cpg.session", "cpg.session",
"learn.features",
"model.candidates", "model.candidates",
"model.config_value", "model.config_value",
"model.dominance", "model.dominance",
@@ -401,6 +430,7 @@
"model.taint", "model.taint",
"plane.budget", "plane.budget",
"plane.generate", "plane.generate",
"plane.tasks.features",
"plane.tasks.repo_facts", "plane.tasks.repo_facts",
"preprocess", "preprocess",
"push.assistance", "push.assistance",
@@ -626,10 +656,12 @@
"reason": "reachable from gate, map_gate, pair_gate or the CLI", "reason": "reachable from gate, map_gate, pair_gate or the CLI",
"importers": [ "importers": [
"cli", "cli",
"learn.features",
"model.candidates", "model.candidates",
"model.report", "model.report",
"model.scan", "model.scan",
"model.specs" "model.specs",
"plane.tasks.features"
] ]
}, },
{ {
@@ -768,11 +800,13 @@
"reason": "ai-service-plane Req 4.1: the task entrypoint package the runner imports by name; runner integration pending (task 3)", "reason": "ai-service-plane Req 4.1: the task entrypoint package the runner imports by name; runner integration pending (task 3)",
"importers": [ "importers": [
"cli", "cli",
"learn.features",
"plane.engine_alerts", "plane.engine_alerts",
"plane.generate", "plane.generate",
"plane.memory", "plane.memory",
"plane.tasks.agree", "plane.tasks.agree",
"plane.tasks.alerts", "plane.tasks.alerts",
"plane.tasks.features",
"plane.tasks.remember" "plane.tasks.remember"
] ]
}, },
@@ -787,9 +821,17 @@
"classification": "load_bearing", "classification": "load_bearing",
"reason": "harnessx-removal Req 6.3: quick-mode alerts on a case's vulnerable and fixed pins, model-free", "reason": "harnessx-removal Req 6.3: quick-mode alerts on a case's vulnerable and fixed pins, model-free",
"importers": [ "importers": [
"plane.engine_alerts" "learn.features",
"plane.engine_alerts",
"plane.tasks.features"
] ]
}, },
{
"module": "plane.tasks.features",
"classification": "load_bearing",
"reason": "learned-decision-engine Req 1.1-1.3/7.2: one allow-listed feature record per candidate, model-free",
"importers": []
},
{ {
"module": "plane.tasks.loop", "module": "plane.tasks.loop",
"classification": "load_bearing", "classification": "load_bearing",
@@ -811,8 +853,10 @@
"classification": "load_bearing", "classification": "load_bearing",
"reason": "ai-service-plane Req 5.1/5.2: source-only functions and callers per file; runner integration pending", "reason": "ai-service-plane Req 5.1/5.2: source-only functions and callers per file; runner integration pending",
"importers": [ "importers": [
"learn.features",
"plane.generate", "plane.generate",
"plane.tasks.alerts", "plane.tasks.alerts",
"plane.tasks.features",
"plane.tasks.remember" "plane.tasks.remember"
] ]
}, },
@@ -872,6 +916,7 @@
"hunter_tools", "hunter_tools",
"improve.evolve", "improve.evolve",
"index", "index",
"learn.features",
"map_gate", "map_gate",
"mapping", "mapping",
"model.candidates", "model.candidates",
@@ -879,6 +924,7 @@
"model.scan", "model.scan",
"pairs", "pairs",
"plane.tasks.alerts", "plane.tasks.alerts",
"plane.tasks.features",
"plane.tasks.repo_facts", "plane.tasks.repo_facts",
"push.runner", "push.runner",
"rank", "rank",
@@ -1148,12 +1194,25 @@
"improve.evolve", "improve.evolve",
"improve.memory", "improve.memory",
"improve.validator", "improve.validator",
"learn.features",
"pairs", "pairs",
"plane.tasks.alerts", "plane.tasks.alerts",
"plane.tasks.features",
"plane.tasks.loop", "plane.tasks.loop",
"ruleset.store",
"semantic.facts",
"stage_processors" "stage_processors"
] ]
}, },
{
"module": "ruleset.frameworks",
"classification": "load_bearing",
"reason": "reachable from gate, map_gate, pair_gate or the CLI",
"importers": [
"ruleset.store",
"semantic.facts"
]
},
{ {
"module": "ruleset.store", "module": "ruleset.store",
"classification": "load_bearing", "classification": "load_bearing",
@@ -1229,6 +1288,7 @@
"cli", "cli",
"cpg.capability", "cpg.capability",
"hunter_tools", "hunter_tools",
"learn.features",
"mapping", "mapping",
"model.candidates", "model.candidates",
"model.layout", "model.layout",
@@ -1283,6 +1343,7 @@
"importers": [ "importers": [
"cli", "cli",
"hunter_tools", "hunter_tools",
"learn.features",
"mapping", "mapping",
"model.candidates", "model.candidates",
"model.layout", "model.layout",
+285
View File
@@ -939,6 +939,24 @@ spec:
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: budibase-mongo-template-nosqli-features
task: features-budibase-mongo-template-nosqli
inputs:
facts: budibase-mongo-template-nosqli-facts/facts.json
facts_summary: budibase-mongo-template-nosqli-facts/summary.json
pass_a: budibase-mongo-template-nosqli-va/units.jsonl
summary_a: budibase-mongo-template-nosqli-va/summary.json
pass_b: budibase-mongo-template-nosqli-vb/units.jsonl
summary_b: budibase-mongo-template-nosqli-vb/summary.json
pass_c: budibase-mongo-template-nosqli-vc/units.jsonl
summary_c: budibase-mongo-template-nosqli-vc/summary.json
agreed: budibase-mongo-template-nosqli-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: budibase-mongo-template-nosqli-remember - name: budibase-mongo-template-nosqli-remember
task: remember-budibase-mongo-template-nosqli task: remember-budibase-mongo-template-nosqli
inputs: inputs:
@@ -951,11 +969,30 @@ spec:
pass_c: budibase-mongo-template-nosqli-vc/units.jsonl pass_c: budibase-mongo-template-nosqli-vc/units.jsonl
summary_c: budibase-mongo-template-nosqli-vc/summary.json summary_c: budibase-mongo-template-nosqli-vc/summary.json
agreed: budibase-mongo-template-nosqli-final/agreed.json agreed: budibase-mongo-template-nosqli-final/agreed.json
features: budibase-mongo-template-nosqli-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: cct-studio-server-cmdi-features
task: features-cct-studio-server-cmdi
inputs:
facts: cct-studio-server-cmdi-facts/facts.json
facts_summary: cct-studio-server-cmdi-facts/summary.json
pass_a: cct-studio-server-cmdi-va/units.jsonl
summary_a: cct-studio-server-cmdi-va/summary.json
pass_b: cct-studio-server-cmdi-vb/units.jsonl
summary_b: cct-studio-server-cmdi-vb/summary.json
pass_c: cct-studio-server-cmdi-vc/units.jsonl
summary_c: cct-studio-server-cmdi-vc/summary.json
agreed: cct-studio-server-cmdi-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: cct-studio-server-cmdi-remember - name: cct-studio-server-cmdi-remember
task: remember-cct-studio-server-cmdi task: remember-cct-studio-server-cmdi
inputs: inputs:
@@ -968,11 +1005,30 @@ spec:
pass_c: cct-studio-server-cmdi-vc/units.jsonl pass_c: cct-studio-server-cmdi-vc/units.jsonl
summary_c: cct-studio-server-cmdi-vc/summary.json summary_c: cct-studio-server-cmdi-vc/summary.json
agreed: cct-studio-server-cmdi-final/agreed.json agreed: cct-studio-server-cmdi-final/agreed.json
features: cct-studio-server-cmdi-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: contextforge-prompt-ssti-features
task: features-contextforge-prompt-ssti
inputs:
facts: contextforge-prompt-ssti-facts/facts.json
facts_summary: contextforge-prompt-ssti-facts/summary.json
pass_a: contextforge-prompt-ssti-va/units.jsonl
summary_a: contextforge-prompt-ssti-va/summary.json
pass_b: contextforge-prompt-ssti-vb/units.jsonl
summary_b: contextforge-prompt-ssti-vb/summary.json
pass_c: contextforge-prompt-ssti-vc/units.jsonl
summary_c: contextforge-prompt-ssti-vc/summary.json
agreed: contextforge-prompt-ssti-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: contextforge-prompt-ssti-remember - name: contextforge-prompt-ssti-remember
task: remember-contextforge-prompt-ssti task: remember-contextforge-prompt-ssti
inputs: inputs:
@@ -985,11 +1041,30 @@ spec:
pass_c: contextforge-prompt-ssti-vc/units.jsonl pass_c: contextforge-prompt-ssti-vc/units.jsonl
summary_c: contextforge-prompt-ssti-vc/summary.json summary_c: contextforge-prompt-ssti-vc/summary.json
agreed: contextforge-prompt-ssti-final/agreed.json agreed: contextforge-prompt-ssti-final/agreed.json
features: contextforge-prompt-ssti-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: froxlor-admin-ip-second-order-sqli-features
task: features-froxlor-admin-ip-second-order-sqli
inputs:
facts: froxlor-admin-ip-second-order-sqli-facts/facts.json
facts_summary: froxlor-admin-ip-second-order-sqli-facts/summary.json
pass_a: froxlor-admin-ip-second-order-sqli-va/units.jsonl
summary_a: froxlor-admin-ip-second-order-sqli-va/summary.json
pass_b: froxlor-admin-ip-second-order-sqli-vb/units.jsonl
summary_b: froxlor-admin-ip-second-order-sqli-vb/summary.json
pass_c: froxlor-admin-ip-second-order-sqli-vc/units.jsonl
summary_c: froxlor-admin-ip-second-order-sqli-vc/summary.json
agreed: froxlor-admin-ip-second-order-sqli-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: froxlor-admin-ip-second-order-sqli-remember - name: froxlor-admin-ip-second-order-sqli-remember
task: remember-froxlor-admin-ip-second-order-sqli task: remember-froxlor-admin-ip-second-order-sqli
inputs: inputs:
@@ -1002,11 +1077,30 @@ spec:
pass_c: froxlor-admin-ip-second-order-sqli-vc/units.jsonl pass_c: froxlor-admin-ip-second-order-sqli-vc/units.jsonl
summary_c: froxlor-admin-ip-second-order-sqli-vc/summary.json summary_c: froxlor-admin-ip-second-order-sqli-vc/summary.json
agreed: froxlor-admin-ip-second-order-sqli-final/agreed.json agreed: froxlor-admin-ip-second-order-sqli-final/agreed.json
features: froxlor-admin-ip-second-order-sqli-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: fuxa-tdengine-escape-sqli-features
task: features-fuxa-tdengine-escape-sqli
inputs:
facts: fuxa-tdengine-escape-sqli-facts/facts.json
facts_summary: fuxa-tdengine-escape-sqli-facts/summary.json
pass_a: fuxa-tdengine-escape-sqli-va/units.jsonl
summary_a: fuxa-tdengine-escape-sqli-va/summary.json
pass_b: fuxa-tdengine-escape-sqli-vb/units.jsonl
summary_b: fuxa-tdengine-escape-sqli-vb/summary.json
pass_c: fuxa-tdengine-escape-sqli-vc/units.jsonl
summary_c: fuxa-tdengine-escape-sqli-vc/summary.json
agreed: fuxa-tdengine-escape-sqli-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: fuxa-tdengine-escape-sqli-remember - name: fuxa-tdengine-escape-sqli-remember
task: remember-fuxa-tdengine-escape-sqli task: remember-fuxa-tdengine-escape-sqli
inputs: inputs:
@@ -1019,11 +1113,30 @@ spec:
pass_c: fuxa-tdengine-escape-sqli-vc/units.jsonl pass_c: fuxa-tdengine-escape-sqli-vc/units.jsonl
summary_c: fuxa-tdengine-escape-sqli-vc/summary.json summary_c: fuxa-tdengine-escape-sqli-vc/summary.json
agreed: fuxa-tdengine-escape-sqli-final/agreed.json agreed: fuxa-tdengine-escape-sqli-final/agreed.json
features: fuxa-tdengine-escape-sqli-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: lmdeploy-media-url-ssrf-features
task: features-lmdeploy-media-url-ssrf
inputs:
facts: lmdeploy-media-url-ssrf-facts/facts.json
facts_summary: lmdeploy-media-url-ssrf-facts/summary.json
pass_a: lmdeploy-media-url-ssrf-va/units.jsonl
summary_a: lmdeploy-media-url-ssrf-va/summary.json
pass_b: lmdeploy-media-url-ssrf-vb/units.jsonl
summary_b: lmdeploy-media-url-ssrf-vb/summary.json
pass_c: lmdeploy-media-url-ssrf-vc/units.jsonl
summary_c: lmdeploy-media-url-ssrf-vc/summary.json
agreed: lmdeploy-media-url-ssrf-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: lmdeploy-media-url-ssrf-remember - name: lmdeploy-media-url-ssrf-remember
task: remember-lmdeploy-media-url-ssrf task: remember-lmdeploy-media-url-ssrf
inputs: inputs:
@@ -1036,11 +1149,30 @@ spec:
pass_c: lmdeploy-media-url-ssrf-vc/units.jsonl pass_c: lmdeploy-media-url-ssrf-vc/units.jsonl
summary_c: lmdeploy-media-url-ssrf-vc/summary.json summary_c: lmdeploy-media-url-ssrf-vc/summary.json
agreed: lmdeploy-media-url-ssrf-final/agreed.json agreed: lmdeploy-media-url-ssrf-final/agreed.json
features: lmdeploy-media-url-ssrf-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: medplum-external-auth-open-redirect-features
task: features-medplum-external-auth-open-redirect
inputs:
facts: medplum-external-auth-open-redirect-facts/facts.json
facts_summary: medplum-external-auth-open-redirect-facts/summary.json
pass_a: medplum-external-auth-open-redirect-va/units.jsonl
summary_a: medplum-external-auth-open-redirect-va/summary.json
pass_b: medplum-external-auth-open-redirect-vb/units.jsonl
summary_b: medplum-external-auth-open-redirect-vb/summary.json
pass_c: medplum-external-auth-open-redirect-vc/units.jsonl
summary_c: medplum-external-auth-open-redirect-vc/summary.json
agreed: medplum-external-auth-open-redirect-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: medplum-external-auth-open-redirect-remember - name: medplum-external-auth-open-redirect-remember
task: remember-medplum-external-auth-open-redirect task: remember-medplum-external-auth-open-redirect
inputs: inputs:
@@ -1053,11 +1185,30 @@ spec:
pass_c: medplum-external-auth-open-redirect-vc/units.jsonl pass_c: medplum-external-auth-open-redirect-vc/units.jsonl
summary_c: medplum-external-auth-open-redirect-vc/summary.json summary_c: medplum-external-auth-open-redirect-vc/summary.json
agreed: medplum-external-auth-open-redirect-final/agreed.json agreed: medplum-external-auth-open-redirect-final/agreed.json
features: medplum-external-auth-open-redirect-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: pgadmin-maintenance-sqli-features
task: features-pgadmin-maintenance-sqli
inputs:
facts: pgadmin-maintenance-sqli-facts/facts.json
facts_summary: pgadmin-maintenance-sqli-facts/summary.json
pass_a: pgadmin-maintenance-sqli-va/units.jsonl
summary_a: pgadmin-maintenance-sqli-va/summary.json
pass_b: pgadmin-maintenance-sqli-vb/units.jsonl
summary_b: pgadmin-maintenance-sqli-vb/summary.json
pass_c: pgadmin-maintenance-sqli-vc/units.jsonl
summary_c: pgadmin-maintenance-sqli-vc/summary.json
agreed: pgadmin-maintenance-sqli-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: pgadmin-maintenance-sqli-remember - name: pgadmin-maintenance-sqli-remember
task: remember-pgadmin-maintenance-sqli task: remember-pgadmin-maintenance-sqli
inputs: inputs:
@@ -1070,11 +1221,30 @@ spec:
pass_c: pgadmin-maintenance-sqli-vc/units.jsonl pass_c: pgadmin-maintenance-sqli-vc/units.jsonl
summary_c: pgadmin-maintenance-sqli-vc/summary.json summary_c: pgadmin-maintenance-sqli-vc/summary.json
agreed: pgadmin-maintenance-sqli-final/agreed.json agreed: pgadmin-maintenance-sqli-final/agreed.json
features: pgadmin-maintenance-sqli-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: qwed-sympy-eval-features
task: features-qwed-sympy-eval
inputs:
facts: qwed-sympy-eval-facts/facts.json
facts_summary: qwed-sympy-eval-facts/summary.json
pass_a: qwed-sympy-eval-va/units.jsonl
summary_a: qwed-sympy-eval-va/summary.json
pass_b: qwed-sympy-eval-vb/units.jsonl
summary_b: qwed-sympy-eval-vb/summary.json
pass_c: qwed-sympy-eval-vc/units.jsonl
summary_c: qwed-sympy-eval-vc/summary.json
agreed: qwed-sympy-eval-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: qwed-sympy-eval-remember - name: qwed-sympy-eval-remember
task: remember-qwed-sympy-eval task: remember-qwed-sympy-eval
inputs: inputs:
@@ -1087,11 +1257,30 @@ spec:
pass_c: qwed-sympy-eval-vc/units.jsonl pass_c: qwed-sympy-eval-vc/units.jsonl
summary_c: qwed-sympy-eval-vc/summary.json summary_c: qwed-sympy-eval-vc/summary.json
agreed: qwed-sympy-eval-final/agreed.json agreed: qwed-sympy-eval-final/agreed.json
features: qwed-sympy-eval-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: reactpress-findall-column-sqli-features
task: features-reactpress-findall-column-sqli
inputs:
facts: reactpress-findall-column-sqli-facts/facts.json
facts_summary: reactpress-findall-column-sqli-facts/summary.json
pass_a: reactpress-findall-column-sqli-va/units.jsonl
summary_a: reactpress-findall-column-sqli-va/summary.json
pass_b: reactpress-findall-column-sqli-vb/units.jsonl
summary_b: reactpress-findall-column-sqli-vb/summary.json
pass_c: reactpress-findall-column-sqli-vc/units.jsonl
summary_c: reactpress-findall-column-sqli-vc/summary.json
agreed: reactpress-findall-column-sqli-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: reactpress-findall-column-sqli-remember - name: reactpress-findall-column-sqli-remember
task: remember-reactpress-findall-column-sqli task: remember-reactpress-findall-column-sqli
inputs: inputs:
@@ -1104,11 +1293,30 @@ spec:
pass_c: reactpress-findall-column-sqli-vc/units.jsonl pass_c: reactpress-findall-column-sqli-vc/units.jsonl
summary_c: reactpress-findall-column-sqli-vc/summary.json summary_c: reactpress-findall-column-sqli-vc/summary.json
agreed: reactpress-findall-column-sqli-final/agreed.json agreed: reactpress-findall-column-sqli-final/agreed.json
features: reactpress-findall-column-sqli-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: unleash-addon-webhook-ssrf-features
task: features-unleash-addon-webhook-ssrf
inputs:
facts: unleash-addon-webhook-ssrf-facts/facts.json
facts_summary: unleash-addon-webhook-ssrf-facts/summary.json
pass_a: unleash-addon-webhook-ssrf-va/units.jsonl
summary_a: unleash-addon-webhook-ssrf-va/summary.json
pass_b: unleash-addon-webhook-ssrf-vb/units.jsonl
summary_b: unleash-addon-webhook-ssrf-vb/summary.json
pass_c: unleash-addon-webhook-ssrf-vc/units.jsonl
summary_c: unleash-addon-webhook-ssrf-vc/summary.json
agreed: unleash-addon-webhook-ssrf-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: unleash-addon-webhook-ssrf-remember - name: unleash-addon-webhook-ssrf-remember
task: remember-unleash-addon-webhook-ssrf task: remember-unleash-addon-webhook-ssrf
inputs: inputs:
@@ -1121,11 +1329,30 @@ spec:
pass_c: unleash-addon-webhook-ssrf-vc/units.jsonl pass_c: unleash-addon-webhook-ssrf-vc/units.jsonl
summary_c: unleash-addon-webhook-ssrf-vc/summary.json summary_c: unleash-addon-webhook-ssrf-vc/summary.json
agreed: unleash-addon-webhook-ssrf-final/agreed.json agreed: unleash-addon-webhook-ssrf-final/agreed.json
features: unleash-addon-webhook-ssrf-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: winml-cli-serve-cors-features
task: features-winml-cli-serve-cors
inputs:
facts: winml-cli-serve-cors-facts/facts.json
facts_summary: winml-cli-serve-cors-facts/summary.json
pass_a: winml-cli-serve-cors-va/units.jsonl
summary_a: winml-cli-serve-cors-va/summary.json
pass_b: winml-cli-serve-cors-vb/units.jsonl
summary_b: winml-cli-serve-cors-vb/summary.json
pass_c: winml-cli-serve-cors-vc/units.jsonl
summary_c: winml-cli-serve-cors-vc/summary.json
agreed: winml-cli-serve-cors-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: winml-cli-serve-cors-remember - name: winml-cli-serve-cors-remember
task: remember-winml-cli-serve-cors task: remember-winml-cli-serve-cors
inputs: inputs:
@@ -1138,11 +1365,30 @@ spec:
pass_c: winml-cli-serve-cors-vc/units.jsonl pass_c: winml-cli-serve-cors-vc/units.jsonl
summary_c: winml-cli-serve-cors-vc/summary.json summary_c: winml-cli-serve-cors-vc/summary.json
agreed: winml-cli-serve-cors-final/agreed.json agreed: winml-cli-serve-cors-final/agreed.json
features: winml-cli-serve-cors-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: wp-directorist-avatar-ssrf-features
task: features-wp-directorist-avatar-ssrf
inputs:
facts: wp-directorist-avatar-ssrf-facts/facts.json
facts_summary: wp-directorist-avatar-ssrf-facts/summary.json
pass_a: wp-directorist-avatar-ssrf-va/units.jsonl
summary_a: wp-directorist-avatar-ssrf-va/summary.json
pass_b: wp-directorist-avatar-ssrf-vb/units.jsonl
summary_b: wp-directorist-avatar-ssrf-vb/summary.json
pass_c: wp-directorist-avatar-ssrf-vc/units.jsonl
summary_c: wp-directorist-avatar-ssrf-vc/summary.json
agreed: wp-directorist-avatar-ssrf-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: wp-directorist-avatar-ssrf-remember - name: wp-directorist-avatar-ssrf-remember
task: remember-wp-directorist-avatar-ssrf task: remember-wp-directorist-avatar-ssrf
inputs: inputs:
@@ -1155,11 +1401,30 @@ spec:
pass_c: wp-directorist-avatar-ssrf-vc/units.jsonl pass_c: wp-directorist-avatar-ssrf-vc/units.jsonl
summary_c: wp-directorist-avatar-ssrf-vc/summary.json summary_c: wp-directorist-avatar-ssrf-vc/summary.json
agreed: wp-directorist-avatar-ssrf-final/agreed.json agreed: wp-directorist-avatar-ssrf-final/agreed.json
features: wp-directorist-avatar-ssrf-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: wp-gopay-log-filter-sqli-features
task: features-wp-gopay-log-filter-sqli
inputs:
facts: wp-gopay-log-filter-sqli-facts/facts.json
facts_summary: wp-gopay-log-filter-sqli-facts/summary.json
pass_a: wp-gopay-log-filter-sqli-va/units.jsonl
summary_a: wp-gopay-log-filter-sqli-va/summary.json
pass_b: wp-gopay-log-filter-sqli-vb/units.jsonl
summary_b: wp-gopay-log-filter-sqli-vb/summary.json
pass_c: wp-gopay-log-filter-sqli-vc/units.jsonl
summary_c: wp-gopay-log-filter-sqli-vc/summary.json
agreed: wp-gopay-log-filter-sqli-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: wp-gopay-log-filter-sqli-remember - name: wp-gopay-log-filter-sqli-remember
task: remember-wp-gopay-log-filter-sqli task: remember-wp-gopay-log-filter-sqli
inputs: inputs:
@@ -1172,11 +1437,30 @@ spec:
pass_c: wp-gopay-log-filter-sqli-vc/units.jsonl pass_c: wp-gopay-log-filter-sqli-vc/units.jsonl
summary_c: wp-gopay-log-filter-sqli-vc/summary.json summary_c: wp-gopay-log-filter-sqli-vc/summary.json
agreed: wp-gopay-log-filter-sqli-final/agreed.json agreed: wp-gopay-log-filter-sqli-final/agreed.json
features: wp-gopay-log-filter-sqli-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
usd: 0 usd: 0
calls: 0 calls: 0
- name: wp-groundhogg-confirm-redirect-features
task: features-wp-groundhogg-confirm-redirect
inputs:
facts: wp-groundhogg-confirm-redirect-facts/facts.json
facts_summary: wp-groundhogg-confirm-redirect-facts/summary.json
pass_a: wp-groundhogg-confirm-redirect-va/units.jsonl
summary_a: wp-groundhogg-confirm-redirect-va/summary.json
pass_b: wp-groundhogg-confirm-redirect-vb/units.jsonl
summary_b: wp-groundhogg-confirm-redirect-vb/summary.json
pass_c: wp-groundhogg-confirm-redirect-vc/units.jsonl
summary_c: wp-groundhogg-confirm-redirect-vc/summary.json
agreed: wp-groundhogg-confirm-redirect-final/agreed.json
outputs:
- features.jsonl
- summary.json
budget:
usd: 0
calls: 0
- name: wp-groundhogg-confirm-redirect-remember - name: wp-groundhogg-confirm-redirect-remember
task: remember-wp-groundhogg-confirm-redirect task: remember-wp-groundhogg-confirm-redirect
inputs: inputs:
@@ -1189,6 +1473,7 @@ spec:
pass_c: wp-groundhogg-confirm-redirect-vc/units.jsonl pass_c: wp-groundhogg-confirm-redirect-vc/units.jsonl
summary_c: wp-groundhogg-confirm-redirect-vc/summary.json summary_c: wp-groundhogg-confirm-redirect-vc/summary.json
agreed: wp-groundhogg-confirm-redirect-final/agreed.json agreed: wp-groundhogg-confirm-redirect-final/agreed.json
features: wp-groundhogg-confirm-redirect-features/features.jsonl
outputs: outputs:
- memory.jsonl - memory.jsonl
budget: budget:
+18
View File
@@ -0,0 +1,18 @@
# features (learned-decision-engine design section 1): one allow-listed feature record per candidate. No model.
# The template of the per-case Tasks (bound to the case's vulnerable Workspace for the functions' spans and the
# entry-point distance); `remember` stores the records as `features` rows. The image is digest-pinned.
apiVersion: ax.io/v1alpha1
kind: Task
metadata:
name: features
atespace: default
spec:
image: "localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699"
command: ["features"]
resources:
requests:
cpu: "250m"
memory: "512Mi"
limits:
cpu: "1"
memory: "1Gi"
+345
View File
@@ -2236,6 +2236,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-budibase-mongo-template-nosqli
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: budibase-mongo-template-nosqli-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-budibase-mongo-template-nosqli name: remember-budibase-mongo-template-nosqli
atespace: default atespace: default
@@ -2261,6 +2284,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-cct-studio-server-cmdi
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: cct-studio-server-cmdi-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-cct-studio-server-cmdi name: remember-cct-studio-server-cmdi
atespace: default atespace: default
@@ -2286,6 +2332,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-contextforge-prompt-ssti
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: contextforge-prompt-ssti-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-contextforge-prompt-ssti name: remember-contextforge-prompt-ssti
atespace: default atespace: default
@@ -2311,6 +2380,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-froxlor-admin-ip-second-order-sqli
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: froxlor-admin-ip-second-order-sqli-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-froxlor-admin-ip-second-order-sqli name: remember-froxlor-admin-ip-second-order-sqli
atespace: default atespace: default
@@ -2336,6 +2428,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-fuxa-tdengine-escape-sqli
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: fuxa-tdengine-escape-sqli-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-fuxa-tdengine-escape-sqli name: remember-fuxa-tdengine-escape-sqli
atespace: default atespace: default
@@ -2361,6 +2476,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-lmdeploy-media-url-ssrf
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: lmdeploy-media-url-ssrf-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-lmdeploy-media-url-ssrf name: remember-lmdeploy-media-url-ssrf
atespace: default atespace: default
@@ -2386,6 +2524,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-medplum-external-auth-open-redirect
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: medplum-external-auth-open-redirect-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-medplum-external-auth-open-redirect name: remember-medplum-external-auth-open-redirect
atespace: default atespace: default
@@ -2411,6 +2572,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-pgadmin-maintenance-sqli
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: pgadmin-maintenance-sqli-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-pgadmin-maintenance-sqli name: remember-pgadmin-maintenance-sqli
atespace: default atespace: default
@@ -2436,6 +2620,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-qwed-sympy-eval
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: qwed-sympy-eval-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-qwed-sympy-eval name: remember-qwed-sympy-eval
atespace: default atespace: default
@@ -2461,6 +2668,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-reactpress-findall-column-sqli
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: reactpress-findall-column-sqli-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-reactpress-findall-column-sqli name: remember-reactpress-findall-column-sqli
atespace: default atespace: default
@@ -2486,6 +2716,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-unleash-addon-webhook-ssrf
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: unleash-addon-webhook-ssrf-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-unleash-addon-webhook-ssrf name: remember-unleash-addon-webhook-ssrf
atespace: default atespace: default
@@ -2511,6 +2764,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-winml-cli-serve-cors
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: winml-cli-serve-cors-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-winml-cli-serve-cors name: remember-winml-cli-serve-cors
atespace: default atespace: default
@@ -2536,6 +2812,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-wp-directorist-avatar-ssrf
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: wp-directorist-avatar-ssrf-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-wp-directorist-avatar-ssrf name: remember-wp-directorist-avatar-ssrf
atespace: default atespace: default
@@ -2561,6 +2860,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-wp-gopay-log-filter-sqli
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: wp-gopay-log-filter-sqli-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-wp-gopay-log-filter-sqli name: remember-wp-gopay-log-filter-sqli
atespace: default atespace: default
@@ -2586,6 +2908,29 @@ spec:
--- ---
apiVersion: ax.io/v1alpha1 apiVersion: ax.io/v1alpha1
kind: Task kind: Task
metadata:
name: features-wp-groundhogg-confirm-redirect
atespace: default
spec:
image: localhost:5001/ousast-runner@sha256:e9964977659c4dcea7d8b25390ca0e16368ac901dd076aa6138df8303a0bb699
command:
- features
env:
- name: OUSAST_WORKSPACE_DIR
value: /workspace/case/repo
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
workspaces:
- name: wp-groundhogg-confirm-redirect-vulnerable
path: /workspace/case
---
apiVersion: ax.io/v1alpha1
kind: Task
metadata: metadata:
name: remember-wp-groundhogg-confirm-redirect name: remember-wp-groundhogg-confirm-redirect
atespace: default atespace: default
+5
View File
@@ -0,0 +1,5 @@
"""The learned decision engine (learned-decision-engine spec): instruments emit signals, a model decides.
Task 1 ships the candidate feature schema (:mod:`.schema`) and the host feature builder (:mod:`.features`); labels,
training, calibration and the decision stage follow in later tasks.
"""
+540
View File
@@ -0,0 +1,540 @@
"""Feature records per candidate, from the signals every instrument emitted (learned-decision-engine design section 1).
No model. The same per-instrument builders serve two callers:
- the plane task ``features`` (:mod:`..plane.tasks.features`), which reads a case's delivered artifacts;
- the host: :func:`build_for_scan` over a checkout's quick findings and an engine result, for ``scan`` and
``pre-push`` (a whole-repository scan, or a base..head delta with :class:`Delta`).
A candidate is ``(path, function, family)`` of one repository and pin: a function where at least one instrument
emitted a signal of that family. The key stays in the record's envelope (``candidate`` is ``path::function``); the
features never see it (:mod:`.schema`). Each instrument contributes a :class:`Part` -- its state, its values and its
version -- and :func:`record` assembles and validates the record: an instrument that did not run leaves ``null``,
never a zero.
"""
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
from ..model.taxonomy import load_families
from ..plane.tasks.repo_facts import _COMMENT_PREFIXES, DECLARATION, GLOBAL, _declared_name, enclosing, product_files
from ..preprocess import build_file_target, detect_language
from ..ruleset import DEFAULT_RULESET_DIR, PatternRule, load_ruleset
from .schema import (
BY_NAME,
LANGUAGES,
MECHANISMS,
RULE_TAG_MECHANISM,
RUNGS,
SCHEMA_VERSION,
FeatureRecordError,
feature_set_digest,
features_for,
instruments_for,
validate_record,
)
ENGINE_RULE_PREFIX = "engine:"
UNKNOWN_FAMILY = "unknown"
# The operation a sink performs, by the CWE its semantic entry binds (a closed table; anything else is `other`).
CWE_OPERATION = {
"CWE-89": "sql", "CWE-943": "sql", "CWE-78": "command", "CWE-77": "command", "CWE-94": "code_eval", "CWE-95": "code_eval",
"CWE-502": "deserialize", "CWE-22": "file_path", "CWE-98": "file_inclusion", "CWE-918": "outbound_request",
"CWE-601": "redirect", "CWE-79": "html_output",
} # fmt: skip
# A semantic source entry's id mapped to its kind; an unlisted id is `other`.
SOURCE_KIND = {
"request": "request", "django-request": "request", "req": "request", "node-request": "request", "servlet": "request",
"server": "server", "raw_body": "raw_body", "argv": "argv_stdin", "stdin": "argv_stdin", "input": "argv_stdin",
} # fmt: skip
_STEPS = re.compile(r"(\d+) steps")
_WITNESS = re.compile(r"^(?P<source>.+?) -> (?P<sink>.+?)(?: in \S+)? \(line")
_CALL = re.compile(r"(?<![A-Za-z0-9_$])([A-Za-z_]\w*)\s*\(")
@dataclass(frozen=True)
class Part:
"""One instrument's contribution to a record: its state, its feature values (only when ``ran``), its version."""
state: str
values: Mapping[str, Any] = field(default_factory=dict)
version: str | None = None
NONE = Part("none")
NOT_APPLICABLE = Part("not_applicable")
def language_of(path: str) -> str:
"""The closed language of a path (its extension), ``other`` outside :data:`.schema.LANGUAGES`."""
language = detect_language(Path(path))
return language if language in LANGUAGES else "other"
def _clip(name: str, value: Any) -> Any:
spec = BY_NAME[name]
if value is None or spec.type != "int" or isinstance(value, bool):
return value
return max(0, min(int(value), spec.cap or 0))
def record(
candidate: str,
family: str,
language: str,
profile: str,
parts: Mapping[str, Part],
*,
unit: str = "pin",
base: str | None = None,
) -> dict[str, Any]:
"""The validated record of one candidate. ``language`` sets the one-hot; an instrument absent from ``parts`` is
``none``; a value for a feature of another instrument, or outside the profile, is refused."""
closed = language if language in LANGUAGES else "other"
parts = {**parts, "language": Part("ran", {f"lang.{name}": name == closed for name in LANGUAGES})}
wanted = instruments_for(profile)
stray = sorted(set(parts) - set(wanted))
if stray:
raise FeatureRecordError(f"parts for instruments outside the {profile} profile: {stray}")
x: dict[str, Any] = {}
for spec in features_for(profile):
part = parts.get(spec.instrument, NONE)
x[spec.name] = _clip(spec.name, part.values.get(spec.name)) if part.state == "ran" else None
for name in wanted:
part = parts.get(name, NONE)
foreign = sorted(k for k in part.values if k not in BY_NAME or BY_NAME[k].instrument != name)
if foreign:
raise FeatureRecordError(f"instrument {name} supplied features it does not own: {foreign}")
instruments = {name: {"state": parts.get(name, NONE).state, "version": parts.get(name, NONE).version} for name in wanted}
out: dict[str, Any] = {
"candidate": candidate, "family": family, "language": closed, "profile": profile, "schema_version": SCHEMA_VERSION,
"feature_set_digest": feature_set_digest(), "unit": unit, "x": x, "instruments": instruments,
} # fmt: skip
if base is not None:
out["base"] = base
return validate_record(out)
# --- per-instrument parts --------------------------------------------------------------------------------------------
def ruleset_digest(rules: Iterable[PatternRule]) -> str:
"""sha256 of the rules as data: the quick instrument's version."""
canonical = json.dumps([asdict(rule) for rule in rules], sort_keys=True, separators=(",", ":"))
return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _is_prior(item: object) -> bool:
return bool(getattr(item, "framework", None) or getattr(item, "library", None))
def quick_part(hits: Sequence[tuple[str, str]], rules: Mapping[str, PatternRule], version: str | None = None) -> Part:
"""``hits`` are ``(rule_id, status)`` of the quick rules that fired in the candidate's function. Rule ids reach
the record only as mechanism buckets and the prior count."""
mechanisms = dict.fromkeys(MECHANISMS, 0)
enabled = shadow = priors = 0
precision: float | None = None
for rule_id, status in hits:
if status == "enabled":
enabled += 1
else:
shadow += 1
rule = rules.get(rule_id)
if rule is None:
mechanisms["other"] += 1
continue
for mechanism in sorted({RULE_TAG_MECHANISM.get(tag, "other") for tag in rule.tags} or {"other"}):
mechanisms[mechanism] += 1
precision = rule.precision_estimate if precision is None else max(precision, rule.precision_estimate)
priors += 1 if _is_prior(rule) else 0
values: dict[str, Any] = {"qr.enabled_hits": enabled, "qr.shadow_hits": shadow, "qr.prior_hits": priors}
values.update({f"qr.mechanism.{m}": n for m, n in mechanisms.items()})
values["qr.max_precision_estimate"] = None if precision is None else round(min(max(precision, 0.0), 1.0), 6)
return Part("ran", values, version)
@dataclass(frozen=True)
class Vocabulary:
"""The semantic entries of one language, to name a witness's sink operation and source kind and their origin."""
sinks: Mapping[str, tuple[str, str]] # call -> (operation, origin)
sources: tuple[tuple[str, str, str], ...] # (pattern, kind, origin)
@classmethod
def load(cls, language: str) -> Vocabulary:
from ..semantic.facts import FactLoadError, load_facts
try:
facts = load_facts().for_language(language)
except FactLoadError:
return cls({}, ())
sinks: dict[str, tuple[str, str]] = {}
for sink in facts.sinks:
for call in sink.calls:
sinks.setdefault(call, (CWE_OPERATION.get(sink.cwe, "other"), "prior" if _is_prior(sink) else "language"))
sources = tuple(
(pattern, SOURCE_KIND.get(src.id, "other"), "prior" if _is_prior(src) else "language")
for src in facts.sources
for pattern in src.patterns
)
return cls(sinks, sources)
def sink(self, text: str) -> tuple[str, str] | None:
name = text.strip()
for call in sorted(self.sinks, key=len, reverse=True):
if name == call or name.endswith(("." + call, "->" + call, "::" + call)) or name.startswith(call + "("):
return self.sinks[call]
return None
def source(self, text: str) -> tuple[str, str] | None:
for pattern, kind, origin in sorted(self.sources, key=lambda s: len(s[0]), reverse=True):
if pattern in text:
return kind, origin
return None
def engine_part(
findings: Sequence[Mapping[str, Any]],
*,
completion: float | None,
degraded: bool,
vocabulary: Vocabulary | None = None,
unstable: bool | None = None,
version: str | None = None,
) -> Part:
"""The engine's findings in the candidate's function (``rung`` and ``witness`` when the producer kept them)."""
rungs = [str(f["rung"]) for f in findings if str(f.get("rung")) in RUNGS]
steps = [int(m.group(1)) for f in findings if (m := _STEPS.search(str(f.get("witness") or "")))]
sink = source = None
for finding in findings:
match = _WITNESS.match(str(finding.get("witness") or ""))
if match and vocabulary is not None:
sink = sink or vocabulary.sink(match.group("sink"))
source = source or vocabulary.source(match.group("source"))
values = {
"eng.findings": len(findings), "eng.rung_max": max(rungs, key=RUNGS.index) if rungs else None,
"eng.witness_steps_min": min(steps) if steps else None, "eng.sink_kind": sink[0] if sink else None,
"eng.sink_origin": sink[1] if sink else None, "eng.source_kind": source[0] if source else None,
"eng.source_origin": source[1] if source else None, "eng.sanitizer_on_path": None,
"eng.completion": None if completion is None else round(min(max(completion, 0.0), 1.0), 6), "eng.degraded": degraded,
"eng.unstable": unstable,
} # fmt: skip
return Part("ran", values, version)
def facts_part(callers: Sequence[Mapping[str, Any]], function: str, version: str | None = None) -> Part:
"""``callers`` are the repo-facts call sites of the candidate's function in other files."""
files = {str(site.get("path")) for site in callers}
return Part("ran", {"facts.callers": len(callers), "facts.caller_files": len(files), "facts.is_global": function == GLOBAL}, version)
def function_span(lines: Sequence[str], language: str, function: str) -> int | None:
"""Lines of the first declaration of ``function`` up to the next declaration (Python: up to the next line indented
no deeper), ``None`` when the file does not declare it. ``<global>`` counts the whole file."""
if function == GLOBAL:
return len(lines)
pattern = DECLARATION.get(language)
if pattern is None:
return None
start = next((i for i, line in enumerate(lines) if _declared_name(pattern, line) == function), None)
if start is None:
return None
indent = len(lines[start]) - len(lines[start].lstrip(" \t"))
for end in range(start + 1, len(lines)):
line = lines[end]
if language == "python":
if line.strip() and not line.lstrip().startswith("#") and len(line) - len(line.lstrip(" \t")) <= indent:
return end - start
elif _declared_name(pattern, line):
return end - start
return len(lines) - start
def source_part(lines: Sequence[str] | None, language: str, function: str) -> Part:
if lines is None:
return NONE
span = function_span(lines, language, function)
return Part("ran", {"facts.function_lines": span}) if span is not None else Part("failed")
def verify_part(passes: Mapping[str, Sequence[Mapping[str, Any]]], candidate: str, line: int | None, version: str | None = None) -> Part:
"""Pass verdicts of one candidate from the passes' ``units.jsonl``: asked in a pass when a unit's candidates list
it, flagged when that unit's ``flagged`` names it. ``none`` when no pass asked it."""
flags: dict[str, bool | None] = {}
turns: list[float] = []
offsets: list[int] = []
for label in ("a", "b", "c"):
asked = [u for u in passes.get(label) or () if any(f"{c[0]}::{c[1]}" == candidate for c in u.get("candidates") or ())]
if not asked:
flags[label] = None
continue
flagged = [f for u in asked for f in u.get("flagged") or () if f.get("candidate") == candidate]
flags[label] = bool(flagged)
turns.extend(float(u["turns"]) for u in asked if isinstance(u.get("turns"), (int, float)))
for finding in flagged:
reported = str(finding.get("reported_at") or "").rpartition(":")[2]
if line is not None and reported.isdigit():
offsets.append(abs(int(reported) - line))
if flags["a"] is None and flags["b"] is None:
return NONE
values = {
"verify.flag_a": flags["a"], "verify.flag_b": flags["b"], "verify.flag_c": flags["c"],
"verify.votes": sum(1 for v in flags.values() if v), "verify.turns_mean": round(sum(turns) / len(turns), 3) if turns else None,
"verify.site_offset": min(offsets) if offsets else None,
} # fmt: skip
return Part("ran", values, version)
def agree_part(final: str | None) -> Part:
return Part("ran", {"agree.final": final}) if final else NONE
# --- the host path ---------------------------------------------------------------------------------------------------
@dataclass(frozen=True)
class Delta:
"""A base..head delta: the base checkout, the head-side changed line ranges per path (``git diff -U0``), and the
base's quick findings and engine result, whose signals decide novelty."""
root: Path
changed: Mapping[str, Sequence[tuple[int, int]]]
findings: Sequence[Any] = ()
engine_result: Mapping[str, Any] | None = None
pin: str | None = None
class SourceIndex:
"""A checkout's product files read once: lines per path, the enclosing function of a line, and the name-level
caller graph (the repo-facts patterns) for the entry-point distance."""
def __init__(self, root: Path) -> None:
self.root = Path(root)
self._lines: dict[str, list[str] | None] = {}
self._callers: dict[str, set[str]] | None = None
self._files: list[str] | None = None
def files(self) -> list[str]:
if self._files is None:
self._files = [p.relative_to(self.root).as_posix() for p in product_files(self.root)]
return self._files
def lines(self, path: str) -> list[str] | None:
if path not in self._lines:
file = self.root / path
self._lines[path] = file.read_text(encoding="utf-8", errors="replace").splitlines() if file.is_file() else None
return self._lines[path]
def function_at(self, path: str, line: int | None, fallback: str | None = None) -> str:
lines = self.lines(path)
language = detect_language(Path(path))
if lines and language in DECLARATION and line and 0 < line <= len(lines):
return enclosing(lines, line - 1, language)
return fallback or GLOBAL
def callers(self) -> dict[str, set[str]]:
"""Function name -> the names of the functions that call it anywhere in the product files."""
if self._callers is None:
declared: dict[str, set[str]] = {}
files = self.files()
for path in files:
language = detect_language(Path(path))
pattern = DECLARATION.get(language)
if pattern is not None:
declared.setdefault(language, set()).update(
n for line in self.lines(path) or () if (n := _declared_name(pattern, line))
)
graph: dict[str, set[str]] = {}
for path in files:
language = detect_language(Path(path))
names, lines = declared.get(language, set()), self.lines(path) or []
for index, line in enumerate(lines):
if line.lstrip().startswith(_COMMENT_PREFIXES):
continue
for match in _CALL.finditer(line):
if match.group(1) in names:
graph.setdefault(match.group(1), set()).add(enclosing(lines, index, language))
self._callers = graph
return self._callers
def entry_distance(function: str, entries: set[str], callers: Mapping[str, set[str]], cap: int = 6) -> int:
"""Caller-graph hops from an entry point to ``function``; ``cap`` means not reached within ``cap - 1`` hops."""
frontier, seen = {function}, {function}
for hops in range(cap):
if frontier & entries:
return hops
frontier = {c for name in frontier for c in callers.get(name, ()) if c not in seen}
seen |= frontier
if not frontier:
break
return cap
def entry_names(root: Path, paths: Iterable[str]) -> tuple[set[str], set[str]]:
"""(entry function names, paths with a file-level entry) by :func:`..mapping.analyze_entry_points`."""
from ..mapping import analyze_entry_points
targets = [build_file_target(root, root / p) for p in sorted(set(paths)) if (root / p).is_file()]
records = analyze_entry_points(root, targets)
return {r.function_name for r in records if r.function_name}, {r.path for r in records}
def _family_of(rule: PatternRule | None) -> str:
if rule is None:
return UNKNOWN_FAMILY
family = load_families().family_of_cwe(rule.cwe)
return family.id if family is not None else UNKNOWN_FAMILY
def _engine_state(result: Mapping[str, Any] | None) -> str:
if result is None:
return "none"
return "ran" if int(result.get("questions") or 0) > 0 else "failed"
def _signals(
index: SourceIndex, findings: Sequence[Any], engine_result: Mapping[str, Any] | None, rules: Mapping[str, PatternRule]
) -> tuple[dict[tuple[str, str, str], list[tuple[str, str]]], dict[tuple[str, str, str], list[Mapping[str, Any]]]]:
quick: dict[tuple[str, str, str], list[tuple[str, str]]] = {}
for finding in findings:
rule_id = str(finding.finding_id).split(":", 1)[0]
function = index.function_at(finding.path, finding.line, finding.function_name)
quick.setdefault((finding.path, function, _family_of(rules.get(rule_id))), []).append((rule_id, str(finding.status)))
engine: dict[tuple[str, str, str], list[Mapping[str, Any]]] = {}
for finding in (engine_result or {}).get("findings") or []:
path, _, rest = str(finding.get("site") or "").partition(":")
line_text, _, function_name = rest.partition(":")
line = int(line_text) if line_text.isdigit() else None
function = index.function_at(path, line, function_name or None)
engine.setdefault((path, function, str(finding.get("family") or UNKNOWN_FAMILY)), []).append(finding)
return quick, engine
def _changed_in(index: SourceIndex, changed: Mapping[str, Sequence[tuple[int, int]]]) -> dict[tuple[str, str], int]:
"""(path, function) -> changed lines inside it, by the head's enclosing declarations."""
counts: dict[tuple[str, str], int] = {}
for path, ranges in changed.items():
for lo, hi in ranges:
for line in range(int(lo), int(hi) + 1):
key = (path, index.function_at(path, line))
counts[key] = counts.get(key, 0) + 1
return counts
def _body_calls(lines: Sequence[str] | None, language: str, function: str) -> set[str]:
if not lines:
return set()
return {m.group(1) for i, line in enumerate(lines) if enclosing(lines, i, language) == function for m in _CALL.finditer(line)}
def build_for_scan(
root: Path,
findings: Sequence[Any],
engine_result: Mapping[str, Any] | None,
*,
base: Delta | None = None,
rules: Sequence[PatternRule] | None = None,
quick_languages: Iterable[str] | None = None,
engine_languages: Iterable[str] | None = None,
) -> list[dict[str, Any]]:
"""``static`` records for a checkout: one per (path, function, family) with a quick or engine signal. ``findings``
are the quick findings (enabled and shadow alike), ``engine_result`` the engine's record (``findings`` with
``site``/``family``/``rung``/``witness``, ``questions``, ``completed``, ``degradations``; ``None`` when the engine
did not run, ``questions == 0`` is ``failed``). With ``base`` only functions enclosing a changed line are
candidates, and ``delta.*`` compares their signals with the base's."""
from ..plane.tasks.alerts import covers
from ..plane.tasks.alerts import engine_languages as engine_covered
from ..plane.tasks.alerts import quick_languages as quick_covered
from ..semantic.facts import FactLoadError, load_facts
root = Path(root)
loaded = tuple(rules) if rules is not None else load_ruleset(DEFAULT_RULESET_DIR)
by_id = {rule.rule_id: rule for rule in loaded}
quick_langs = set(quick_languages if quick_languages is not None else quick_covered())
engine_langs = set(engine_languages if engine_languages is not None else engine_covered())
index = SourceIndex(root)
quick, engine = _signals(index, findings, engine_result, by_id)
keys = sorted(set(quick) | set(engine))
changed: dict[tuple[str, str], int] = {}
base_quick: dict[tuple[str, str, str], list[tuple[str, str]]] = {}
base_engine: dict[tuple[str, str, str], list[Mapping[str, Any]]] = {}
base_index: SourceIndex | None = None
if base is not None:
changed = _changed_in(index, base.changed)
keys = [k for k in keys if (k[0], k[1]) in changed]
base_index = SourceIndex(base.root)
base_quick, base_engine = _signals(base_index, base.findings, base.engine_result, by_id)
entries, file_entries = entry_names(root, {k[0] for k in keys}) if keys else (set(), set())
callers = index.callers() if keys else {}
quick_version = ruleset_digest(loaded)
engine_state = _engine_state(engine_result)
completed, questions = (engine_result or {}).get("completed"), (engine_result or {}).get("questions")
completion = int(completed) / int(questions) if isinstance(completed, int) and isinstance(questions, int) and questions else None
degraded = bool((engine_result or {}).get("degradations"))
vocabularies: dict[str, Vocabulary] = {}
try:
sanitizer_calls = {language: {c for s in load_facts().for_language(language).sanitizers for c in s.calls} for language in LANGUAGES}
except FactLoadError:
sanitizer_calls = {}
out: list[dict[str, Any]] = []
for key in keys:
path, function, family = key
language = detect_language(Path(path))
parts: dict[str, Part] = {}
parts["quick"] = quick_part(quick.get(key, []), by_id, quick_version) if covers(quick_langs, language) else NONE
if language in engine_langs and engine_state != "none":
vocabulary = vocabularies.setdefault(language, Vocabulary.load(language))
parts["engine"] = (
engine_part(engine.get(key, []), completion=completion, degraded=degraded, vocabulary=vocabulary)
if engine_state == "ran" else Part("failed")
) # fmt: skip
lines = index.lines(path)
parts["source"] = source_part(lines, language, function)
distance = 0 if function == GLOBAL and path in file_entries else entry_distance(function, entries, callers)
parts["entry_points"] = Part("ran", {"facts.entry_distance": distance})
parts["facts"] = facts_part(_callers_of(index, callers, path, function), function)
if base is None or base_index is None:
parts["delta"] = NOT_APPLICABLE
else:
head_n = len(quick.get(key, [])) + len(engine.get(key, []))
base_n = len(base_quick.get(key, [])) + len(base_engine.get(key, []))
novelty = "new" if base_n == 0 else "worsened" if head_n > base_n else "unchanged"
known = sanitizer_calls.get(language, set())
removed = bool((_body_calls(base_index.lines(path), language, function) - _body_calls(lines, language, function)) & known)
values = {"delta.novelty": novelty, "delta.changed_lines": changed.get((path, function), 0), "delta.sanitizer_removed": removed}
parts["delta"] = Part("ran", values)
unit, pin = ("delta", base.pin) if base is not None else ("pin", None)
out.append(record(f"{path}::{function}", family, language, "static", parts, unit=unit, base=pin))
return out
def _callers_of(index: SourceIndex, callers: Mapping[str, set[str]], path: str, function: str) -> list[dict[str, Any]]:
"""The call sites of ``function`` in other product files, as repo-facts records them (capped at 40)."""
if function == GLOBAL or function not in callers:
return []
sites: list[dict[str, Any]] = []
pattern = re.compile(r"(?<![A-Za-z0-9_$])" + re.escape(function) + r"\s*\(")
for other in index.files():
if other == path or detect_language(Path(other)) != detect_language(Path(path)):
continue
for number, line in enumerate(index.lines(other) or (), start=1):
if pattern.search(line) and not line.lstrip().startswith(_COMMENT_PREFIXES):
sites.append({"path": other, "line": number})
if len(sites) >= 40:
return sites
return sites
__all__ = [
"CWE_OPERATION", "NONE", "NOT_APPLICABLE", "SOURCE_KIND", "Delta", "Part", "SourceIndex", "Vocabulary", "agree_part",
"build_for_scan", "engine_part", "entry_distance", "entry_names", "facts_part", "function_span", "language_of", "quick_part", "record",
"ruleset_digest", "source_part", "verify_part",
] # fmt: skip
+258
View File
@@ -0,0 +1,258 @@
"""The candidate feature record: a closed, typed allow-list (learned-decision-engine Req 1, 7.2; design section 1).
A candidate is ``(repo, pin, path, function, family)``. That key lives in the record's envelope, for joining labels
and grouping folds, and is never a feature. The features are the closed list :data:`FEATURES`: each has a name, the
instrument that produces it, a type (``bool``, ``int`` with a cap, ``float`` in [0, 1], or ``enum`` over a closed
vocabulary), a profile (``static``: what ``scan``/``pre-push`` compute locally without a model; ``plane``: adds
verify, agree and model roles) and whether it is a framework *prior* (Req 8.2). There is no free text, no
identifier and no raw count of a repository-specific token: rule ids enter only through the closed mechanism
buckets and the prior count, because a rule written after one case's miss would otherwise name that case.
**Missing is not negative (Req 1.2).** A record carries ``instruments: {name: {state, version}}`` with ``state`` in
:data:`STATES`:
- ``ran``: the instrument read its input for this candidate; its features hold values (``0`` is a real zero);
- ``none``: no coverage -- the instrument has no rule/model for the language, or it was not part of the run;
- ``failed``: the instrument broke or did not demonstrably read its input (never zero findings);
- ``not_applicable``: ``delta.*`` on a whole-repository scan, ``facts.*`` on a pair excerpt.
Every feature of an instrument whose state is not ``ran`` is ``null``. A feature of an instrument that ran is
``null`` only where the spec says ``nullable`` (a minimum over no findings, an answer to a pass never asked).
At training time a ``null`` becomes 0 plus one indicator ``missing.<instrument>`` per instrument.
**Excluded** (label information or identities, :data:`EXCLUDED_FIELDS`): ``site_match``, ``in_fix_range``, every
fixed-pin alert, declared sites and ``case.json``, costs, rule ids, paths, file and function names, commit ids and
repository names. :func:`validate_record` rejects them anywhere in a record's features, and the builders drop them on
read.
Versioning: :data:`SCHEMA_VERSION` (bumped on any spec change) and :func:`feature_set_digest` (sha256 of the
canonical :data:`FEATURES`), recorded in every record, so a model trained on one feature set never scores another.
"""
from __future__ import annotations
import hashlib
import json
import math
from collections.abc import Iterable, Mapping
from dataclasses import asdict, dataclass
from typing import Any, Literal
SCHEMA_VERSION = 1
PROFILES = ("static", "plane")
STATES = ("ran", "none", "failed", "not_applicable")
UNITS = ("pin", "delta")
Profile = Literal["static", "plane"]
FeatureType = Literal["bool", "int", "float", "enum"]
# The instruments, in record order. `language` always runs (it is the file's extension).
INSTRUMENTS = (
"language", "quick", "engine", "facts", "source", "entry_points", "roles", "delta", "verify", "agree", "model_sinks",
) # fmt: skip
# The languages the engine or quick mode covers; anything else is `other` (a one-hot, never a name).
LANGUAGES = ("c", "cpp", "groovy", "java", "javascript", "php", "python", "typescript", "other")
# The closed mechanism vocabulary: the ids of `benchmarks/pairs/mechanisms.toml` (a test keeps them equal).
MECHANISMS = (
"source_reaches_sink", "container_taint", "config_sink_weak_literal", "unchecked_length_copy", "unchecked_alloc_size",
"null_deref_unguarded", "signed_size_operand", "fixed_buffer_capacity", "identity_from_request_body",
"missing_auth_guard", "secret_in_client", "permissive_default", "path_join_user_input", "prototype_pollution", "redos",
"uaf", "type_confusion", "validation_strength", "cross_artifact", "unconstrained_protected_read",
"unconstrained_protected_write", "unguarded_privileged_action", "other",
) # fmt: skip
# A quick rule's `tags` mapped into the mechanism vocabulary; an unmapped tag counts as `other`.
RULE_TAG_MECHANISM = {
"injection": "source_reaches_sink",
"syscall_entry": "source_reaches_sink",
"deserialization": "source_reaches_sink",
"network_entry": "source_reaches_sink",
"filesystem_entry": "path_join_user_input",
"memory_unsafe": "unchecked_length_copy",
"crypto": "config_sink_weak_literal",
"insecure_default": "permissive_default",
}
RUNGS = ("suspicion", "model_corroborated", "model_entailed", "execution_confirmed")
OPERATIONS = (
"sql", "command", "code_eval", "deserialize", "file_path", "file_inclusion", "outbound_request", "redirect", "html_output",
"other",
) # fmt: skip
SOURCE_KINDS = ("request", "server", "raw_body", "argv_stdin", "inferred_role", "prior", "other")
ORIGINS = ("language", "inferred_wrapper", "model_role", "prior")
NOVELTY = ("new", "worsened", "unchanged", "unknown", "not_applicable")
FINALS = ("agreed", "disputed", "rejected")
# Fields that carry label information or an identity. None may be a feature; builders drop them on read.
EXCLUDED_FIELDS = frozenset(
{
"site_match", "in_fix_range", "fixed_ranges", "ranges", "sites", "declared_sites", "case", "case_json",
"usd", "cost", "rule_id", "rule_ids", "path", "file", "function", "candidate", "repo", "repository", "pin",
"commit", "base", "sha", "title", "witness", "site", "reported_at",
}
) # fmt: skip
class FeatureRecordError(ValueError):
"""A record names a feature outside the allow-list, a value outside its type, or a label/identity field."""
@dataclass(frozen=True)
class FeatureSpec:
name: str
instrument: str
type: FeatureType
profile: Profile
cap: int | None = None # int: the value is clipped to [0, cap]
vocabulary: tuple[str, ...] = () # enum: the closed set of values
prior: bool = False # a framework/library prior (Req 8.2): off by default for the engine
nullable: bool = False # may be null while its instrument ran (a minimum over nothing, a pass never asked)
def _specs() -> tuple[FeatureSpec, ...]:
specs: list[FeatureSpec] = [FeatureSpec(f"lang.{name}", "language", "bool", "static") for name in LANGUAGES]
specs += [
FeatureSpec("qr.enabled_hits", "quick", "int", "static", cap=20),
FeatureSpec("qr.shadow_hits", "quick", "int", "static", cap=20),
]
specs += [FeatureSpec(f"qr.mechanism.{m}", "quick", "int", "static", cap=10) for m in MECHANISMS]
specs += [
FeatureSpec("qr.max_precision_estimate", "quick", "float", "static", nullable=True),
FeatureSpec("qr.prior_hits", "quick", "int", "static", cap=10, prior=True),
FeatureSpec("eng.findings", "engine", "int", "static", cap=10),
FeatureSpec("eng.rung_max", "engine", "enum", "static", vocabulary=RUNGS, nullable=True),
FeatureSpec("eng.witness_steps_min", "engine", "int", "static", cap=50, nullable=True),
FeatureSpec("eng.sink_kind", "engine", "enum", "static", vocabulary=OPERATIONS, nullable=True),
FeatureSpec("eng.source_kind", "engine", "enum", "static", vocabulary=SOURCE_KINDS, nullable=True),
FeatureSpec("eng.sink_origin", "engine", "enum", "static", vocabulary=ORIGINS, nullable=True),
FeatureSpec("eng.source_origin", "engine", "enum", "static", vocabulary=ORIGINS, nullable=True),
FeatureSpec("eng.sanitizer_on_path", "engine", "bool", "static", nullable=True),
FeatureSpec("eng.completion", "engine", "float", "static", nullable=True),
FeatureSpec("eng.degraded", "engine", "bool", "static"),
FeatureSpec("eng.unstable", "engine", "bool", "static", nullable=True),
FeatureSpec("facts.callers", "facts", "int", "static", cap=40),
FeatureSpec("facts.caller_files", "facts", "int", "static", cap=20),
FeatureSpec("facts.is_global", "facts", "bool", "static"),
FeatureSpec("facts.function_lines", "source", "int", "static", cap=500),
FeatureSpec("facts.entry_distance", "entry_points", "int", "static", cap=6),
FeatureSpec("roles.sink_confidence", "roles", "float", "static", nullable=True),
FeatureSpec("roles.source_in_function", "roles", "bool", "static"),
FeatureSpec("delta.novelty", "delta", "enum", "static", vocabulary=NOVELTY),
FeatureSpec("delta.changed_lines", "delta", "int", "static", cap=200),
FeatureSpec("delta.sanitizer_removed", "delta", "bool", "static"),
FeatureSpec("verify.flag_a", "verify", "bool", "plane", nullable=True),
FeatureSpec("verify.flag_b", "verify", "bool", "plane", nullable=True),
FeatureSpec("verify.flag_c", "verify", "bool", "plane", nullable=True),
FeatureSpec("verify.votes", "verify", "int", "plane", cap=3),
FeatureSpec("verify.turns_mean", "verify", "float", "plane", nullable=True),
FeatureSpec("verify.site_offset", "verify", "int", "plane", cap=200, nullable=True),
FeatureSpec("agree.final", "agree", "enum", "plane", vocabulary=FINALS),
FeatureSpec("ms.flagged", "model_sinks", "bool", "plane"),
FeatureSpec("ms.operation", "model_sinks", "enum", "plane", vocabulary=OPERATIONS, nullable=True),
]
return tuple(specs)
FEATURES: tuple[FeatureSpec, ...] = _specs()
BY_NAME: Mapping[str, FeatureSpec] = {spec.name: spec for spec in FEATURES}
# `float` features are fractions or means; `verify.turns_mean` is a mean of tool turns, the only unbounded float.
UNBOUNDED_FLOATS = frozenset({"verify.turns_mean"})
def features_for(profile: str) -> tuple[FeatureSpec, ...]:
"""The features a record of ``profile`` carries: ``static`` never sees an instrument absent in deployment."""
if profile not in PROFILES:
raise FeatureRecordError(f"unknown profile {profile!r} (one of {', '.join(PROFILES)})")
return FEATURES if profile == "plane" else tuple(spec for spec in FEATURES if spec.profile == "static")
def instruments_for(profile: str) -> tuple[str, ...]:
used = {spec.instrument for spec in features_for(profile)}
return tuple(name for name in INSTRUMENTS if name in used)
def feature_set_digest() -> str:
"""sha256 of the canonical feature list: any change to a name, type, cap, vocabulary or profile changes it."""
canonical = json.dumps([asdict(spec) for spec in FEATURES], sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _check_value(spec: FeatureSpec, value: object) -> None:
where = f"feature {spec.name}"
if spec.type == "bool":
if not isinstance(value, bool):
raise FeatureRecordError(f"{where}: expected a bool, got {value!r}")
elif spec.type == "int":
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= (spec.cap or 0):
raise FeatureRecordError(f"{where}: expected an int in [0, {spec.cap}], got {value!r}")
elif spec.type == "float":
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0:
raise FeatureRecordError(f"{where}: expected a finite non-negative number, got {value!r}")
if spec.name not in UNBOUNDED_FLOATS and value > 1:
raise FeatureRecordError(f"{where}: expected a fraction in [0, 1], got {value!r}")
elif not isinstance(value, str) or value not in spec.vocabulary:
raise FeatureRecordError(f"{where}: {value!r} is outside the vocabulary {list(spec.vocabulary)}")
def validate_x(x: Mapping[str, object], instruments: Mapping[str, Mapping[str, object]], profile: str) -> None:
"""The features of one record against the allow-list and the instruments' states."""
specs = {spec.name: spec for spec in features_for(profile)}
unknown = sorted(set(x) - set(specs))
if unknown:
label = sorted(k for k in unknown if k.rsplit(".", 1)[-1] in EXCLUDED_FIELDS)
why = f" (label or identity fields: {label})" if label else ""
raise FeatureRecordError(f"features outside the {profile} allow-list: {unknown}{why}")
missing = sorted(set(specs) - set(x))
if missing:
raise FeatureRecordError(f"features missing from the record: {missing}")
wanted = set(instruments_for(profile))
if set(instruments) != wanted:
raise FeatureRecordError(f"instruments {sorted(instruments)} != the {profile} profile's {sorted(wanted)}")
for name, info in instruments.items():
if not isinstance(info, Mapping) or info.get("state") not in STATES:
raise FeatureRecordError(f"instrument {name}: state must be one of {STATES}, got {info!r}")
for name, spec in specs.items():
value, state = x[name], instruments[spec.instrument]["state"]
if state != "ran":
if value is not None:
raise FeatureRecordError(f"feature {name}: instrument {spec.instrument} is {state}, so the value must be null")
continue
if value is None:
if not spec.nullable:
raise FeatureRecordError(f"feature {name}: instrument {spec.instrument} ran, so null is not allowed here")
continue
_check_value(spec, value)
RECORD_FIELDS = ("candidate", "family", "language", "profile", "schema_version", "feature_set_digest", "unit", "x", "instruments")
def validate_record(record: Mapping[str, Any]) -> dict[str, Any]:
"""A feature record, checked: envelope fields, versions, profile, and :func:`validate_x`. Returns it as a dict."""
extra = sorted(set(record) - {*RECORD_FIELDS, "base"})
if extra:
raise FeatureRecordError(f"record fields outside the schema: {extra}")
missing = [name for name in RECORD_FIELDS if name not in record]
if missing:
raise FeatureRecordError(f"record fields missing: {missing}")
if record["schema_version"] != SCHEMA_VERSION or record["feature_set_digest"] != feature_set_digest():
raise FeatureRecordError("record built for another schema version or feature set")
if record["unit"] not in UNITS:
raise FeatureRecordError(f"unit {record['unit']!r} is not one of {UNITS}")
if not isinstance(record["x"], Mapping) or not isinstance(record["instruments"], Mapping):
raise FeatureRecordError("x and instruments must be objects")
validate_x(record["x"], record["instruments"], str(record["profile"]))
return dict(record)
def names(specs: Iterable[FeatureSpec] = FEATURES) -> tuple[str, ...]:
return tuple(spec.name for spec in specs)
__all__ = [
"BY_NAME", "EXCLUDED_FIELDS", "FEATURES", "FINALS", "INSTRUMENTS", "LANGUAGES", "MECHANISMS", "NOVELTY", "OPERATIONS",
"ORIGINS", "PROFILES", "RULE_TAG_MECHANISM", "RUNGS", "SCHEMA_VERSION", "SOURCE_KINDS", "STATES", "UNITS",
"FeatureRecordError", "FeatureSpec", "feature_set_digest", "features_for", "instruments_for", "names",
"validate_record", "validate_x",
] # fmt: skip
+1
View File
@@ -68,6 +68,7 @@ SPEC_OWNED = {
"plane.tasks.alerts": "harnessx-removal Req 6.3: quick-mode alerts on a case's vulnerable and fixed pins, model-free", "plane.tasks.alerts": "harnessx-removal Req 6.3: quick-mode alerts on a case's vulnerable and fixed pins, model-free",
"plane.engine_alerts": "harnessx-removal Req 6.3: engine alerts where quick mode has no rules (PHP), on the host", "plane.engine_alerts": "harnessx-removal Req 6.3: engine alerts where quick mode has no rules (PHP), on the host",
"plane.tasks.loop": "harnessx-removal Req 6.3: the improvement loop's snapshot, measure, propose and improve steps", "plane.tasks.loop": "harnessx-removal Req 6.3: the improvement loop's snapshot, measure, propose and improve steps",
"plane.tasks.features": "learned-decision-engine Req 1.1-1.3/7.2: one allow-listed feature record per candidate, model-free",
"plane.workspaces": "ai-service-plane task 4: Workspace manifests per case pin of a population; `ousast plane workspaces`", "plane.workspaces": "ai-service-plane task 4: Workspace manifests per case pin of a population; `ousast plane workspaces`",
"plane.runner": "ai-service-plane Req 4.1-4.4: the ax-task-runner entrypoint (PID 1 of the task image); delivers the output directory", "plane.runner": "ai-service-plane Req 4.1-4.4: the ax-task-runner entrypoint (PID 1 of the task image); delivers the output directory",
} }
+32 -9
View File
@@ -15,7 +15,8 @@ under ``--plane`` (default ``plane/``), everything a Run over the set needs besi
(``triage.json``, for the measurement); (``triage.json``, for the measurement);
- ``tasks/<run>.yaml``: per case, one Task per template (two for ``verify``: ``OUSAST_PASS=a|b``) binding the two - ``tasks/<run>.yaml``: per case, one Task per template (two for ``verify``: ``OUSAST_PASS=a|b``) binding the two
Workspaces and naming the files as env; Workspaces and naming the files as env;
- ``runs/<run>.yaml``: the Run, per case ``facts -> verify a, verify b -> agree -> verify c -> final -> remember``, - ``runs/<run>.yaml``: the Run, per case ``facts -> verify a, verify b -> agree -> verify c -> final -> features ->
remember`` (``features``: one allow-listed feature record per candidate, learned-decision-engine task 1),
with budgets, annotated with the population and split (``openultrasast.io/population``/``split``: the files' with budgets, annotated with the population and split (``openultrasast.io/population``/``split``: the files'
stems) the memory rows carry. stems) the memory rows carry.
@@ -86,7 +87,7 @@ CASE_PATH = "/workspace/case"
INPUTS_PATH = "/workspace/inputs" INPUTS_PATH = "/workspace/inputs"
FIXED_PATH = "/workspace/fixed" FIXED_PATH = "/workspace/fixed"
PROJECT_PATH = "/workspace/project" PROJECT_PATH = "/workspace/project"
TEMPLATES = ("repo-facts", "verify", "agree", "remember", "alerts", "loop") TEMPLATES = ("repo-facts", "verify", "agree", "remember", "alerts", "loop", "features")
FIXED_RANGES_BASIS = "git diff -U0 <vulnerable> <fixed>, new side: the fix's lines at the fixed pin" FIXED_RANGES_BASIS = "git diff -U0 <vulnerable> <fixed>, new side: the fix's lines at the fixed pin"
LOOP_MANIFEST = "benchmarks/manifests/java-spring-boot-vulnerable.toml" # the equality baseline's `improve` manifest LOOP_MANIFEST = "benchmarks/manifests/java-spring-boot-vulnerable.toml" # the equality baseline's `improve` manifest
LOOP_CATALOG = "benchmarks/pairs/catalog.toml" LOOP_CATALOG = "benchmarks/pairs/catalog.toml"
@@ -302,12 +303,9 @@ def _free() -> dict[str, int]:
return {"usd": 0, "calls": 0} # model-free: budget.py refuses any call, so a stray one fails loudly return {"usd": 0, "calls": 0} # model-free: budget.py refuses any call, so a stray one fails loudly
def _remember(item: CaseInputs, templates: Mapping[str, Task], population: str, split: str, alerts: bool) -> tuple[dict, dict]: def _chain_inputs(case_id: str, alerts: bool) -> dict[str, str]:
"""(Task, Run entry) of the case's `remember` (design section 4): bound to the vulnerable Workspace for the """Every artifact of a case's chain -- facts, the three passes with their summaries, the final agree -- and, in a
repository and pin its rows carry; every artifact of the case's chain (and, in a loop, its alerts) as input.""" loop, its alerts with their coverage."""
case_id = item.case["id"]
env = {"OUSAST_POPULATION": population or "unknown", "OUSAST_SPLIT": split or "unknown"}
task = _task(templates["remember"], f"remember-{case_id}", env, [(_ax_name(case_id, "vulnerable"), CASE_PATH)])
inputs = {"facts": f"{case_id}-facts/facts.json", "facts_summary": f"{case_id}-facts/summary.json"} inputs = {"facts": f"{case_id}-facts/facts.json", "facts_summary": f"{case_id}-facts/summary.json"}
for label in ("a", "b", "c"): for label in ("a", "b", "c"):
inputs.update({f"pass_{label}": f"{case_id}-v{label}/units.jsonl", f"summary_{label}": f"{case_id}-v{label}/summary.json"}) inputs.update({f"pass_{label}": f"{case_id}-v{label}/units.jsonl", f"summary_{label}": f"{case_id}-v{label}/summary.json"})
@@ -315,6 +313,28 @@ def _remember(item: CaseInputs, templates: Mapping[str, Task], population: str,
if alerts: if alerts:
inputs["alerts"] = f"{case_id}-alerts/alerts.jsonl" inputs["alerts"] = f"{case_id}-alerts/alerts.jsonl"
inputs["alerts_summary"] = f"{case_id}-alerts/summary.json" # the coverage: whether a zero could mean clean inputs["alerts_summary"] = f"{case_id}-alerts/summary.json" # the coverage: whether a zero could mean clean
return inputs
def _features(item: CaseInputs, templates: Mapping[str, Task], alerts: bool) -> tuple[dict, dict]:
"""(Task, Run entry) of the case's `features` (learned-decision-engine design section 1): model-free, bound to the
vulnerable Workspace for the functions' spans and entry-point distance; the chain's artifacts as input."""
case_id = item.case["id"]
env = {"OUSAST_WORKSPACE_DIR": f"{CASE_PATH}/repo"}
task = _task(templates["features"], f"features-{case_id}", env, [(_ax_name(case_id, "vulnerable"), CASE_PATH)])
inputs = _chain_inputs(case_id, alerts)
entry = {"name": f"{case_id}-features", "task": f"features-{case_id}", "inputs": inputs, "outputs": ["features.jsonl", "summary.json"]}
return task, {**entry, "budget": _free()}
def _remember(item: CaseInputs, templates: Mapping[str, Task], population: str, split: str, alerts: bool) -> tuple[dict, dict]:
"""(Task, Run entry) of the case's `remember` (design section 4): bound to the vulnerable Workspace for the
repository and pin its rows carry; every artifact of the case's chain (and, in a loop, its alerts) and the case's
feature records as input."""
case_id = item.case["id"]
env = {"OUSAST_POPULATION": population or "unknown", "OUSAST_SPLIT": split or "unknown"}
task = _task(templates["remember"], f"remember-{case_id}", env, [(_ax_name(case_id, "vulnerable"), CASE_PATH)])
inputs = {**_chain_inputs(case_id, alerts), "features": f"{case_id}-features/features.jsonl"}
entry = {"name": f"{case_id}-remember", "task": f"remember-{case_id}", "inputs": inputs, "outputs": ["memory.jsonl"], "budget": _free()} entry = {"name": f"{case_id}-remember", "task": f"remember-{case_id}", "inputs": inputs, "outputs": ["memory.jsonl"], "budget": _free()}
return task, entry return task, entry
@@ -382,7 +402,7 @@ def render(
) -> dict[str, str]: ) -> dict[str, str]:
"""Relative path under the plane root -> file text, for every generated manifest. ``population`` and ``split`` """Relative path under the plane root -> file text, for every generated manifest. ``population`` and ``split``
(the population file's and the set's stems) become the Run's annotations, which the memory rows carry. Each (the population file's and the set's stems) become the Run's annotations, which the memory rows carry. Each
case ends in `remember`; ``loop`` adds per case `alerts` (and the fixed-pin Workspace), and once the case ends in `features` -> `remember`; ``loop`` adds per case `alerts` (and the fixed-pin Workspace), and once the
snapshot, `loop-measure`, `loop-propose` and `loop-improve` (design section 6), all model-free.""" snapshot, `loop-measure`, `loop-propose` and `loop-improve` (design section 6), all model-free."""
header = f"# generated by `{command}`; do not edit, regenerate.\n" header = f"# generated by `{command}`; do not edit, regenerate.\n"
out: dict[str, str] = {} out: dict[str, str] = {}
@@ -435,6 +455,9 @@ def render(
out[f"workspaces/{fixed['metadata']['name']}.yaml"] = _dump(header, [fixed]) out[f"workspaces/{fixed['metadata']['name']}.yaml"] = _dump(header, [fixed])
tasks.append(alerts_task) tasks.append(alerts_task)
entries.append(alerts_entry) entries.append(alerts_entry)
features_task, features_entry = _features(item, templates, loop is not None)
tasks.append(features_task)
entries.append(features_entry)
remember_task, remember_entry = _remember(item, templates, population, split, loop is not None) remember_task, remember_entry = _remember(item, templates, population, split, loop is not None)
tasks.append(remember_task) tasks.append(remember_task)
entries.append(remember_entry) entries.append(remember_entry)
+9 -3
View File
@@ -1,8 +1,9 @@
"""The plane's memory store (harnessx-removal Requirement 6.1/6.4, design section 4). """The plane's memory store (harnessx-removal Requirement 6.1/6.4, design section 4).
One store, keyed by repository and pin, holds what plane runs learned: repository facts by content hash, and rows One store, keyed by repository and pin, holds what plane runs learned: repository facts by content hash, and rows
of six kinds (``facts``, ``verdict``, ``unit_cost``, ``alert``, ``coverage``, ``proposal_outcome``) that ``remember`` of the kinds in ``KINDS`` (``facts``, ``verdict``, ``unit_cost``, ``alert``, ``coverage``, ``features``, ...) that
(:mod:`.tasks.remember`) derives from a run's delivered artifacts. Layout, identical for both backends:: ``remember`` (:mod:`.tasks.remember`) derives from a run's delivered artifacts, plus the loop's and the decision
engine's own. Layout, identical for both backends::
index.jsonl one row per ingested (run, task): sha256 of its rows, row count index.jsonl one row per ingested (run, task): sha256 of its rows, row count
facts/<sha256>.json facts.json by content (identical facts stored once) facts/<sha256>.json facts.json by content (identical facts stored once)
@@ -49,7 +50,12 @@ MEMORY_KEY_ANNOTATION = "openultrasast.io/memory-key"
POPULATION_ANNOTATION = "openultrasast.io/population" POPULATION_ANNOTATION = "openultrasast.io/population"
SPLIT_ANNOTATION = "openultrasast.io/split" SPLIT_ANNOTATION = "openultrasast.io/split"
SNAPSHOT_ANNOTATION = "openultrasast.io/memory-snapshot" # the loop's guard: {"catalog", "manifest", "populations"} SNAPSHOT_ANNOTATION = "openultrasast.io/memory-snapshot" # the loop's guard: {"catalog", "manifest", "populations"}
KINDS = ("facts", "verdict", "unit_cost", "alert", "coverage", "proposal_outcome") # The decision engine's kinds (learned-decision-engine, Data Models): `features` from the `features` task through
# `remember`; `label`, `decision`, `experiment`, `arm_outcome` and `experiment_result` for the later tasks.
KINDS = (
"facts", "verdict", "unit_cost", "alert", "coverage", "proposal_outcome",
"features", "label", "decision", "experiment", "arm_outcome", "experiment_result",
) # fmt: skip
ROW_FIELDS = ("id", "kind", "repo", "pin", "run", "task", "population", "split", "image") ROW_FIELDS = ("id", "kind", "repo", "pin", "run", "task", "population", "split", "image")
TAG_FIELDS = ("repo", "pin", "kind", "family", "run", "population", "split") TAG_FIELDS = ("repo", "pin", "kind", "family", "run", "population", "split")
MIN_FREE_BYTES = 1 << 30 MIN_FREE_BYTES = 1 << 30
+259
View File
@@ -0,0 +1,259 @@
"""`features` (learned-decision-engine design section 1): one feature record per candidate of a case. No model.
Budget ``{usd: 0, calls: 0}``, one per case, after ``final`` (and ``alerts`` in a loop Run). A candidate is a
``(path, function, family)`` that the verify passes asked about or a vulnerable-pin alert named. It reads:
- ``facts.json`` (repo-facts callers), the verify passes' ``units.jsonl`` a, b, c and their ``summary.json`` (the
model id is the verify instrument's version), the final ``agreed.json`` -- with ``site_match`` dropped on read;
- in a loop, ``alerts.jsonl`` -- vulnerable-pin rows only, ``in_fix_range`` dropped: a deployed scan has no fixed pin
and no fix -- and the alerts' ``summary.json`` (coverage per language, the engine's questions and degradations);
- the bound checkout (``OUSAST_WORKSPACE_DIR``) for the function's span and its entry-point distance.
It writes ``features.jsonl`` (records validated by :func:`..learn.schema.validate_record`, the `plane` profile) and
``summary.json``; ``remember`` turns the records into ``features`` rows. An instrument without coverage for the
candidate's language is ``none``, one that did not read its input ``failed``: never a zero (Req 1.2). The task fails
when every instrument it could have used failed.
Env: ``OUSAST_OUTPUT_DIR``, ``OUSAST_INPUT_FACTS`` (and ``OUSAST_INPUT_FACTS_SUMMARY``), ``OUSAST_INPUT_PASS_A``/``_B``/``_C``,
``OUSAST_INPUT_SUMMARY_A``/``_B``/``_C``, ``OUSAST_INPUT_AGREED``, optional ``OUSAST_INPUT_ALERTS`` and
``OUSAST_INPUT_ALERTS_SUMMARY``, optional ``OUSAST_WORKSPACE_DIR``. Exit 0 done, 2 failed. On the host,
:func:`case_features` does the same over a recorded run directory.
"""
from __future__ import annotations
import json
import os
import sys
import traceback
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any
from ...learn.features import (
NONE,
NOT_APPLICABLE,
Part,
SourceIndex,
Vocabulary,
agree_part,
engine_part,
entry_distance,
entry_names,
facts_part,
quick_part,
record,
ruleset_digest,
source_part,
verify_part,
)
from ...model.taxonomy import load_families
from ...preprocess import detect_language
from ...ruleset import DEFAULT_RULESET_DIR, PatternRule, load_ruleset
from .alerts import covers
from .repo_facts import GLOBAL
OUTPUT = "features.jsonl"
PASSES = ("a", "b", "c")
ENGINE_PREFIX = "engine:"
# Read, never passed on: label information (the declared site, the fix) and the fixed pin.
DROPPED_ON_READ = ("site_match", "in_fix_range")
def _drop(row: Mapping[str, Any]) -> dict[str, Any]:
return {k: v for k, v in row.items() if k not in DROPPED_ON_READ}
def _family(rule: PatternRule | None) -> str:
family = load_families().family_of_cwe(rule.cwe) if rule is not None else None
return family.id if family is not None else "unknown"
def _line(site: object) -> int | None:
parts = str(site or "").split(":")
return int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None
def _final(row: Mapping[str, Any], disputed: set[str]) -> str:
if row.get("agreed"):
return "agreed"
return "disputed" if str(row["candidate"]) in disputed else "rejected"
def _quick(
summary: Mapping[str, Any] | None, language: str, hits: list[tuple[str, str]], rules: Mapping[str, PatternRule], version: str
) -> Part:
if summary is None:
return NONE
if summary.get("status") not in (None, "done"):
return Part("failed")
if not covers(summary.get("quick_languages") or (), language):
return NONE
return quick_part(hits, rules, version)
def _engine(summary: Mapping[str, Any] | None, language: str, rows: list[dict[str, Any]], vocabulary: Vocabulary) -> Part:
if summary is None or language not in (summary.get("engine_languages") or ()):
return NONE
run = (summary.get("engine") or {}).get("vulnerable")
if not isinstance(run, Mapping):
return NONE
questions, completed = int(run.get("questions") or 0), int(run.get("completed") or 0)
if questions <= 0 or int(run.get("files") or 0) <= 0:
return Part("failed", version=summary.get("image"))
return engine_part(
rows, completion=completed / questions, degraded=bool(run.get("degradations")), vocabulary=vocabulary, version=summary.get("image")
)
def case_features(
*,
facts: Mapping[str, Any] | None,
passes: Mapping[str, Sequence[Mapping[str, Any]]],
models: Mapping[str, str | None],
agreed: Mapping[str, Any] | None,
alerts: Sequence[Mapping[str, Any]] = (),
alerts_summary: Mapping[str, Any] | None = None,
workspace: Path | None = None,
rules: Sequence[PatternRule] | None = None,
facts_summary: Mapping[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""The `plane` records of one case, sorted by (candidate, family). Versions: the ruleset digest (quick), the
engine image (engine), the image the facts were computed on (facts), the verify model ids (verify)."""
loaded = tuple(rules) if rules is not None else load_ruleset(DEFAULT_RULESET_DIR)
by_id = {rule.rule_id: rule for rule in loaded}
candidates: dict[tuple[str, str], dict[str, Any]] = {}
verdicts = [_drop(c) for c in (agreed or {}).get("candidates") or []]
disputed = {str(d["candidate"]) for d in (agreed or {}).get("disputed") or []}
for row in verdicts:
family = str((row.get("finding") or {}).get("family") or (agreed or {}).get("family") or "unknown")
entry = candidates.setdefault((str(row["candidate"]), family), {"line": _line(row.get("site")), "quick": [], "engine": []})
entry["final"] = _final(row, disputed)
for alert in (_drop(a) for a in alerts):
if alert.get("pin_role") != "vulnerable":
continue # a deployed scan has no fixed pin
rule_id = str(alert.get("rule_id") or "")
engine = rule_id.startswith(ENGINE_PREFIX)
family = rule_id.removeprefix(ENGINE_PREFIX) if engine else _family(by_id.get(rule_id))
key = (f"{alert.get('path')}::{alert.get('function') or GLOBAL}", family)
entry = candidates.setdefault(key, {"line": alert.get("line"), "quick": [], "engine": []})
if engine:
entry["engine"].append({"site": f"{alert.get('path')}:{alert.get('line')}"})
else:
entry["quick"].append((rule_id, str(alert.get("rule_status") or "enabled")))
callers = (facts or {}).get("callers") or {}
index = SourceIndex(workspace) if workspace is not None and workspace.is_dir() else None
entries: tuple[set[str], set[str]] | None = None
quick_version = ruleset_digest(loaded)
vocabularies: dict[str, Vocabulary] = {}
reused = (facts_summary or {}).get("reused")
facts_image = (reused.get("image") if isinstance(reused, Mapping) else None) or (facts_summary or {}).get("image")
facts_version = str(facts_image) if facts_image else None
verify_version = "+".join(sorted({m for m in models.values() if m})) or None
out: list[dict[str, Any]] = []
for (candidate, family), entry in sorted(candidates.items()):
path, _, function = candidate.partition("::")
language = detect_language(Path(path))
vocabulary = vocabularies.setdefault(language, Vocabulary.load(language))
parts: dict[str, Part] = {
"quick": _quick(alerts_summary, language, entry["quick"], by_id, quick_version),
"engine": _engine(alerts_summary, language, entry["engine"], vocabulary),
"facts": facts_part(callers[candidate], function, facts_version) if candidate in callers else NONE,
"delta": NOT_APPLICABLE,
"verify": verify_part(passes, candidate, entry["line"], verify_version),
"agree": agree_part(entry.get("final")),
}
if index is not None:
if entries is None:
entries = entry_names(index.root, {c.partition("::")[0] for c, _ in candidates})
parts["source"] = source_part(index.lines(path), language, function)
distance = 0 if function == GLOBAL and path in entries[1] else entry_distance(function, entries[0], index.callers())
parts["entry_points"] = Part("ran", {"facts.entry_distance": distance})
out.append(record(candidate, family, language, "plane", parts))
return out
def _jsonl(path: Path | None) -> list[dict[str, Any]]:
if path is None or not path.is_file():
return []
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def _json(path: Path | None) -> Any:
return json.loads(path.read_text(encoding="utf-8")) if path is not None and path.is_file() else None
def _model(summary: object) -> str | None:
return str(summary["model"]) if isinstance(summary, dict) and summary.get("model") else None
def recorded_case(base: Path, case: str, workspace: Path | None = None) -> list[dict[str, Any]]:
""":func:`case_features` over a recorded run directory's artifacts (the final ``agreed.json`` when present)."""
final = base / f"{case}-final" / "agreed.json"
return case_features(
facts=_json(base / f"{case}-facts" / "facts.json"),
passes={p: _jsonl(base / f"{case}-v{p}" / "units.jsonl") for p in PASSES},
models={p: _model(_json(base / f"{case}-v{p}" / "summary.json")) for p in PASSES},
agreed=_json(final if final.is_file() else base / f"{case}-agree" / "agreed.json"),
alerts=_jsonl(base / f"{case}-alerts" / "alerts.jsonl"),
alerts_summary=_json(base / f"{case}-alerts" / "summary.json"),
workspace=workspace,
facts_summary=_json(base / f"{case}-facts" / "summary.json"),
)
def _path(env: Mapping[str, str], name: str) -> Path | None:
return Path(env[name]) if env.get(name) else None
def _units(path: Path | None) -> list[dict[str, Any]]:
return _jsonl(path / "units.jsonl" if path is not None and path.is_dir() else path)
def _states(records: Sequence[Mapping[str, Any]]) -> dict[str, dict[str, int]]:
counts: dict[str, dict[str, int]] = {}
for rec in records:
for name, info in rec["instruments"].items():
counts.setdefault(name, {}).setdefault(info["state"], 0)
counts[name][info["state"]] += 1
return dict(sorted(counts.items()))
def main(environ: Mapping[str, str] | None = None) -> int:
env = environ if environ is not None else os.environ
output_dir = _path(env, "OUSAST_OUTPUT_DIR")
summary: dict[str, Any] = {"status": "failed", "units_done": 0, "units_total": 1, "usd": 0, "calls": 0, "usage": {}, "model": None}
try:
if output_dir is None:
raise ValueError("OUSAST_OUTPUT_DIR is not set")
output_dir.mkdir(parents=True, exist_ok=True)
workspace = _path(env, "OUSAST_WORKSPACE_DIR")
records = case_features(
facts=_json(_path(env, "OUSAST_INPUT_FACTS")),
passes={p: _units(_path(env, f"OUSAST_INPUT_PASS_{p.upper()}")) for p in PASSES},
models={p: _model(_json(_path(env, f"OUSAST_INPUT_SUMMARY_{p.upper()}"))) for p in PASSES},
agreed=_json(_path(env, "OUSAST_INPUT_AGREED")),
alerts=_jsonl(_path(env, "OUSAST_INPUT_ALERTS")),
alerts_summary=_json(_path(env, "OUSAST_INPUT_ALERTS_SUMMARY")),
workspace=workspace,
facts_summary=_json(_path(env, "OUSAST_INPUT_FACTS_SUMMARY")),
)
states = _states(records)
used = {name: n for name, n in states.items() if name not in ("language", "delta") and set(n) - {"none"}}
if records and used and all(set(n) == {"failed"} for n in used.values()):
raise ValueError(f"every instrument failed for this case: {used}")
(output_dir / OUTPUT).write_text("".join(json.dumps(r, sort_keys=True) + "\n" for r in records), encoding="utf-8")
summary.update(status="done", units_done=1, records=len(records), instruments=states)
except Exception: # noqa: BLE001 -- a crash is `failed` with its traceback
summary["reason"] = traceback.format_exc()[-2000:]
if output_dir is not None:
(output_dir / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(json.dumps({k: summary.get(k) for k in ("status", "records")}), file=sys.stderr)
return 0 if summary["status"] == "done" else 2
__all__ = ["DROPPED_ON_READ", "case_features", "main", "recorded_case"]
if __name__ == "__main__":
raise SystemExit(main())
+19 -6
View File
@@ -2,8 +2,9 @@
No model, budget ``{usd: 0, calls: 0}``, one per case. It reads the case's `facts.json`, the `units.jsonl` of the No model, budget ``{usd: 0, calls: 0}``, one per case. It reads the case's `facts.json`, the `units.jsonl` of the
verify passes a, b and c, the final `agreed.json` (its per-candidate rows and `disputed` list) and, when present, verify passes a, b and c, the final `agreed.json` (its per-candidate rows and `disputed` list) and, when present,
`alerts.jsonl` with the alerts' `summary.json`, and writes `memory.jsonl` -- rows of the kinds `facts`, `verdict`, `alerts.jsonl` with the alerts' `summary.json`, and the `features` task's `features.jsonl`, and writes `memory.jsonl`
`unit_cost`, `alert` and `coverage` (per language and pin: whether any rule could have fired there; the kind -- rows of the kinds `facts`, `verdict`, `unit_cost`, `alert`, `coverage` (per language and pin: whether any rule
could have fired there) and `features` (one per candidate and family, validated against the allow-list; the kind
`proposal_outcome` comes from the loop) -- and passes `facts.json` through. Task code interprets the `proposal_outcome` comes from the loop) -- and passes `facts.json` through. Task code interprets the
artifacts, so the reconciler and the runner stay content-blind (ai-service-plane Req 3.4). artifacts, so the reconciler and the runner stay content-blind (ai-service-plane Req 3.4).
@@ -14,7 +15,7 @@ Env contract in a Run: `OUSAST_OUTPUT_DIR`, `OUSAST_RUN`, `OUSAST_TASK`, `OUSAST
whose reused or recorded `image` the facts row carries; without one named, the row is omitted), whose reused or recorded `image` the facts row carries; without one named, the row is omitted),
`OUSAST_INPUT_PASS_A`/`_B`, optional `OUSAST_INPUT_PASS_C`, `OUSAST_INPUT_SUMMARY_A`/`_B`/`_C` (the passes' `OUSAST_INPUT_PASS_A`/`_B`, optional `OUSAST_INPUT_PASS_C`, `OUSAST_INPUT_SUMMARY_A`/`_B`/`_C` (the passes'
`summary.json`, for the model), `OUSAST_INPUT_AGREED`, `OUSAST_INPUT_ALERTS` (and `OUSAST_INPUT_ALERTS_SUMMARY`, `summary.json`, for the model), `OUSAST_INPUT_AGREED`, `OUSAST_INPUT_ALERTS` (and `OUSAST_INPUT_ALERTS_SUMMARY`,
the coverage); the repository and pin from the bound the coverage), `OUSAST_INPUT_FEATURES`; the repository and pin from the bound
Workspace (`AX_WORKSPACES_YAML`, `OUSAST_GIT_PINS`), the image from `AX_TASK_YAML`, the population and split from Workspace (`AX_WORKSPACES_YAML`, `OUSAST_GIT_PINS`), the image from `AX_TASK_YAML`, the population and split from
`OUSAST_POPULATION`/`OUSAST_SPLIT` (the generator copies the Run's `openultrasast.io/population` and `OUSAST_POPULATION`/`OUSAST_SPLIT` (the generator copies the Run's `openultrasast.io/population` and
`openultrasast.io/split` annotations). Exit 0 done, 2 failed. `openultrasast.io/split` annotations). Exit 0 done, 2 failed.
@@ -38,6 +39,7 @@ from typing import Any
import yaml import yaml
from ...learn.schema import validate_record
from ..memory import REMEMBER_OUTPUT, IngestResult, MemoryStore, image_digest, ingest, repo_key, row_id, validate_row from ..memory import REMEMBER_OUTPUT, IngestResult, MemoryStore, image_digest, ingest, repo_key, row_id, validate_row
from .repo_facts import _digest from .repo_facts import _digest
@@ -105,8 +107,10 @@ def rows_for(
agreed: Mapping[str, Any] | None, agreed: Mapping[str, Any] | None,
alerts: Sequence[Mapping[str, Any]] = (), alerts: Sequence[Mapping[str, Any]] = (),
alerts_summary: Mapping[str, Any] | None = None, alerts_summary: Mapping[str, Any] | None = None,
features: Sequence[Mapping[str, Any]] = (),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""The memory rows of one case: facts, verdicts, unit costs, alerts, and the alerts' coverage per language and pin.""" """The memory rows of one case: facts, verdicts, unit costs, alerts, the alerts' coverage per language and pin, and
the feature records (re-validated: a delivered record outside the allow-list fails the case)."""
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
if facts is not None: if facts is not None:
loaded = json.loads(facts) loaded = json.loads(facts)
@@ -143,6 +147,9 @@ def rows_for(
subject = f"{fields['rule_id']}:{fields['path']}:{fields['line']}" + (":fixed" if fields.get("pin_role") == "fixed" else "") subject = f"{fields['rule_id']}:{fields['path']}:{fields['line']}" + (":fixed" if fields.get("pin_role") == "fixed" else "")
rows.append(ctx.row("alert", subject, **fields)) rows.append(ctx.row("alert", subject, **fields))
rows.extend(coverage_rows(ctx, alerts_summary)) rows.extend(coverage_rows(ctx, alerts_summary))
for rec in features:
checked = validate_record(rec)
rows.append(ctx.row("features", f"{checked['candidate']}:{checked['family']}", **checked))
return [validate_row(r, f"{ctx.task} {r['kind']}") for r in rows] return [validate_row(r, f"{ctx.task} {r['kind']}") for r in rows]
@@ -212,7 +219,10 @@ def case_rows(base: Path, case: str, *, population: str, split: str) -> tuple[li
models = {p: _model(_json(base / f"{case}-v{p}" / "summary.json")) for p in PASSES} models = {p: _model(_json(base / f"{case}-v{p}" / "summary.json")) for p in PASSES}
alerts = _jsonl(base / f"{case}-alerts" / "alerts.jsonl") alerts = _jsonl(base / f"{case}-alerts" / "alerts.jsonl")
alerts_summary = _json(base / f"{case}-alerts" / "summary.json") alerts_summary = _json(base / f"{case}-alerts" / "summary.json")
rows = rows_for(ctx, facts=facts, passes=passes, models=models, agreed=agreed, alerts=alerts, alerts_summary=alerts_summary) features = _jsonl(base / f"{case}-features" / "features.jsonl")
rows = rows_for(
ctx, facts=facts, passes=passes, models=models, agreed=agreed, alerts=alerts, alerts_summary=alerts_summary, features=features
)
return rows, facts return rows, facts
@@ -275,7 +285,10 @@ def main(environ: Mapping[str, str] | None = None) -> int:
models = {p: _model(_json(_path(env, f"OUSAST_INPUT_SUMMARY_{p.upper()}"))) for p in PASSES} models = {p: _model(_json(_path(env, f"OUSAST_INPUT_SUMMARY_{p.upper()}"))) for p in PASSES}
agreed = _json(_path(env, "OUSAST_INPUT_AGREED")) agreed = _json(_path(env, "OUSAST_INPUT_AGREED"))
alerts, alerts_summary = _jsonl(_path(env, "OUSAST_INPUT_ALERTS")), _json(_path(env, "OUSAST_INPUT_ALERTS_SUMMARY")) alerts, alerts_summary = _jsonl(_path(env, "OUSAST_INPUT_ALERTS")), _json(_path(env, "OUSAST_INPUT_ALERTS_SUMMARY"))
rows = rows_for(ctx, facts=None, passes=passes, models=models, agreed=agreed, alerts=alerts, alerts_summary=alerts_summary) features = _jsonl(_path(env, "OUSAST_INPUT_FEATURES"))
rows = rows_for(
ctx, facts=None, passes=passes, models=models, agreed=agreed, alerts=alerts, alerts_summary=alerts_summary, features=features
)
facts_image = _facts_image(env, ctx.image) facts_image = _facts_image(env, ctx.image)
if facts is not None and facts_image is not None: # the facts row carries the image the facts were computed on if facts is not None and facts_image is not None: # the facts row carries the image the facts were computed on
facts_ctx = Context(ctx.run, ctx.task, ctx.repo, ctx.pin, facts_image, ctx.population, ctx.split) facts_ctx = Context(ctx.run, ctx.task, ctx.repo, ctx.pin, facts_image, ctx.population, ctx.split)
+126
View File
@@ -0,0 +1,126 @@
"""Framework and library priors (learned-decision-engine Req 8.2, design section 2).
``frameworks.toml`` lists the framework and library ids a ruleset entry may be tagged with, each with a lexicon of
symbols. A tagged entry (``framework = "<id>"`` or ``library = "<id>"`` on a ``semantic/*.toml`` source, sink,
sanitizer or dispatch, or on a quick ``[[rule]]``) is a *prior*: the loaders keep it or drop it by ``priors``:
- ``"all"``: every entry (today's scan: the default of every loader, so output is unchanged);
- ``"off"``: language-level entries only (the decision engine's default);
- a set of ids: language-level entries plus those priors.
The lexicon is for the lint test and repository stratification, never for detection.
"""
from __future__ import annotations
import re
import tomllib
from collections.abc import Iterable
from dataclasses import dataclass
from functools import cache
from pathlib import Path
from typing import Literal
DEFAULT_FRAMEWORKS_PATH = Path(__file__).resolve().parent / "frameworks.toml"
KINDS = ("framework", "library")
Priors = Literal["off", "all"] | frozenset[str]
class FrameworksError(ValueError):
"""``frameworks.toml`` or a tag is malformed, or names an id the file does not list."""
@dataclass(frozen=True)
class Framework:
id: str
kind: str # KINDS
languages: tuple[str, ...]
packages: tuple[str, ...]
symbols: tuple[str, ...]
markers: tuple[str, ...] = ()
def _strings(value: object, field: str, where: str) -> tuple[str, ...]:
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
raise FrameworksError(f"{where}: {field} must be a list of non-empty strings")
return tuple(value)
@cache
def load_frameworks(path: Path = DEFAULT_FRAMEWORKS_PATH) -> tuple[Framework, ...]:
data = tomllib.loads(Path(path).read_text(encoding="utf-8"))
rows: list[Framework] = []
for item in data.get("framework", []):
where = f"{path} framework {item.get('id')!r}"
if not isinstance(item.get("id"), str) or item.get("kind") not in KINDS:
raise FrameworksError(f"{where}: needs an id and a kind in {KINDS}")
extra = set(item) - {"id", "kind", "languages", "packages", "symbols", "markers"}
if extra:
raise FrameworksError(f"{where}: unknown fields {sorted(extra)}")
rows.append(
Framework(
id=item["id"], kind=item["kind"], languages=_strings(item.get("languages"), "languages", where),
packages=_strings(item.get("packages", []), "packages", where), symbols=_strings(item.get("symbols"), "symbols", where),
markers=_strings(item.get("markers", []), "markers", where),
)
) # fmt: skip
ids = [row.id for row in rows]
if len(ids) != len(set(ids)):
raise FrameworksError(f"{path}: an id is listed twice")
return tuple(rows)
def framework_ids() -> frozenset[str]:
return frozenset(row.id for row in load_frameworks())
def normalize_priors(priors: Priors | Iterable[str]) -> Priors:
"""``"all"``, ``"off"`` or a frozenset of known ids; an unknown id is refused, never silently ignored."""
if priors in ("all", "off"):
return priors # type: ignore[return-value]
if isinstance(priors, str):
raise FrameworksError(f"priors must be 'all', 'off' or a set of framework ids, got {priors!r}")
chosen = frozenset(priors)
unknown = sorted(chosen - framework_ids())
if unknown:
raise FrameworksError(f"unknown priors {unknown} (frameworks.toml lists {sorted(framework_ids())})")
return chosen
def tag_of(item: object) -> str | None:
"""The framework or library id an entry is tagged with, or None for a language-level entry."""
return getattr(item, "framework", None) or getattr(item, "library", None)
def kept(tag: str | None, priors: Priors) -> bool:
"""Whether an entry with ``tag`` survives ``priors``: language-level entries always do."""
if tag is None or priors == "all":
return True
if priors == "off":
return False
return tag in priors
def read_tag(item: dict[str, object], where: str) -> tuple[str | None, str | None]:
"""(framework, library) of a TOML entry, checked: at most one, and a listed id of the matching kind."""
framework, library = item.get("framework"), item.get("library")
if framework is not None and library is not None:
raise FrameworksError(f"{where}: tagged both framework and library; one tag covers a whole entry")
for value, kind in ((framework, "framework"), (library, "library")):
if value is None:
continue
rows = {row.id: row for row in load_frameworks()}
if not isinstance(value, str) or value not in rows or rows[value].kind != kind:
raise FrameworksError(f"{where}: {kind} = {value!r} is not a {kind} id of frameworks.toml")
return (framework if isinstance(framework, str) else None), (library if isinstance(library, str) else None)
def symbol_in(symbol: str, text: str) -> bool:
"""The lexicon's match rule: ``symbol`` as a whole token of ``text``."""
return re.search(r"(?<![A-Za-z0-9_])" + re.escape(symbol) + r"(?![A-Za-z0-9_])", text) is not None
__all__ = [
"DEFAULT_FRAMEWORKS_PATH", "Framework", "FrameworksError", "Priors", "framework_ids", "kept", "load_frameworks",
"normalize_priors", "read_tag", "symbol_in", "tag_of",
] # fmt: skip
+160
View File
@@ -0,0 +1,160 @@
# Framework and library ids for the ruleset's optional priors (learned-decision-engine Req 8, design section 2).
#
# Hand-written knowledge stays at the language level: superglobals, built-ins, process execution, raw database
# drivers, deserialisers, the standard library. An entry of `semantic/*.toml` or a quick `[[rule]]` that knows a
# framework or a library carries `framework = "<id>"` or `library = "<id>"` naming one row here, and loaders take
# `priors` ("all" -- today's scan --, "off", or a set of ids) to keep or drop the tagged entries.
#
# `symbols` is a lexicon with two uses only: the lint test (`tests/test_semantic_priors.py`) fails on an untagged
# entry that contains one, and repository stratification (with `packages`, the dependency names a manifest such as
# composer.json, requirements*.txt, pyproject.toml, package.json or pom.xml declares). It never feeds detection.
# A symbol matches as a whole token: not preceded or followed by a letter, digit or underscore.
version = "1"
[[framework]]
id = "wordpress"
kind = "framework"
languages = ["php"]
packages = ["johnpbloch/wordpress", "roots/wordpress", "wordpress/wordpress"]
# A plugin or theme checkout declares itself in a file header instead of a dependency manifest.
markers = ["Plugin Name:", "Theme Name:"]
symbols = [
"$wpdb", "wpdb->", "maybe_unserialize", "esc_sql", "absint", "esc_html", "esc_attr", "esc_url", "esc_js",
"sanitize_text_field", "wp_kses", "add_action", "add_filter", "add_shortcode", "apply_filters",
"apply_filters_ref_array", "do_action", "do_action_ref_array",
]
[[framework]]
id = "flask"
kind = "framework"
languages = ["python"]
packages = ["flask"]
symbols = [
"request.args", "request.form", "request.json", "request.values", "request.get_json", "request.files",
"send_file", "send_from_directory", "render_template_string", "Markup", "url_for",
]
[[framework]]
id = "django"
kind = "framework"
languages = ["python"]
packages = ["django", "djangorestframework"]
symbols = [
"request.GET", "request.POST", "request.COOKIES", "request.FILES", "request.META", "request.query_params",
"cleaned_data", "objects.raw", "RawSQL", "mark_safe", "format_html", "HttpResponse", "HttpResponseRedirect",
"FileResponse", "url_has_allowed_host_and_scheme", "is_safe_url", "reverse_lazy",
]
[[framework]]
id = "express"
kind = "framework"
languages = ["javascript", "typescript"]
packages = ["express"]
symbols = ["req.query", "req.body", "req.params", "req.cookies", "req.headers", "res.send", "response.send", "sendFile", "res.redirect"]
[[framework]]
id = "spring"
kind = "framework"
languages = ["java"]
packages = ["org.springframework:spring-web", "org.springframework.boot:spring-boot-starter-web"]
symbols = ["getForObject", "postForObject", "RestTemplate", "WebClient"]
[[framework]]
id = "jakarta-servlet"
kind = "framework"
languages = ["java"]
packages = ["jakarta.servlet:jakarta.servlet-api", "javax.servlet:javax.servlet-api"]
symbols = ["getParameter", "getQueryString", "getWriter", "sendRedirect", "HttpServletRequest", "HttpServletResponse"]
[[framework]]
id = "react"
kind = "framework"
languages = ["javascript", "typescript"]
packages = ["react", "react-dom"]
symbols = ["dangerouslySetInnerHTML"]
[[framework]]
id = "vue"
kind = "framework"
languages = ["javascript", "typescript"]
packages = ["vue"]
symbols = ["v-html"]
[[framework]]
id = "sqlalchemy"
kind = "library"
languages = ["python"]
packages = ["sqlalchemy"]
symbols = ["from_statement"]
[[framework]]
id = "pyyaml"
kind = "library"
languages = ["python"]
packages = ["pyyaml"]
symbols = ["yaml.load", "yaml.safe_load"]
[[framework]]
id = "requests"
kind = "library"
languages = ["python"]
packages = ["requests"]
symbols = ["requests.get", "requests.post", "requests.request"]
[[framework]]
id = "httpx"
kind = "library"
languages = ["python"]
packages = ["httpx"]
symbols = ["httpx.get", "httpx.post"]
[[framework]]
id = "lodash"
kind = "library"
languages = ["javascript", "typescript"]
packages = ["lodash", "lodash.merge", "lodash.set", "lodash.defaultsdeep"]
symbols = ["mergeDeep", "defaultsDeep", "setWith"]
# The Node HTTP client packages, one group: each is a thin wrapper over `http.request`, and they are known as a set.
[[framework]]
id = "node-http-clients"
kind = "library"
languages = ["javascript", "typescript"]
packages = ["axios", "got", "needle", "request", "superagent", "unirest", "undici"]
symbols = ["axios", "needle", "superagent", "unirest", "undici"]
[[framework]]
id = "escape-html"
kind = "library"
languages = ["javascript", "typescript"]
packages = ["escape-html"]
symbols = ["escapeHtml"]
[[framework]]
id = "sanitize-html"
kind = "library"
languages = ["javascript", "typescript"]
packages = ["sanitize-html"]
symbols = ["sanitizeHtml"]
[[framework]]
id = "dompurify"
kind = "library"
languages = ["javascript", "typescript"]
packages = ["dompurify", "isomorphic-dompurify"]
symbols = ["DOMPurify"]
[[framework]]
id = "esapi"
kind = "library"
languages = ["java"]
packages = ["org.owasp.esapi:esapi"]
symbols = ["ESAPI", "encodeForHTML", "encodeForURL"]
[[framework]]
id = "commons-text"
kind = "library"
languages = ["java"]
packages = ["org.apache.commons:commons-text", "org.apache.commons:commons-lang3"]
symbols = ["StringEscapeUtils"]
@@ -104,8 +104,23 @@ status = "enabled"
min_evidence_level = "static_corroboration" min_evidence_level = "static_corroboration"
precision_estimate = 0.0 precision_estimate = 0.0
version = "1" version = "1"
library = "node-http-clients"
pattern = '''\b(axios|fetch|got)\s*[.(][^\n]*\b(?:req|request)\.''' pattern = '''\b(axios|fetch|got)\s*[.(][^\n]*\b(?:req|request)\.'''
# Active only when the rule above is dropped (priors off); status and precision are the tagged rule's measurement.
[[rule]]
rule_id = "js-ssrf-language"
title = "JavaScript outbound request with external input needs review (language-level)"
languages = ["javascript", "typescript"]
cwe = "CWE-918"
tags = ["network_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
fallback_for = "js-ssrf"
pattern = '''\b(fetch)\s*[.(][^\n]*\b(?:req|request)\.'''
[[rule]] [[rule]]
rule_id = "js-weak-hash" rule_id = "js-weak-hash"
title = "JavaScript weak hash needs review" title = "JavaScript weak hash needs review"
+76 -1
View File
@@ -26,6 +26,7 @@ status = "enabled"
min_evidence_level = "static_corroboration" min_evidence_level = "static_corroboration"
precision_estimate = 0.032 precision_estimate = 0.032
version = "1" version = "1"
framework = "wordpress"
pattern = '''\$wpdb->(get_var|get_results|get_row|get_col|query|replace)\s*\(\s*(?!\$wpdb->prepare)(?:"[^"\n]*\{?\$(?!wpdb->)[A-Za-z_]|[^\n;]*?["']\s*\.\s*\$(?!wpdb->)[A-Za-z_]|[^\n;]*?\$_(?:GET|POST|REQUEST|COOKIE)\b)''' pattern = '''\$wpdb->(get_var|get_results|get_row|get_col|query|replace)\s*\(\s*(?!\$wpdb->prepare)(?:"[^"\n]*\{?\$(?!wpdb->)[A-Za-z_]|[^\n;]*?["']\s*\.\s*\$(?!wpdb->)[A-Za-z_]|[^\n;]*?\$_(?:GET|POST|REQUEST|COOKIE)\b)'''
[[rule]] [[rule]]
@@ -54,8 +55,23 @@ status = "enabled"
min_evidence_level = "static_corroboration" min_evidence_level = "static_corroboration"
precision_estimate = 0.034 precision_estimate = 0.034
version = "1" version = "1"
framework = "wordpress"
pattern = '''(?<![$\w>])(?=(VALUES|IN|ORDER|GROUP|LIMIT|OFFSET|WHERE|AND|OR|SET|HAVING)\b)(?:(?:VALUES|IN)\s*\(|(?:ORDER|GROUP)\s+BY\b[^"'\n]*|(?:LIMIT|OFFSET)\s+(?:\d+\s*,\s*)?|(?:WHERE|AND|OR|SET|HAVING)\s+[\w.`]+\s*(?:=|<>|!=|<=|>=|<|>|LIKE)\s*)["']\s*\.\s*\$(?!wpdb->)(?!\w*(?:format|placeholder))[A-Za-z_]''' pattern = '''(?<![$\w>])(?=(VALUES|IN|ORDER|GROUP|LIMIT|OFFSET|WHERE|AND|OR|SET|HAVING)\b)(?:(?:VALUES|IN)\s*\(|(?:ORDER|GROUP)\s+BY\b[^"'\n]*|(?:LIMIT|OFFSET)\s+(?:\d+\s*,\s*)?|(?:WHERE|AND|OR|SET|HAVING)\s+[\w.`]+\s*(?:=|<>|!=|<=|>=|<|>|LIKE)\s*)["']\s*\.\s*\$(?!wpdb->)(?!\w*(?:format|placeholder))[A-Za-z_]'''
# Active only when the rule above is dropped (priors off); status and precision are the tagged rule's measurement.
[[rule]]
rule_id = "php-sql-unquoted-concat-language"
title = "PHP SQL literal concatenates a variable into an unquoted position (language-level)"
languages = ["php"]
cwe = "CWE-89"
tags = ["injection"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.034
version = "1"
fallback_for = "php-sql-unquoted-concat"
pattern = '''(?<![$\w>])(?=(VALUES|IN|ORDER|GROUP|LIMIT|OFFSET|WHERE|AND|OR|SET|HAVING)\b)(?:(?:VALUES|IN)\s*\(|(?:ORDER|GROUP)\s+BY\b[^"'\n]*|(?:LIMIT|OFFSET)\s+(?:\d+\s*,\s*)?|(?:WHERE|AND|OR|SET|HAVING)\s+[\w.`]+\s*(?:=|<>|!=|<=|>=|<|>|LIKE)\s*)["']\s*\.\s*\$(?!\w*(?:format|placeholder))[A-Za-z_]'''
[[rule]] [[rule]]
rule_id = "php-sql-unquoted-interpolation" rule_id = "php-sql-unquoted-interpolation"
title = "PHP SQL string interpolates a variable into an unquoted position" title = "PHP SQL string interpolates a variable into an unquoted position"
@@ -66,8 +82,23 @@ status = "enabled"
min_evidence_level = "static_corroboration" min_evidence_level = "static_corroboration"
precision_estimate = 0.129 precision_estimate = 0.129
version = "1" version = "1"
framework = "wordpress"
pattern = '''(?<![$\w>])(?=(VALUES|IN|ORDER|GROUP|LIMIT|OFFSET|WHERE|AND|OR|SET|HAVING)\b)(?:(?:VALUES|IN)\s*\(\s*|(?:ORDER|GROUP)\s+BY\b[^"'\n;]*?|(?:LIMIT|OFFSET)\s+(?:\d+\s*,\s*)?|(?:WHERE|AND|OR|SET|HAVING)\s+[\w.`]+\s*(?:=|<>|!=|<=|>=|<|>|LIKE)\s*)\{?\$(?!wpdb->)(?!\w*(?:format|placeholder))[A-Za-z_]''' pattern = '''(?<![$\w>])(?=(VALUES|IN|ORDER|GROUP|LIMIT|OFFSET|WHERE|AND|OR|SET|HAVING)\b)(?:(?:VALUES|IN)\s*\(\s*|(?:ORDER|GROUP)\s+BY\b[^"'\n;]*?|(?:LIMIT|OFFSET)\s+(?:\d+\s*,\s*)?|(?:WHERE|AND|OR|SET|HAVING)\s+[\w.`]+\s*(?:=|<>|!=|<=|>=|<|>|LIKE)\s*)\{?\$(?!wpdb->)(?!\w*(?:format|placeholder))[A-Za-z_]'''
# Active only when the rule above is dropped (priors off); status and precision are the tagged rule's measurement.
[[rule]]
rule_id = "php-sql-unquoted-interpolation-language"
title = "PHP SQL string interpolates a variable into an unquoted position (language-level)"
languages = ["php"]
cwe = "CWE-89"
tags = ["injection"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.129
version = "1"
fallback_for = "php-sql-unquoted-interpolation"
pattern = '''(?<![$\w>])(?=(VALUES|IN|ORDER|GROUP|LIMIT|OFFSET|WHERE|AND|OR|SET|HAVING)\b)(?:(?:VALUES|IN)\s*\(\s*|(?:ORDER|GROUP)\s+BY\b[^"'\n;]*?|(?:LIMIT|OFFSET)\s+(?:\d+\s*,\s*)?|(?:WHERE|AND|OR|SET|HAVING)\s+[\w.`]+\s*(?:=|<>|!=|<=|>=|<|>|LIKE)\s*)\{?\$(?!\w*(?:format|placeholder))[A-Za-z_]'''
# sprintf-built SQL with a quoted %s and no escaping or coercion anywhere in the statement (phpMyFAQ # sprintf-built SQL with a quoted %s and no escaping or coercion anywhere in the statement (phpMyFAQ
# CVE-2026-46364). Statement-scoped, so it spans lines up to the closing `);`. # CVE-2026-46364). Statement-scoped, so it spans lines up to the closing `);`.
[[rule]] [[rule]]
@@ -80,8 +111,23 @@ status = "enabled"
min_evidence_level = "static_corroboration" min_evidence_level = "static_corroboration"
precision_estimate = 0.109 precision_estimate = 0.109
version = "1" version = "1"
framework = "wordpress"
pattern = '''(sprintf)\(\s*["']\s*(?:SELECT|INSERT|UPDATE|DELETE|REPLACE)\b(?=(?:(?!\);)[\s\S]){0,2000}?'%s')(?=(?:(?![Ee]scape|quote\(|intval\(|absint\(|\(int\)|esc_sql\()[\s\S]){0,2000}?\);)''' pattern = '''(sprintf)\(\s*["']\s*(?:SELECT|INSERT|UPDATE|DELETE|REPLACE)\b(?=(?:(?!\);)[\s\S]){0,2000}?'%s')(?=(?:(?![Ee]scape|quote\(|intval\(|absint\(|\(int\)|esc_sql\()[\s\S]){0,2000}?\);)'''
# Active only when the rule above is dropped (priors off); status and precision are the tagged rule's measurement.
[[rule]]
rule_id = "php-sql-sprintf-unescaped-language"
title = "PHP sprintf-built SQL with an unescaped %s value (language-level)"
languages = ["php"]
cwe = "CWE-89"
tags = ["injection"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.109
version = "1"
fallback_for = "php-sql-sprintf-unescaped"
pattern = '''(sprintf)\(\s*["']\s*(?:SELECT|INSERT|UPDATE|DELETE|REPLACE)\b(?=(?:(?!\);)[\s\S]){0,2000}?'%s')(?=(?:(?![Ee]scape|quote\(|intval\(|\(int\))[\s\S]){0,2000}?\);)'''
# --- command / code (command, code_eval) ------------------------------------------------------------------- # --- command / code (command, code_eval) -------------------------------------------------------------------
[[rule]] [[rule]]
@@ -120,8 +166,22 @@ status = "shadow"
min_evidence_level = "static_corroboration" min_evidence_level = "static_corroboration"
precision_estimate = 0.25 precision_estimate = 0.25
version = "1" version = "1"
pattern = '''(?<![\w>:$\\])(?<!function )(?=(unserialize)\s*\()unserialize\s*\((?![^\n]*allowed_classes)[^\n;]*(?:\$[A-Za-z_]|php://input)'''
[[rule]]
rule_id = "php-maybe-unserialize"
title = "WordPress maybe_unserialize of request input"
languages = ["php"]
cwe = "CWE-502"
tags = ["deserialization"]
status = "shadow"
min_evidence_level = "static_corroboration"
precision_estimate = 0.25
version = "1"
framework = "wordpress"
# `maybe_unserialize` (WordPress) runs on every option and meta read, so it counts only with request input. # `maybe_unserialize` (WordPress) runs on every option and meta read, so it counts only with request input.
pattern = '''(?<![\w>:$\\])(?<!function )(?=(unserialize|maybe_unserialize)\s*\()(?:unserialize\s*\((?![^\n]*allowed_classes)[^\n;]*(?:\$[A-Za-z_]|php://input)|maybe_unserialize\s*\([^\n;]*(?:\$_(?:GET|POST|REQUEST|COOKIE)\b|php://input))''' # Split from php-unserialize (2026-09-30), whose measured status and precision it keeps.
pattern = '''(?<![\w>:$\\])(?<!function )(?=(maybe_unserialize)\s*\()maybe_unserialize\s*\([^\n;]*(?:\$_(?:GET|POST|REQUEST|COOKIE)\b|php://input)'''
# --- output encoding (echo) -------------------------------------------------------------------------------- # --- output encoding (echo) --------------------------------------------------------------------------------
@@ -135,8 +195,23 @@ status = "shadow"
min_evidence_level = "static_corroboration" min_evidence_level = "static_corroboration"
precision_estimate = 0.333 precision_estimate = 0.333
version = "1" version = "1"
framework = "wordpress"
pattern = '''(?m)^(?![^\n]*(?:htmlspecialchars|htmlentities|esc_html|esc_attr|esc_url|esc_js|intval|absint|json_encode|urlencode)\s*\()[^\n]*?(\becho\b|\bprint\b|<\?=)(?:[^\n;]*?[.,])?\s*\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\[[^\]\n]*\]\s*(?:[.,;]|\?>)''' pattern = '''(?m)^(?![^\n]*(?:htmlspecialchars|htmlentities|esc_html|esc_attr|esc_url|esc_js|intval|absint|json_encode|urlencode)\s*\()[^\n]*?(\becho\b|\bprint\b|<\?=)(?:[^\n;]*?[.,])?\s*\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\[[^\]\n]*\]\s*(?:[.,;]|\?>)'''
# Active only when the rule above is dropped (priors off); status and precision are the tagged rule's measurement.
[[rule]]
rule_id = "php-xss-echo-input-language"
title = "PHP echo of request input without HTML escaping (language-level)"
languages = ["php"]
cwe = "CWE-79"
tags = ["injection"]
status = "shadow"
min_evidence_level = "static_corroboration"
precision_estimate = 0.333
version = "1"
fallback_for = "php-xss-echo-input"
pattern = '''(?m)^(?![^\n]*(?:htmlspecialchars|htmlentities|intval|json_encode|urlencode)\s*\()[^\n]*?(\becho\b|\bprint\b|<\?=)(?:[^\n;]*?[.,])?\s*\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\[[^\]\n]*\]\s*(?:[.,;]|\?>)'''
# --- path / inclusion (filesystem, file_inclusion) --------------------------------------------------------- # --- path / inclusion (filesystem, file_inclusion) ---------------------------------------------------------
[[rule]] [[rule]]
@@ -80,6 +80,7 @@ status = "enabled"
min_evidence_level = "static_corroboration" min_evidence_level = "static_corroboration"
precision_estimate = 0.0 precision_estimate = 0.0
version = "1" version = "1"
framework = "flask"
pattern = '''\b(render_template_string)\s*\(''' pattern = '''\b(render_template_string)\s*\('''
[[rule]] [[rule]]
@@ -92,8 +93,23 @@ status = "enabled"
min_evidence_level = "static_corroboration" min_evidence_level = "static_corroboration"
precision_estimate = 0.0 precision_estimate = 0.0
version = "1" version = "1"
library = "pyyaml"
pattern = '''\b(pickle\.loads|pickle\.load|cPickle\.loads|yaml\.load|marshal\.loads)\s*\(''' pattern = '''\b(pickle\.loads|pickle\.load|cPickle\.loads|yaml\.load|marshal\.loads)\s*\('''
# Active only when the rule above is dropped (priors off); status and precision are the tagged rule's measurement.
[[rule]]
rule_id = "python-unsafe-deserialization-language"
title = "Unsafe Python deserialization needs review (language-level)"
languages = ["python"]
cwe = "CWE-502"
tags = ["deserialization"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
fallback_for = "python-unsafe-deserialization"
pattern = '''\b(pickle\.loads|pickle\.load|cPickle\.loads|marshal\.loads)\s*\('''
[[rule]] [[rule]]
rule_id = "python-unsafe-eval" rule_id = "python-unsafe-eval"
title = "Dynamic Python execution needs review" title = "Dynamic Python execution needs review"
+34 -3
View File
@@ -3,7 +3,13 @@ language = "java"
[[source]] [[source]]
id = "servlet" id = "servlet"
patterns = ["getParameter", "getHeader", "getCookies", "readLine", "getQueryString"] framework = "jakarta-servlet"
patterns = ["getParameter", "getHeader", "getCookies", "getQueryString"]
# A request body or a console read line by line: the reader, not the servlet API.
[[source]]
id = "servlet"
patterns = ["readLine"]
[[sink]] [[sink]]
id = "exec" id = "exec"
@@ -45,6 +51,7 @@ rule_ids = []
[[sink]] [[sink]]
# CWE-79. Servlet response writers: what reaches these reaches the browser unescaped. # CWE-79. Servlet response writers: what reaches these reaches the browser unescaped.
id = "response.write" id = "response.write"
framework = "jakarta-servlet"
cwe = "CWE-79" cwe = "CWE-79"
calls = ["println", "print", "write", "getWriter", "setHeader", "addHeader"] calls = ["println", "print", "write", "getWriter", "setHeader", "addHeader"]
rule_ids = [] rule_ids = []
@@ -53,13 +60,37 @@ rule_ids = []
# CWE-918 / CWE-601: outbound requests and redirects to a caller-supplied destination. # CWE-918 / CWE-601: outbound requests and redirects to a caller-supplied destination.
id = "outbound.request" id = "outbound.request"
cwe = "CWE-918" cwe = "CWE-918"
calls = ["openConnection", "openStream", "sendRedirect", "getForObject", "exchange"] calls = ["openConnection", "openStream"]
rule_ids = []
[[sink]]
id = "outbound.request"
framework = "jakarta-servlet"
cwe = "CWE-918"
calls = ["sendRedirect"]
rule_ids = []
[[sink]]
id = "outbound.request"
framework = "spring"
cwe = "CWE-918"
calls = ["getForObject", "exchange"]
rule_ids = [] rule_ids = []
[[sanitizer]] [[sanitizer]]
# CWE-22 / CWE-79. The documented normalising and encoding APIs; ESAPI is what the OWASP suite itself uses. # CWE-22 / CWE-79. The documented normalising and encoding APIs; ESAPI is what the OWASP suite itself uses.
id = "java.escape" id = "java.escape"
calls = ["getCanonicalPath", "normalize", "ESAPI.encoder", "encodeForHTML", "encodeForURL", "StringEscapeUtils.escapeHtml4", "URLEncoder.encode"] calls = ["getCanonicalPath", "normalize", "URLEncoder.encode"]
[[sanitizer]]
id = "java.escape"
library = "esapi"
calls = ["ESAPI.encoder", "encodeForHTML", "encodeForURL"]
[[sanitizer]]
id = "java.escape"
library = "commons-text"
calls = ["StringEscapeUtils.escapeHtml4"]
[[sanitizer]] [[sanitizer]]
id = "parameterized_statement" id = "parameterized_statement"
@@ -3,6 +3,7 @@ language = "javascript"
[[source]] [[source]]
id = "req" id = "req"
framework = "express"
patterns = ["req.query", "req.body", "req.params", "req.cookies", "req.headers", "request.query"] patterns = ["req.query", "req.body", "req.params", "req.cookies", "req.headers", "request.query"]
[[sink]] [[sink]]
@@ -56,14 +57,35 @@ patterns = ["req.url", "request.url", "req.rawHeaders", "url.parse", "parsedUrl"
# dynamic key. These are the documented merge helpers plus the assignment forms. # dynamic key. These are the documented merge helpers plus the assignment forms.
id = "prototype.merge" id = "prototype.merge"
cwe = "CWE-1321" cwe = "CWE-1321"
calls = ["merge", "mergeDeep", "extend", "defaultsDeep", "Object.assign", "set", "setWith"] calls = ["Object.assign"]
rule_ids = []
[[sink]]
id = "prototype.merge"
library = "lodash"
cwe = "CWE-1321"
calls = ["merge", "mergeDeep", "extend", "defaultsDeep", "set", "setWith"]
rule_ids = [] rule_ids = []
[[sink]] [[sink]]
# CWE-79. The documented "trusted HTML" sinks in the DOM and the major frameworks. # CWE-79. The documented "trusted HTML" sinks in the DOM and the major frameworks.
id = "html.unescaped" id = "html.unescaped"
cwe = "CWE-79" cwe = "CWE-79"
calls = ["innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "dangerouslySetInnerHTML", "v-html"] calls = ["innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"]
rule_ids = []
[[sink]]
id = "html.unescaped"
framework = "react"
cwe = "CWE-79"
calls = ["dangerouslySetInnerHTML"]
rule_ids = []
[[sink]]
id = "html.unescaped"
framework = "vue"
cwe = "CWE-79"
calls = ["v-html"]
rule_ids = [] rule_ids = []
[[sink]] [[sink]]
@@ -79,14 +101,28 @@ rule_ids = []
# limit on this family rather than guessed at here. # limit on this family rather than guessed at here.
id = "html.response" id = "html.response"
cwe = "CWE-79" cwe = "CWE-79"
calls = ["res.write", "res.end", "res.send", "response.write", "response.end", "response.send"] calls = ["res.write", "res.end", "response.write", "response.end"]
rule_ids = []
[[sink]]
id = "html.response"
framework = "express"
cwe = "CWE-79"
calls = ["res.send", "response.send"]
rule_ids = [] rule_ids = []
[[sink]] [[sink]]
# CWE-22. Node filesystem entry points that accept a caller-supplied path. # CWE-22. Node filesystem entry points that accept a caller-supplied path.
id = "path.open" id = "path.open"
cwe = "CWE-22" cwe = "CWE-22"
calls = ["readFile", "readFileSync", "createReadStream", "sendFile", "unlink", "unlinkSync"] calls = ["readFile", "readFileSync", "createReadStream", "unlink", "unlinkSync"]
rule_ids = []
[[sink]]
id = "path.open"
framework = "express"
cwe = "CWE-22"
calls = ["sendFile"]
rule_ids = [] rule_ids = []
[[sink]] [[sink]]
@@ -109,13 +145,6 @@ rule_ids = []
id = "outbound.request" id = "outbound.request"
cwe = "CWE-918" cwe = "CWE-918"
calls = [ calls = [
"axios", # axios, and axios.get/.post/.put/.patch/.delete/.head/.request through it
"got", # got
"needle", # needle
"request", # request / request-promise
"superagent", # superagent
"unirest", # unirest
"undici", # undici, and undici.fetch/.request through it
"fetch", # global fetch, node-fetch, cross-fetch "fetch", # global fetch, node-fetch, cross-fetch
"http.get", "http.get",
"http.request", "http.request",
@@ -125,10 +154,34 @@ calls = [
rule_ids = [] rule_ids = []
prefix_fixes_origin = true prefix_fixes_origin = true
[[sink]]
id = "outbound.request"
library = "node-http-clients"
cwe = "CWE-918"
calls = [
"axios", # axios, and axios.get/.post/.put/.patch/.delete/.head/.request through it
"got", # got
"needle", # needle
"request", # request / request-promise
"superagent", # superagent
"unirest", # unirest
"undici", # undici, and undici.fetch/.request through it
]
rule_ids = []
prefix_fixes_origin = true
[[sink]]
id = "redirect"
framework = "express"
cwe = "CWE-601"
calls = ["redirect"]
rule_ids = []
prefix_fixes_origin = true
[[sink]] [[sink]]
id = "redirect" id = "redirect"
cwe = "CWE-601" cwe = "CWE-601"
calls = ["redirect", "location.assign", "location.replace"] calls = ["location.assign", "location.replace"]
rule_ids = [] rule_ids = []
prefix_fixes_origin = true prefix_fixes_origin = true
@@ -146,7 +199,22 @@ calls = ["path.normalize", "path.resolve"]
[[sanitizer]] [[sanitizer]]
# CWE-79. The standard encoders: the DOM/global URI encoders and the common escaping helpers. # CWE-79. The standard encoders: the DOM/global URI encoders and the common escaping helpers.
id = "html.escape" id = "html.escape"
calls = ["encodeURIComponent", "encodeURI", "escapeHtml", "escape", "sanitizeHtml", "DOMPurify.sanitize"] calls = ["encodeURIComponent", "encodeURI", "escape"]
[[sanitizer]]
id = "html.escape"
library = "escape-html"
calls = ["escapeHtml"]
[[sanitizer]]
id = "html.escape"
library = "sanitize-html"
calls = ["sanitizeHtml"]
[[sanitizer]]
id = "html.escape"
library = "dompurify"
calls = ["DOMPurify.sanitize"]
[[layout]] [[layout]]
+27 -5
View File
@@ -46,6 +46,7 @@ patterns = ["php://input"]
# them itself, so they are not injection sinks. # them itself, so they are not injection sinks.
[[sink]] [[sink]]
id = "wpdb" id = "wpdb"
framework = "wordpress"
cwe = "CWE-89" cwe = "CWE-89"
calls = [ calls = [
"$wpdb->get_var", "$wpdb->get_var",
@@ -124,6 +125,12 @@ rule_ids = []
# --- deserialization --------------------------------------------------------------------------------- # --- deserialization ---------------------------------------------------------------------------------
[[sink]]
id = "unserialize"
cwe = "CWE-502"
calls = ["unserialize"]
rule_ids = []
[[sink]] [[sink]]
# `maybe_unserialize` is WordPress's own wrapper and the form every plugin in this project's corpus actually # `maybe_unserialize` is WordPress's own wrapper and the form every plugin in this project's corpus actually
# writes: across four plugins and 1,164 PHP files there is exactly one deserialization site, and it is spelled # writes: across four plugins and 1,164 PHP files there is exactly one deserialization site, and it is spelled
@@ -134,8 +141,9 @@ rule_ids = []
# It is a real entry point rather than a safe wrapper: `maybe_unserialize` calls `unserialize` whenever the # It is a real entry point rather than a safe wrapper: `maybe_unserialize` calls `unserialize` whenever the
# string looks serialized, and only returns the value untouched when it does not. # string looks serialized, and only returns the value untouched when it does not.
id = "unserialize" id = "unserialize"
framework = "wordpress"
cwe = "CWE-502" cwe = "CWE-502"
calls = ["unserialize", "maybe_unserialize"] calls = ["maybe_unserialize"]
rule_ids = [] rule_ids = []
# --- untrusted destination --------------------------------------------------------------------------- # --- untrusted destination ---------------------------------------------------------------------------
@@ -167,7 +175,14 @@ calls = ["htmlspecialchars", "htmlentities", "html_entity_decode_safe"]
# a cleanser. # a cleanser.
[[sanitizer]] [[sanitizer]]
id = "sql_escape" id = "sql_escape"
calls = ["mysqli_real_escape_string", "mysql_real_escape_string", "pg_escape_string", "esc_sql"] calls = ["mysqli_real_escape_string", "mysql_real_escape_string", "pg_escape_string"]
quoted_only = true
# WordPress's escape of the same kind.
[[sanitizer]]
id = "sql_escape"
framework = "wordpress"
calls = ["esc_sql"]
quoted_only = true quoted_only = true
[[sanitizer]] [[sanitizer]]
@@ -193,6 +208,7 @@ guard = true
[[sanitizer]] [[sanitizer]]
id = "wpdb_prepare" id = "wpdb_prepare"
framework = "wordpress"
calls = ["$wpdb->prepare"] calls = ["$wpdb->prepare"]
[[sanitizer]] [[sanitizer]]
@@ -205,7 +221,12 @@ calls = ["basename", "realpath"]
[[sanitizer]] [[sanitizer]]
id = "coerce" id = "coerce"
calls = ["intval", "floatval", "filter_var", "settype", "absint"] calls = ["intval", "floatval", "filter_var", "settype"]
[[sanitizer]]
id = "coerce"
framework = "wordpress"
calls = ["absint"]
# A prepared statement is a SHAPE, not a cleansing call: `mysqli_prepare` makes the query safe by binding # A prepared statement is a SHAPE, not a cleansing call: `mysqli_prepare` makes the query safe by binding
# rather than by transforming the value, which is the same distinction `execute`/`executemany` carry in # rather than by transforming the value, which is the same distinction `execute`/`executemany` carry in
@@ -224,6 +245,7 @@ parameterized = true
# a row here rather than a change to the query. That is the whole reason it is a fact and not a constant. # a row here rather than a change to the query. That is the whole reason it is a fact and not a constant.
[[dispatch]] [[dispatch]]
id = "wordpress_hooks" id = "wordpress_hooks"
framework = "wordpress"
register = ["add_action", "add_filter", "add_shortcode"] register = ["add_action", "add_filter", "add_shortcode"]
apply = ["apply_filters", "apply_filters_ref_array", "do_action", "do_action_ref_array"] apply = ["apply_filters", "apply_filters_ref_array", "do_action", "do_action_ref_array"]
# A filter returns its VALUE argument, filtered; its further arguments are context and never the result, and # A filter returns its VALUE argument, filtered; its further arguments are context and never the result, and
@@ -240,8 +262,8 @@ request_arguments = ["add_shortcode"]
# (a bundled library is a separate unit); `tests` paths stay in scope but are not product, so their # (a bundled library is a separate unit); `tests` paths stay in scope but are not product, so their
# regions are ordered after the product's. Measured before this row: 207 of WP Statistics's 357 PHP files # regions are ordered after the product's. Measured before this row: 207 of WP Statistics's 357 PHP files
# were vendored and two of them were 60% of its graph; 14 of the 67 regions ahead of MW WP Form's CVE # were vendored and two of them were 60% of its graph; 14 of the 67 regions ahead of MW WP Form's CVE
# were test-mail.php and test-mail-parser.php. # were test-mail.php and test-mail-parser.php. A product filter (vendored and test paths), not detection: untagged.
[[layout]] [[layout]]
id = "php_composer_and_wordpress" id = "php_layout"
vendored = ["vendor/", "node_modules/", "third_party/", "third-party/", "lib/vendor/"] vendored = ["vendor/", "node_modules/", "third_party/", "third-party/", "lib/vendor/"]
tests = ["tests/", "test/", "spec/", "test-*.php", "*Test.php", "*.test.php", "*_test.php", "phpunit/"] tests = ["tests/", "test/", "spec/", "test-*.php", "*Test.php", "*.test.php", "*_test.php", "phpunit/"]
+73 -6
View File
@@ -3,6 +3,7 @@ language = "python"
[[source]] [[source]]
id = "request" id = "request"
framework = "flask"
patterns = [ patterns = [
"request.args", "request.args",
"request.form", "request.form",
@@ -22,6 +23,7 @@ patterns = [
# validated for type, not for trust. # validated for type, not for trust.
[[source]] [[source]]
id = "django-request" id = "django-request"
framework = "django"
patterns = [ patterns = [
"request.GET", "request.GET",
"request.POST", "request.POST",
@@ -54,9 +56,16 @@ rule_ids = ["python-unsafe-eval"]
[[sink]] [[sink]]
id = "pickle.loads" id = "pickle.loads"
cwe = "CWE-502" cwe = "CWE-502"
calls = ["pickle.loads", "pickle.load", "yaml.load", "marshal.loads", "cPickle.loads"] calls = ["pickle.loads", "pickle.load", "marshal.loads", "cPickle.loads"]
rule_ids = ["python-unsafe-deserialization"] rule_ids = ["python-unsafe-deserialization"]
[[sink]]
id = "pickle.loads"
library = "pyyaml"
cwe = "CWE-502"
calls = ["yaml.load"]
rule_ids = []
[[sink]] [[sink]]
id = "execute" id = "execute"
cwe = "CWE-89" cwe = "CWE-89"
@@ -82,6 +91,7 @@ calls = ["ast.literal_eval"]
[[sanitizer]] [[sanitizer]]
id = "yaml.safe_load" id = "yaml.safe_load"
library = "pyyaml"
calls = ["yaml.safe_load", "safe_load"] calls = ["yaml.safe_load", "safe_load"]
[[sanitizer]] [[sanitizer]]
@@ -100,8 +110,16 @@ parameterized = true
# CWE-89. Django's documented raw-SQL escape hatches (docs: "Performing raw SQL queries") and the # CWE-89. Django's documented raw-SQL escape hatches (docs: "Performing raw SQL queries") and the
# SQLAlchemy/peewee text forms. These were the single largest miss class in the go/no-go. # SQLAlchemy/peewee text forms. These were the single largest miss class in the go/no-go.
id = "orm.raw" id = "orm.raw"
framework = "django"
cwe = "CWE-89" cwe = "CWE-89"
calls = ["objects.raw", "raw", "RawSQL", "extra", "text", "from_statement"] calls = ["objects.raw", "raw", "RawSQL", "extra"]
rule_ids = []
[[sink]]
id = "orm.raw"
library = "sqlalchemy"
cwe = "CWE-89"
calls = ["text", "from_statement"]
rule_ids = [] rule_ids = []
[[sink]] [[sink]]
@@ -109,7 +127,21 @@ rule_ids = []
# send_file/send_from_directory and Django's FileResponse. # send_file/send_from_directory and Django's FileResponse.
id = "path.open" id = "path.open"
cwe = "CWE-22" cwe = "CWE-22"
calls = ["open", "send_file", "send_from_directory", "FileResponse", "os.remove", "os.unlink", "shutil.copy", "shutil.move", "shutil.rmtree"] calls = ["open", "os.remove", "os.unlink", "shutil.copy", "shutil.move", "shutil.rmtree"]
rule_ids = []
[[sink]]
id = "path.open"
framework = "flask"
cwe = "CWE-22"
calls = ["send_file", "send_from_directory"]
rule_ids = []
[[sink]]
id = "path.open"
framework = "django"
cwe = "CWE-22"
calls = ["FileResponse"]
rule_ids = [] rule_ids = []
[[sink]] [[sink]]
@@ -117,8 +149,16 @@ rule_ids = []
# Markup, Django's mark_safe and format_html. Rendering a template *file* is not a sink; rendering a # Markup, Django's mark_safe and format_html. Rendering a template *file* is not a sink; rendering a
# caller-supplied *string* is. # caller-supplied *string* is.
id = "html.unescaped" id = "html.unescaped"
framework = "flask"
cwe = "CWE-79" cwe = "CWE-79"
calls = ["render_template_string", "Markup", "mark_safe", "format_html", "HttpResponse"] calls = ["render_template_string", "Markup"]
rule_ids = []
[[sink]]
id = "html.unescaped"
framework = "django"
cwe = "CWE-79"
calls = ["mark_safe", "format_html", "HttpResponse"]
rule_ids = [] rule_ids = []
[[sink]] [[sink]]
@@ -126,14 +166,40 @@ rule_ids = []
# destination. requests/urllib/httpx are the documented HTTP clients; redirect is Flask's and Django's. # destination. requests/urllib/httpx are the documented HTTP clients; redirect is Flask's and Django's.
id = "outbound.request" id = "outbound.request"
cwe = "CWE-918" cwe = "CWE-918"
calls = ["requests.get", "requests.post", "requests.request", "urlopen", "urlretrieve", "httpx.get", "httpx.post"] calls = ["urlopen", "urlretrieve"]
rule_ids = []
prefix_fixes_origin = true
[[sink]]
id = "outbound.request"
library = "requests"
cwe = "CWE-918"
calls = ["requests.get", "requests.post", "requests.request"]
rule_ids = []
prefix_fixes_origin = true
[[sink]]
id = "outbound.request"
library = "httpx"
cwe = "CWE-918"
calls = ["httpx.get", "httpx.post"]
rule_ids = []
prefix_fixes_origin = true
# `redirect` is Flask's (Django's shortcut shares the name; the Flask row carries it).
[[sink]]
id = "redirect"
framework = "flask"
cwe = "CWE-601"
calls = ["redirect", "url_for"]
rule_ids = [] rule_ids = []
prefix_fixes_origin = true prefix_fixes_origin = true
[[sink]] [[sink]]
id = "redirect" id = "redirect"
framework = "django"
cwe = "CWE-601" cwe = "CWE-601"
calls = ["redirect", "HttpResponseRedirect", "url_for"] calls = ["HttpResponseRedirect"]
rule_ids = [] rule_ids = []
# Django's `reverse` builds an in-site path from a route NAME; request data can only fill its path segments. # Django's `reverse` builds an in-site path from a route NAME; request data can only fill its path segments.
prefix_fixes_origin = true prefix_fixes_origin = true
@@ -143,5 +209,6 @@ origin_anchors = ["reverse", "reverse_lazy"]
# redirect is `if not url_has_allowed_host_and_scheme(next_url, ...): next_url = reverse(...)`. # redirect is `if not url_has_allowed_host_and_scheme(next_url, ...): next_url = reverse(...)`.
[[sanitizer]] [[sanitizer]]
id = "url_allowlist" id = "url_allowlist"
framework = "django"
calls = ["url_has_allowed_host_and_scheme", "is_safe_url"] calls = ["url_has_allowed_host_and_scheme", "is_safe_url"]
guard = true guard = true
+30 -2
View File
@@ -6,6 +6,8 @@ from collections.abc import Iterable
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
from .frameworks import Priors, kept, normalize_priors, read_tag
DEFAULT_RULESET_DIR = Path(__file__).parent DEFAULT_RULESET_DIR = Path(__file__).parent
VALID_STATUS = ("enabled", "shadow", "disabled") VALID_STATUS = ("enabled", "shadow", "disabled")
@@ -32,14 +34,23 @@ class PatternRule:
min_evidence_level: str = "static_corroboration" min_evidence_level: str = "static_corroboration"
precision_estimate: float = 0.0 precision_estimate: float = 0.0
version: str = "1" version: str = "1"
# A prior (learned-decision-engine Req 8.2): the rule knows a framework or a library (`ruleset/frameworks.toml`).
framework: str | None = None
library: str | None = None
# The language-level stand-in of a tagged rule: active only when that rule's prior is off.
fallback_for: str | None = None
def load_ruleset(directory: Path = DEFAULT_RULESET_DIR, ledger: Path | None = None) -> tuple[PatternRule, ...]: def load_ruleset(directory: Path = DEFAULT_RULESET_DIR, ledger: Path | None = None, *, priors: Priors = "all") -> tuple[PatternRule, ...]:
"""Load every ``*.toml`` rule file under ``directory`` and apply the loop ledger. """Load every ``*.toml`` rule file under ``directory`` and apply the loop ledger.
Rules are returned sorted by ``rule_id`` for determinism. The optional ledger Rules are returned sorted by ``rule_id`` for determinism. The optional ledger
(``rule_policy.json``) overlays loop-owned ``status``/``min_evidence_level``/ (``rule_policy.json``) overlays loop-owned ``status``/``min_evidence_level``/
``precision_estimate`` per ``rule_id``. ``precision_estimate`` per ``rule_id``.
``priors`` keeps or drops the rules tagged ``framework``/``library``: ``"all"`` (the default, today's rules),
``"off"`` or a set of ids. A rule with ``fallback_for`` stands in for a tagged rule and is active exactly when
that rule is dropped, so the language-level part of a mixed rule survives with its prior off.
""" """
rules: dict[str, PatternRule] = {} rules: dict[str, PatternRule] = {}
for path in sorted(directory.rglob("*.toml")): for path in sorted(directory.rglob("*.toml")):
@@ -49,8 +60,17 @@ def load_ruleset(directory: Path = DEFAULT_RULESET_DIR, ledger: Path | None = No
if rule.rule_id in rules: if rule.rule_id in rules:
raise RulesetError(f"duplicate rule_id {rule.rule_id!r} in {path}") raise RulesetError(f"duplicate rule_id {rule.rule_id!r} in {path}")
rules[rule.rule_id] = rule rules[rule.rule_id] = rule
chosen = normalize_priors(priors)
for rule in rules.values():
target = rules.get(rule.fallback_for) if rule.fallback_for else None
if rule.fallback_for and (target is None or not (target.framework or target.library)):
raise RulesetError(f"rule {rule.rule_id}: fallback_for {rule.fallback_for!r} names no tagged rule")
if rule.fallback_for and (rule.framework or rule.library):
raise RulesetError(f"rule {rule.rule_id}: a fallback is language-level and carries no tag")
active = {rule_id: rule for rule_id, rule in rules.items() if kept(rule.framework or rule.library, chosen)}
selected = [rule for rule in active.values() if not (rule.fallback_for and rule.fallback_for in active)]
overlay = _load_ledger(ledger) overlay = _load_ledger(ledger)
resolved = [_apply_overlay(rule, overlay.get(rule.rule_id)) for rule in rules.values()] resolved = [_apply_overlay(rule, overlay.get(rule.rule_id)) for rule in selected]
return tuple(sorted(resolved, key=lambda rule: rule.rule_id)) return tuple(sorted(resolved, key=lambda rule: rule.rule_id))
@@ -74,6 +94,7 @@ def write_ruleset(path: Path, rules: Iterable[PatternRule]) -> None:
f"min_evidence_level = {_s(rule.min_evidence_level)}", f"min_evidence_level = {_s(rule.min_evidence_level)}",
f"precision_estimate = {float(rule.precision_estimate)}", f"precision_estimate = {float(rule.precision_estimate)}",
f"version = {_s(rule.version)}", f"version = {_s(rule.version)}",
*(f"{name} = {_s(value)}" for name in ("framework", "library", "fallback_for") if (value := getattr(rule, name))),
f"pattern = '''{rule.pattern}'''", f"pattern = '''{rule.pattern}'''",
] ]
) )
@@ -104,6 +125,10 @@ def _rule_from_dict(item: dict[str, object], path: Path) -> PatternRule:
raise RulesetError(f"rule {rule_id} has invalid status {status!r}") raise RulesetError(f"rule {rule_id} has invalid status {status!r}")
if not pattern: if not pattern:
raise RulesetError(f"rule {rule_id} has an empty pattern") raise RulesetError(f"rule {rule_id} has an empty pattern")
try:
framework, library = read_tag(item, f"rule {rule_id} in {path}")
except ValueError as exc:
raise RulesetError(str(exc)) from exc
return PatternRule( return PatternRule(
rule_id=rule_id, rule_id=rule_id,
title=str(item.get("title", rule_id)), title=str(item.get("title", rule_id)),
@@ -115,6 +140,9 @@ def _rule_from_dict(item: dict[str, object], path: Path) -> PatternRule:
min_evidence_level=str(item.get("min_evidence_level", "static_corroboration")), min_evidence_level=str(item.get("min_evidence_level", "static_corroboration")),
precision_estimate=float(item.get("precision_estimate", 0.0)), # type: ignore[arg-type] precision_estimate=float(item.get("precision_estimate", 0.0)), # type: ignore[arg-type]
version=str(item.get("version", "1")), version=str(item.get("version", "1")),
framework=framework,
library=library,
fallback_for=str(item["fallback_for"]) if item.get("fallback_for") else None,
) )
+50 -5
View File
@@ -1,4 +1,9 @@
"""Source, sink, and sanitizer facts as versioned data next to the ruleset.""" """Source, sink, and sanitizer facts as versioned data next to the ruleset.
An entry that knows a framework or a library carries ``framework = "<id>"`` or ``library = "<id>"`` (an id of
``ruleset/frameworks.toml``): a prior (learned-decision-engine Req 8.2). :func:`load_facts` keeps or drops the tagged
entries by ``priors`` -- ``"all"`` (the default: today's facts), ``"off"`` (language-level only) or a set of ids.
"""
from __future__ import annotations from __future__ import annotations
@@ -6,6 +11,8 @@ import tomllib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ..ruleset.frameworks import Priors, kept, normalize_priors, read_tag, tag_of
DEFAULT_FACTS_DIR = Path(__file__).resolve().parents[1] / "ruleset" / "semantic" DEFAULT_FACTS_DIR = Path(__file__).resolve().parents[1] / "ruleset" / "semantic"
_LANGUAGE_ALIASES = { _LANGUAGE_ALIASES = {
@@ -31,6 +38,8 @@ class SourceFact:
id: str id: str
patterns: tuple[str, ...] patterns: tuple[str, ...]
language: str language: str
framework: str | None = None
library: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -48,6 +57,8 @@ class SinkFact:
# not an untrusted destination, and neither is one that only fills an anchor call's arguments. # not an untrusted destination, and neither is one that only fills an anchor call's arguments.
prefix_fixes_origin: bool = False prefix_fixes_origin: bool = False
origin_anchors: tuple[str, ...] = () origin_anchors: tuple[str, ...] = ()
framework: str | None = None
library: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -64,6 +75,8 @@ class SanitizerFact:
# makes a value safe only where it held -- inside the branch it guards, past an exit on its failure, or in # makes a value safe only where it held -- inside the branch it guards, past an exit on its failure, or in
# the arm of a ternary it selects. Never a cleansing call on a path. # the arm of a ternary it selects. Never a cleansing call on a path.
guard: bool = False guard: bool = False
framework: str | None = None
library: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -98,6 +111,8 @@ class DispatchFact:
# `WP_User` a profile hook gets -- so its parameters are not attacker input; where request data does # `WP_User` a profile hook gets -- so its parameters are not attacker input; where request data does
# reach an apply call's arguments, the hook half of the taint query follows it from there. # reach an apply call's arguments, the hook half of the taint query follows it from there.
request_arguments: tuple[str, ...] = () request_arguments: tuple[str, ...] = ()
framework: str | None = None
library: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -142,6 +157,25 @@ class SemanticFacts:
) )
def _tags(item: dict[str, object], path: Path) -> dict[str, str | None]:
framework, library = read_tag(item, f"{path} {item.get('id')!r}")
return {"framework": framework, "library": library}
def with_priors(facts: SemanticFacts, priors: Priors) -> SemanticFacts:
"""``facts`` with the tagged entries ``priors`` does not keep removed (language-level entries always stay)."""
if priors == "all":
return facts
return SemanticFacts(
version=facts.version,
sources=tuple(f for f in facts.sources if kept(tag_of(f), priors)),
sinks=tuple(f for f in facts.sinks if kept(tag_of(f), priors)),
sanitizers=tuple(f for f in facts.sanitizers if kept(tag_of(f), priors)),
dispatches=tuple(f for f in facts.dispatches if kept(tag_of(f), priors)),
layouts=facts.layouts,
)
def _load_facts_uncached(directory: Path | None = None) -> SemanticFacts: def _load_facts_uncached(directory: Path | None = None) -> SemanticFacts:
root = directory if directory is not None else DEFAULT_FACTS_DIR root = directory if directory is not None else DEFAULT_FACTS_DIR
if not root.is_dir(): if not root.is_dir():
@@ -193,6 +227,7 @@ def _dispatches(value: object, language: str, path: Path) -> list[DispatchFact]:
returns=_returning(item, path), returns=_returning(item, path),
value_arg=_value_arg(item, path), value_arg=_value_arg(item, path),
request_arguments=_request_arguments(item, path), request_arguments=_request_arguments(item, path),
**_tags(item, path),
) )
) )
return facts return facts
@@ -272,7 +307,7 @@ def _sources(value: object, language: str, path: Path) -> list[SourceFact]:
patterns = _strings(item.get("patterns"), "patterns", path) patterns = _strings(item.get("patterns"), "patterns", path)
if not patterns: if not patterns:
raise FactLoadError(f"{path} source {ident} has no patterns") raise FactLoadError(f"{path} source {ident} has no patterns")
rows.append(SourceFact(id=ident, patterns=patterns, language=language)) rows.append(SourceFact(id=ident, patterns=patterns, language=language, **_tags(item, path)))
return rows return rows
@@ -310,6 +345,7 @@ def _sinks(value: object, language: str, path: Path) -> list[SinkFact]:
format_arg=format_arg if isinstance(format_arg, int) else None, format_arg=format_arg if isinstance(format_arg, int) else None,
prefix_fixes_origin=_flag(item.get("prefix_fixes_origin"), "prefix_fixes_origin", path, ident), prefix_fixes_origin=_flag(item.get("prefix_fixes_origin"), "prefix_fixes_origin", path, ident),
origin_anchors=_strings(item.get("origin_anchors"), "origin_anchors", path), origin_anchors=_strings(item.get("origin_anchors"), "origin_anchors", path),
**_tags(item, path),
) )
) )
return rows return rows
@@ -336,6 +372,7 @@ def _sanitizers(value: object, language: str, path: Path) -> list[SanitizerFact]
literal_format_arg=literal if isinstance(literal, int) else None, literal_format_arg=literal if isinstance(literal, int) else None,
quoted_only=_flag(item.get("quoted_only"), "quoted_only", path, ident), quoted_only=_flag(item.get("quoted_only"), "quoted_only", path, ident),
guard=_flag(item.get("guard"), "guard", path, ident), guard=_flag(item.get("guard"), "guard", path, ident),
**_tags(item, path),
) )
) )
return rows return rows
@@ -372,11 +409,19 @@ def _stat_of(path: Path) -> tuple[int, int]:
return info.st_size, info.st_mtime_ns return info.st_size, info.st_mtime_ns
def load_facts(directory: Path | None = None) -> SemanticFacts: def load_facts(directory: Path | None = None, *, priors: Priors = "all") -> SemanticFacts:
"""The facts of ``directory`` (default: the bundled ruleset), with the priors ``priors`` keeps: ``"all"`` (the
default, today's facts), ``"off"`` or a set of framework/library ids."""
chosen = normalize_priors(priors)
root = directory if directory is not None else DEFAULT_FACTS_DIR root = directory if directory is not None else DEFAULT_FACTS_DIR
if not root.is_dir(): if not root.is_dir():
return _load_facts_uncached(directory) return with_priors(_load_facts_uncached(directory), chosen)
key = _ruleset_state(root, "*.toml") key = _ruleset_state(root, "*.toml")
if key not in _FACTS_MEMO: if key not in _FACTS_MEMO:
_FACTS_MEMO[key] = _load_facts_uncached(directory) _FACTS_MEMO[key] = _load_facts_uncached(directory)
return _FACTS_MEMO[key] if chosen == "all":
return _FACTS_MEMO[key]
scoped = (*key, "priors", chosen if isinstance(chosen, str) else tuple(sorted(chosen)))
if scoped not in _FACTS_MEMO:
_FACTS_MEMO[scoped] = with_priors(_FACTS_MEMO[key], chosen)
return _FACTS_MEMO[scoped]
+1 -1
View File
@@ -11,7 +11,7 @@ def test_layout_rows_load_and_match() -> None:
from openultrasast.model.layout import is_test_path, is_vendored, layout_facts from openultrasast.model.layout import is_test_path, is_vendored, layout_facts
rows = layout_facts("php") rows = layout_facts("php")
assert [r.id for r in rows] == ["php_composer_and_wordpress"] assert [r.id for r in rows] == ["php_layout"]
assert is_vendored("includes/vendor/whichbrowser/parser/data/profiles.php", rows) assert is_vendored("includes/vendor/whichbrowser/parser/data/profiles.php", rows)
assert is_vendored("vendor/autoload.php", rows) assert is_vendored("vendor/autoload.php", rows)
assert not is_vendored("classes/class.mail.php", rows) assert not is_vendored("classes/class.mail.php", rows)
+196
View File
@@ -0,0 +1,196 @@
"""The candidate feature schema (learned-decision-engine task 1; Req 1.2, 7.2): a closed allow-list with no
identities, missing distinct from zero, and no label-carrying field reaching a feature."""
from __future__ import annotations
import copy
import tomllib
from pathlib import Path
from typing import Any
import pytest
from openultrasast.findings import quick_scan_findings
from openultrasast.learn import schema
from openultrasast.learn.features import Delta, Part, build_for_scan, record
from openultrasast.learn.schema import EXCLUDED_FIELDS, FEATURES, FeatureRecordError, validate_record
from openultrasast.mapping import analyze_entry_points, attach_reachability_hints
from openultrasast.plane.tasks.features import case_features
from openultrasast.preprocess import preprocess_repository
from openultrasast.rank import rank_targets
ROOT = Path(__file__).resolve().parents[1]
APP = """import os
def {inner}(value):
os.system(value)
def {outer}():
{inner}("x")
"""
def _scan(root: Path) -> list[dict[str, Any]]:
_, targets = preprocess_repository(root)
assert targets and all((root / t.path).stat().st_size > 0 for t in targets), "the fixture was read"
targets = attach_reachability_hints(targets, analyze_entry_points(root, targets))
findings = quick_scan_findings(root, targets, rank_targets(targets))
assert findings, "the fixture's os.system fires a quick rule"
engine = {"questions": 3, "completed": 3, "degradations": [], "findings": []}
return build_for_scan(root, findings, engine)
def _repo(root: Path, path: str, inner: str, outer: str) -> Path:
(root / path).parent.mkdir(parents=True, exist_ok=True)
(root / path).write_text(APP.format(inner=inner, outer=outer))
return root
# --- the allow-list ----------------------------------------------------------------------------------------------
def test_features_are_allow_listed(tmp_path: Path) -> None:
names = [spec.name for spec in FEATURES]
assert len(names) == len(set(names)), "a feature name is declared twice"
for spec in FEATURES:
assert spec.type in ("bool", "int", "float", "enum"), f"{spec.name}: no free text"
assert spec.instrument in schema.INSTRUMENTS and spec.profile in schema.PROFILES
assert (spec.type == "int") == (spec.cap is not None), spec.name
assert (spec.type == "enum") == bool(spec.vocabulary), spec.name
assert spec.name.rsplit(".", 1)[-1] not in EXCLUDED_FIELDS, f"{spec.name} names a label or identity field"
mechanisms = tomllib.loads((ROOT / "benchmarks" / "pairs" / "mechanisms.toml").read_text())["mechanism"]
assert tuple(m["id"] for m in mechanisms) == schema.MECHANISMS, "the mechanism buckets are the closed vocabulary"
assert set(schema.RULE_TAG_MECHANISM.values()) <= set(schema.MECHANISMS)
records = _scan(_repo(tmp_path, "app/one.py", "alpha", "beta"))
assert records
for rec in records:
assert set(rec["x"]) == {s.name for s in schema.features_for("static")}, "every emitted key is in FEATURES"
assert rec["profile"] == "static" and not any(k.startswith(("verify.", "agree.", "ms.")) for k in rec["x"])
assert {s.name for s in schema.features_for("static")} < set(names)
assert [s.name for s in FEATURES if s.prior] == ["qr.prior_hits"]
def test_record_is_name_invariant(tmp_path: Path) -> None:
"""Two repositories that differ only in repository name, paths, file and function names and commit ids give the
same features: nothing identifies a repository."""
first = _scan(_repo(tmp_path / "acme-shop", "app/one.py", "alpha", "beta"))
second = _scan(_repo(tmp_path / "other-thing", "lib/sub/two.py", "gamma", "delta"))
assert [r["x"] for r in first] == [r["x"] for r in second]
assert [r["instruments"] for r in first] == [r["instruments"] for r in second]
assert first[0]["candidate"] != second[0]["candidate"], "the key differs; it lives only in the envelope"
assert first[0]["x"]["qr.enabled_hits"] >= 1 and first[0]["x"]["facts.entry_distance"] is not None
root = tmp_path / "acme-shop"
_, targets = preprocess_repository(root)
findings = quick_scan_findings(root, targets, rank_targets(targets))
changed = {"app/one.py": [(5, 5)]}
one = build_for_scan(root, findings, None, base=Delta(root, changed, pin="a" * 40))
two = build_for_scan(root, findings, None, base=Delta(root, changed, pin="b" * 40))
assert len(one) == 1 and one[0]["unit"] == "delta" and one[0]["base"] == "a" * 40
assert one[0]["x"]["delta.novelty"] == "new" and one[0]["x"]["delta.changed_lines"] == 1
assert [r["x"] for r in one] == [r["x"] for r in two], "a commit id is envelope, never a feature"
# --- label fields ------------------------------------------------------------------------------------------------
AGREED = {
"family": "injection",
"candidates": [
{"candidate": "a.py::f", "site": "a.py:4:f", "a": True, "b": False, "c": True, "agreed": True, "site_match": True,
"finding": {"family": "injection", "reported_at": "a.py:5"}},
],
"disputed": [],
} # fmt: skip
PASSES = {
"a": [{"path": "a.py", "candidates": [["a.py", "f", 4]], "flagged": [{"candidate": "a.py::f", "reported_at": "a.py:5"}], "turns": 4}],
"b": [{"path": "a.py", "candidates": [["a.py", "f", 4]], "flagged": [], "turns": 6}],
"c": [{"path": "a.py", "candidates": [["a.py", "f", 4]], "flagged": [{"candidate": "a.py::f", "reported_at": "a.py:4"}], "turns": 5}],
}
ALERTS = [
{"rule_id": "python-os-command", "rule_status": "enabled", "path": "a.py", "line": 5, "function": "f", "pin_role": "vulnerable",
"in_fix_range": True},
] # fmt: skip
SUMMARY = {"status": "done", "quick_languages": ["python"], "engine_languages": ["php"]}
def _plane(**overrides: Any) -> list[dict[str, Any]]:
inputs: dict[str, Any] = {
"facts": {"callers": {"a.py::f": [{"path": "b.py", "line": 2}]}}, "passes": PASSES, "models": {"a": "m", "b": "m", "c": "m"},
"agreed": AGREED, "alerts": ALERTS, "alerts_summary": SUMMARY,
} # fmt: skip
return case_features(**{**inputs, **overrides})
def test_label_fields_never_reach_features() -> None:
baseline = _plane()
assert [r["candidate"] for r in baseline] == ["a.py::f"] and baseline[0]["profile"] == "plane"
x = baseline[0]["x"]
assert (x["verify.flag_a"], x["verify.flag_b"], x["verify.flag_c"], x["verify.votes"]) == (True, False, True, 2)
assert x["agree.final"] == "agreed" and x["qr.enabled_hits"] == 1 and x["verify.site_offset"] == 0
flipped = copy.deepcopy(AGREED)
flipped["candidates"][0]["site_match"] = False
outside = [{**ALERTS[0], "in_fix_range": False}]
fixed_pin = [*ALERTS, {**ALERTS[0], "pin_role": "fixed", "line": 9}, {**ALERTS[0], "pin_role": "fixed", "function": "g"}]
for variant in (_plane(agreed=flipped), _plane(alerts=outside), _plane(alerts=fixed_pin)):
assert variant == baseline, "site_match, in_fix_range and fixed-pin alerts change nothing"
for field in ("site_match", "in_fix_range", "rule_id", "path", "repo", "usd"):
smuggled = copy.deepcopy(baseline[0])
smuggled["x"][field] = True
with pytest.raises(FeatureRecordError, match="allow-list"):
validate_record(smuggled)
with pytest.raises(FeatureRecordError):
validate_record({**baseline[0], field: True})
# --- missing is not zero -----------------------------------------------------------------------------------------
def test_missing_is_not_zero() -> None:
uncovered = _plane(alerts=[], alerts_summary={**SUMMARY, "quick_languages": ["php"]})[0]
assert uncovered["instruments"]["quick"]["state"] == "none"
assert all(v is None for k, v in uncovered["x"].items() if k.startswith("qr."))
covered = _plane(alerts=[])[0]
assert covered["instruments"]["quick"]["state"] == "ran"
assert covered["x"]["qr.enabled_hits"] == 0 and covered["x"]["qr.shadow_hits"] == 0, "ran and found nothing is 0"
no_alerts = _plane(alerts=[], alerts_summary=None)[0]
assert no_alerts["instruments"]["quick"]["state"] == "none" and no_alerts["x"]["qr.enabled_hits"] is None
assert covered["instruments"]["engine"]["state"] == "none" and covered["x"]["eng.findings"] is None
assert covered["instruments"]["delta"]["state"] == "not_applicable" and covered["x"]["delta.novelty"] is None
failed = _plane(alerts_summary={**SUMMARY, "status": "failed"})[0]
assert failed["instruments"]["quick"]["state"] == "failed" and failed["x"]["qr.enabled_hits"] is None
php = {"path": "p.php", "line": 3, "function": "q", "pin_role": "vulnerable", "rule_id": "engine:injection", "rule_status": "enabled"}
engine = {**SUMMARY, "engine": {"vulnerable": {"questions": 0, "completed": 0, "files": 5, "degradations": []}}}
[_, unread] = _plane(alerts=[*ALERTS, php], alerts_summary=engine)
assert unread["instruments"]["engine"]["state"] == "failed" and unread["x"]["eng.findings"] is None, "no question is not zero"
def test_build_for_scan_marks_an_engine_that_did_not_run_or_read(tmp_path: Path) -> None:
root = _repo(tmp_path / "r", "app/one.py", "alpha", "beta")
_, targets = preprocess_repository(root)
findings = quick_scan_findings(root, targets, rank_targets(targets))
unread = build_for_scan(root, findings, {"questions": 0, "findings": []})
absent = build_for_scan(root, findings, None)
assert {r["instruments"]["engine"]["state"] for r in unread} == {"failed"}
assert {r["instruments"]["engine"]["state"] for r in absent} == {"none"}
assert all(r["x"]["eng.findings"] is None for r in (*unread, *absent))
def test_validate_record_refuses_values_outside_their_type() -> None:
good = _plane()[0]
validate_record(good)
for name, value in (("agree.final", "maybe"), ("verify.votes", 4), ("qr.enabled_hits", -1), ("lang.python", 1)):
bad = copy.deepcopy(good)
bad["x"][name] = value
with pytest.raises(FeatureRecordError, match=name.replace(".", r"\.")):
validate_record(bad)
null = copy.deepcopy(good)
null["x"]["agree.final"] = None
with pytest.raises(FeatureRecordError, match="null is not allowed"):
validate_record(null)
value = copy.deepcopy(good)
value["x"]["ms.flagged"] = False
with pytest.raises(FeatureRecordError, match="must be null"):
validate_record(value)
with pytest.raises(FeatureRecordError, match="outside the static profile"):
record("a.py::f", "injection", "python", "static", {"verify": Part("ran", {})})
+2 -1
View File
@@ -31,7 +31,7 @@ def _benchmark(name: str, *, reported_only: bool): # type: ignore[no-untyped-de
def test_php_rules_load_compile_and_are_php_scoped() -> None: def test_php_rules_load_compile_and_are_php_scoped() -> None:
rules = _php_rules() rules = _php_rules()
assert len(rules) == 13 assert len(rules) == 14, "13 measured rules; php-unserialize split into its language and WordPress parts (task 2)"
for rule in rules.values(): for rule in rules.values():
assert rule.languages == ("php",) and rule.status in {"enabled", "shadow"} assert rule.languages == ("php",) and rule.status in {"enabled", "shadow"}
re.compile(rule.pattern) re.compile(rule.pattern)
@@ -52,6 +52,7 @@ def test_every_php_rule_sink_is_in_the_engines_vocabulary() -> None:
def test_status_and_precision_are_the_committed_measurement() -> None: def test_status_and_precision_are_the_committed_measurement() -> None:
measured = json.loads(MEASUREMENT.read_text())["rules"] measured = json.loads(MEASUREMENT.read_text())["rules"]
for rule_id, rule in _php_rules().items(): for rule_id, rule in _php_rules().items():
rule_id = "php-unserialize" if rule_id == "php-maybe-unserialize" else rule_id # split from it, keeps its numbers
assert rule.status == measured[rule_id]["status"], rule_id assert rule.status == measured[rule_id]["status"], rule_id
assert rule.precision_estimate == measured[rule_id]["precision_lower_bound"], rule_id assert rule.precision_estimate == measured[rule_id]["precision_lower_bound"], rule_id
+83
View File
@@ -0,0 +1,83 @@
"""The plane task `features` (learned-decision-engine task 1) on validation-46's recorded lmdeploy case
(``tests/fixtures/plane-remember``), and `remember` storing its records as `features` rows."""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
from openultrasast.learn.schema import FeatureRecordError, feature_set_digest
from openultrasast.plane.memory import FileStore
from openultrasast.plane.tasks import features
from openultrasast.plane.tasks.remember import Context, remember_run, rows_for
FIXTURE = Path(__file__).parent / "fixtures" / "plane-remember" / "validation-46"
CASE = "lmdeploy-media-url-ssrf"
@pytest.fixture
def run_dir(tmp_path: Path) -> Path:
target = tmp_path / "plane" / "validation-46"
shutil.copytree(FIXTURE, target)
return target
def _checkout(root: Path) -> Path:
media = root / "lmdeploy" / "vl" / "media"
media.mkdir(parents=True)
(media / "connection.py").write_text("def _is_safe_url(u):\n return True\n\n\ndef _load_http_url(u):\n x = 1\n return x\n")
return root
def test_features_of_the_recorded_case(run_dir: Path, tmp_path: Path) -> None:
[rec] = features.recorded_case(run_dir, CASE, workspace=_checkout(tmp_path / "checkout"))
assert (rec["candidate"], rec["family"], rec["profile"], rec["unit"]) == (
"lmdeploy/vl/media/connection.py::_load_http_url", "untrusted_destination", "plane", "pin",
) # fmt: skip
x, states = rec["x"], {k: v["state"] for k, v in rec["instruments"].items()}
assert (x["verify.flag_a"], x["verify.flag_b"], x["verify.flag_c"], x["verify.votes"]) == (True, True, None, 2)
assert x["agree.final"] == "agreed" and x["facts.callers"] == 0 and x["facts.is_global"] is False
assert x["facts.function_lines"] == 3 and x["lang.python"] is True and x["verify.site_offset"] == 0
assert states["verify"] == states["agree"] == states["facts"] == states["source"] == "ran"
assert states["quick"] == states["engine"] == "none", "no alerts task in this Run: no coverage, not zero hits"
assert rec["instruments"]["verify"]["version"] == "deepseek-flash" and rec["feature_set_digest"] == feature_set_digest()
assert "site_match" not in json.dumps(rec), "the fixture's site_match: true was dropped on read"
[without] = features.recorded_case(run_dir, CASE)
assert without["instruments"]["source"]["state"] == "none" and without["x"]["facts.function_lines"] is None
def test_the_task_writes_records_and_remember_stores_them(run_dir: Path, tmp_path: Path) -> None:
out = tmp_path / "out"
env = {
"OUSAST_OUTPUT_DIR": str(out), "OUSAST_INPUT_FACTS": str(run_dir / f"{CASE}-facts" / "facts.json"),
"OUSAST_INPUT_AGREED": str(run_dir / f"{CASE}-final" / "agreed.json"),
**{f"OUSAST_INPUT_PASS_{p.upper()}": str(run_dir / f"{CASE}-v{p}" / "units.jsonl") for p in "abc"},
**{f"OUSAST_INPUT_SUMMARY_{p.upper()}": str(run_dir / f"{CASE}-v{p}" / "summary.json") for p in "abc"},
} # fmt: skip
assert features.main(env) == 0
summary = json.loads((out / "summary.json").read_text())
assert (summary["status"], summary["records"], summary["usd"], summary["calls"]) == ("done", 1, 0, 0)
assert summary["instruments"]["verify"] == {"ran": 1} and summary["instruments"]["quick"] == {"none": 1}
(run_dir / f"{CASE}-features").mkdir()
shutil.copy(out / features.OUTPUT, run_dir / f"{CASE}-features" / features.OUTPUT)
store = FileStore(tmp_path / "memory")
[result] = remember_run(run_dir, store, population="population-v2", split="validation")
assert result.kinds == {"facts": 1, "unit_cost": 2, "verdict": 1, "features": 1}
[row] = store.rows(kind="features")
assert row.row["candidate"] == "lmdeploy/vl/media/connection.py::_load_http_url" and row.row["family"] == "untrusted_destination"
assert row.row["repo"] == "example.com/reserved/lmdeploy" and row.row["x"]["agree.final"] == "agreed"
def test_remember_refuses_a_record_outside_the_allow_list(run_dir: Path) -> None:
[rec] = features.recorded_case(run_dir, CASE)
rec["x"]["site_match"] = True
ctx = Context("r", "c-remember", "https://github.com/o/r.git", "0" * 40, "sha256:" + "1" * 64)
with pytest.raises(FeatureRecordError, match="site_match"):
rows_for(ctx, facts=None, passes={}, models={}, agreed=None, features=[rec])
def test_the_task_fails_loudly_without_an_output_dir() -> None:
assert features.main({}) == 2
+11 -2
View File
@@ -56,7 +56,8 @@ def test_model_prices_are_the_endpoint_prices() -> None:
def test_validation_run_resolves_and_renders_every_task(tmp_path: Path) -> None: def test_validation_run_resolves_and_renders_every_task(tmp_path: Path) -> None:
run, manifests = reconciler.load_run(RUN) run, manifests = reconciler.load_run(RUN)
assert len(run.tasks) == 105 # per case: facts, va, vb, agree, then vc, final, then remember (harnessx-removal task 4) # per case: facts, va, vb, agree, then vc, final, then features (learned-decision-engine task 1) and remember
assert len(run.tasks) == 120
population = {c["id"]: c for c in tomllib.loads(POPULATION.read_text())["case"]} population = {c["id"]: c for c in tomllib.loads(POPULATION.read_text())["case"]}
ax_names: set[str] = set() ax_names: set[str] = set()
for task in manifests.tasks.values(): for task in manifests.tasks.values():
@@ -96,7 +97,9 @@ def test_tiebreak_appends_thirty_tasks_and_keeps_the_first_sixty_byte_identical(
first = text.split(" - name: budibase-mongo-template-nosqli-vc\n", 1)[0] first = text.split(" - name: budibase-mongo-template-nosqli-vc\n", 1)[0]
assert hashlib.sha256(first.encode()).hexdigest() == FIRST_RUN_ENTRIES assert hashlib.sha256(first.encode()).hexdigest() == FIRST_RUN_ENTRIES
run, manifests = reconciler.load_run(RUN) run, manifests = reconciler.load_run(RUN)
old, new, remembered = run.tasks[:60], run.tasks[60:90], run.tasks[90:] old, new, appended = run.tasks[:60], run.tasks[60:90], run.tasks[90:]
assert [e.name.rsplit("-", 1)[1] for e in appended] == ["features", "remember"] * 15
remembered = appended[1::2]
assert [e.name.rsplit("-", 1)[1] for e in old] == ["facts", "va", "vb", "agree"] * 15 assert [e.name.rsplit("-", 1)[1] for e in old] == ["facts", "va", "vb", "agree"] * 15
assert [e.name.rsplit("-", 1)[1] for e in new] == ["vc", "final"] * 15 assert [e.name.rsplit("-", 1)[1] for e in new] == ["vc", "final"] * 15
for vc, final in zip(new[::2], new[1::2], strict=True): for vc, final in zip(new[::2], new[1::2], strict=True):
@@ -112,9 +115,15 @@ def test_tiebreak_appends_thirty_tasks_and_keeps_the_first_sixty_byte_identical(
for entry in (vc, final): for entry in (vc, final):
name = reconciler._ax_name(run.metadata.name, entry.name, limit=reconciler.TASK_NAME_LIMIT) name = reconciler._ax_name(run.metadata.name, entry.name, limit=reconciler.TASK_NAME_LIMIT)
assert len(name) <= reconciler.TASK_NAME_LIMIT assert len(name) <= reconciler.TASK_NAME_LIMIT
for entry in appended[::2]: # learned-decision-engine task 1: model-free, the chain's artifacts in, records out
case = entry.name.removesuffix("-features")
assert entry.inputs["agreed"] == f"{case}-final/agreed.json" and entry.outputs == ("features.jsonl", "summary.json")
assert entry.budget is not None and (entry.budget.usd, entry.budget.calls) == (0, 0)
assert reconciler.MODEL_ANNOTATION not in manifests.tasks[entry.task].metadata.annotations
for entry in remembered: # harnessx-removal task 4: appended after the tie-break, model-free for entry in remembered: # harnessx-removal task 4: appended after the tie-break, model-free
case = entry.name.removesuffix("-remember") case = entry.name.removesuffix("-remember")
assert entry.inputs["agreed"] == f"{case}-final/agreed.json" and entry.inputs["pass_c"] == f"{case}-vc/units.jsonl" assert entry.inputs["agreed"] == f"{case}-final/agreed.json" and entry.inputs["pass_c"] == f"{case}-vc/units.jsonl"
assert entry.inputs["features"] == f"{case}-features/features.jsonl"
assert entry.budget is not None and (entry.budget.usd, entry.budget.calls) == (0, 0) assert entry.budget is not None and (entry.budget.usd, entry.budget.calls) == (0, 0)
assert reconciler.MODEL_ANNOTATION not in manifests.tasks[entry.task].metadata.annotations assert reconciler.MODEL_ANNOTATION not in manifests.tasks[entry.task].metadata.annotations
assert len(reconciler._ax_name(run.metadata.name, entry.name, limit=reconciler.TASK_NAME_LIMIT)) <= reconciler.TASK_NAME_LIMIT assert len(reconciler._ax_name(run.metadata.name, entry.name, limit=reconciler.TASK_NAME_LIMIT)) <= reconciler.TASK_NAME_LIMIT
+13 -6
View File
@@ -169,15 +169,18 @@ def test_fix_ranges_new_side_are_the_fixed_pins_lines(tmp_path: Path) -> None:
def test_every_case_ends_in_remember_and_the_loop_is_model_free(tmp_path: Path) -> None: def test_every_case_ends_in_remember_and_the_loop_is_model_free(tmp_path: Path) -> None:
plain = render([_case()], _templates(), "validation-46", "t", population="population-v2", split="validation") plain = render([_case()], _templates(), "validation-46", "t", population="population-v2", split="validation")
run, _ = reconciler.load_run(_write(tmp_path / "plain", plain)) run, _ = reconciler.load_run(_write(tmp_path / "plain", plain))
assert [e.name.removeprefix(f"{CASE}-") for e in run.tasks] == [*CHAIN[:4], "vc", "final", "remember"] assert [e.name.removeprefix(f"{CASE}-") for e in run.tasks] == [*CHAIN[:4], "vc", "final", "features", "remember"]
remember = run.task(f"{CASE}-remember") remember = run.task(f"{CASE}-remember")
assert remember.inputs["agreed"] == f"{CASE}-final/agreed.json" and "alerts" not in remember.inputs assert remember.inputs["agreed"] == f"{CASE}-final/agreed.json" and "alerts" not in remember.inputs
assert set(remember.producers) == {f"{CASE}-{step}" for step in ("facts", "va", "vb", "vc", "final")} assert set(remember.producers) == {f"{CASE}-{step}" for step in ("facts", "va", "vb", "vc", "final", "features")}
assert set(run.task(f"{CASE}-features").producers) == {f"{CASE}-{step}" for step in ("facts", "va", "vb", "vc", "final")}
files = render([_case(fixed_ranges={"x.py": [(1, 2)]})], _templates(), "validation-46", "t", population="p", split="s", loop=LOOP) files = render([_case(fixed_ranges={"x.py": [(1, 2)]})], _templates(), "validation-46", "t", population="p", split="s", loop=LOOP)
run, manifests = reconciler.load_run(_write(tmp_path / "loop", files)) run, manifests = reconciler.load_run(_write(tmp_path / "loop", files))
names = [e.name for e in run.tasks] names = [e.name for e in run.tasks]
assert names[:6] == [f"{CASE}-{step}" for step in CHAIN] and names[6:] == [f"{CASE}-alerts", f"{CASE}-remember", *LOOP_TASKS] assert names[:6] == [f"{CASE}-{step}" for step in CHAIN]
assert names[6:] == [f"{CASE}-alerts", f"{CASE}-features", f"{CASE}-remember", *LOOP_TASKS]
assert run.task(f"{CASE}-features").inputs["alerts"] == f"{CASE}-alerts/alerts.jsonl", "features read the alerts"
assert run.task(f"{CASE}-remember").inputs["alerts"] == f"{CASE}-alerts/alerts.jsonl" assert run.task(f"{CASE}-remember").inputs["alerts"] == f"{CASE}-alerts/alerts.jsonl"
assert run.task(f"{CASE}-remember").inputs["alerts_summary"] == f"{CASE}-alerts/summary.json", "the coverage travels too" assert run.task(f"{CASE}-remember").inputs["alerts_summary"] == f"{CASE}-alerts/summary.json", "the coverage travels too"
measure_inputs = {f"remember_{CASE}": f"{CASE}-remember/memory.jsonl", "snapshot": "memory-snapshot/rows.jsonl"} measure_inputs = {f"remember_{CASE}": f"{CASE}-remember/memory.jsonl", "snapshot": "memory-snapshot/rows.jsonl"}
@@ -262,6 +265,7 @@ def test_the_loop_runs_as_one_plane_run_and_the_gate_rejects_a_demotion_that_cos
fake.script({ fake.script({
f"{CASE}-alerts": {"exec": {"env": {"PYTHONPATH": SRC, "OUSAST_WORKSPACE_DIR": str(vulnerable), "OUSAST_FIXED_DIR": str(fixed), f"{CASE}-alerts": {"exec": {"env": {"PYTHONPATH": SRC, "OUSAST_WORKSPACE_DIR": str(vulnerable), "OUSAST_FIXED_DIR": str(fixed),
"OUSAST_INPUT_CASE": str(tmp_path / "case.json")}}}, "OUSAST_INPUT_CASE": str(tmp_path / "case.json")}}},
f"{CASE}-features": {"exec": {"env": {"PYTHONPATH": SRC, "OUSAST_WORKSPACE_DIR": str(vulnerable)}}},
f"{CASE}-remember": {"exec": {"env": {"PYTHONPATH": SRC}}}, f"{CASE}-remember": {"exec": {"env": {"PYTHONPATH": SRC}}},
"loop-measure": {"exec": {"env": {"PYTHONPATH": SRC}}}, "loop-measure": {"exec": {"env": {"PYTHONPATH": SRC}}},
"loop-propose": {"exec": {"env": project_env}}, "loop-propose": {"exec": {"env": project_env}},
@@ -271,10 +275,10 @@ def test_the_loop_runs_as_one_plane_run_and_the_gate_rejects_a_demotion_that_cos
assert cli.main(["plane", "run", str(manifest), "--ax", str(fake.ax)]) == 0, (fake.root / "ax.log").read_text()[-3000:] assert cli.main(["plane", "run", str(manifest), "--ax", str(fake.ax)]) == 0, (fake.root / "ax.log").read_text()[-3000:]
statuses = {name: info["status"] for name, info in json.loads((base / "state.json").read_text())["tasks"].items()} statuses = {name: info["status"] for name, info in json.loads((base / "state.json").read_text())["tasks"].items()}
assert set(statuses.values()) == {"done"} and len(statuses) == 12 assert set(statuses.values()) == {"done"} and len(statuses) == 13
assert json.loads((base / "memory-snapshot" / "index.json").read_text())["kept"] == 4, "seeded on the host, not run" assert json.loads((base / "memory-snapshot" / "index.json").read_text())["kept"] == 4, "seeded on the host, not run"
delivered = [name for _, name in fake.events("delivered")] delivered = [name for _, name in fake.events("delivered")]
assert delivered == [f"{CASE}-alerts", f"{CASE}-remember", "loop-measure", "loop-propose", "loop-improve"] assert delivered == [f"{CASE}-alerts", f"{CASE}-features", f"{CASE}-remember", "loop-measure", "loop-propose", "loop-improve"]
measure = json.loads((base / "loop-measure" / "measure.json").read_text()) measure = json.loads((base / "loop-measure" / "measure.json").read_text())
assert measure["run_metrics"]["declared_sites_agreed"] == 1 and measure["run_metrics"]["candidates"] == 1 assert measure["run_metrics"]["declared_sites_agreed"] == 1 and measure["run_metrics"]["candidates"] == 1
@@ -297,10 +301,13 @@ def test_the_loop_runs_as_one_plane_run_and_the_gate_rejects_a_demotion_that_cos
report = reconciler.attribution("validation-46") report = reconciler.attribution("validation-46")
for row in report["tasks"]: for row in report["tasks"]:
if row["task"] in (f"{CASE}-alerts", f"{CASE}-remember", *LOOP_TASKS): if row["task"] in (f"{CASE}-alerts", f"{CASE}-features", f"{CASE}-remember", *LOOP_TASKS):
assert row["model"] is None and row["usd"] in (0, None) and row["calls"] in (0, None), row assert row["model"] is None and row["usd"] in (0, None) and row["calls"] in (0, None), row
ingested = FileStore(store.root) ingested = FileStore(store.root)
outcomes = ingested.rows(kind="proposal_outcome") outcomes = ingested.rows(kind="proposal_outcome")
assert {r.row["outcome"] for r in outcomes} == {"reverted"} and len(outcomes) == 2, "one per (repo, pin) of the evidence" assert {r.row["outcome"] for r in outcomes} == {"reverted"} and len(outcomes) == 2, "one per (repo, pin) of the evidence"
[fixed_alert] = ingested.rows(repo="example.com/reserved/lmdeploy", pin=FIXED, kind="alert") [fixed_alert] = ingested.rows(repo="example.com/reserved/lmdeploy", pin=FIXED, kind="alert")
features = {r.row["candidate"]: r.row for r in ingested.rows(kind="features")}
assert set(features) == {"lmdeploy/vl/media/connection.py::_load_http_url"}, "no fixed-pin alert became a candidate"
assert all(r["pin"] == VULNERABLE and "in_fix_range" not in json.dumps(r["x"]) for r in features.values())
assert fixed_alert.row["in_fix_range"] is True and fixed_alert.row["function"] == "load" assert fixed_alert.row["in_fix_range"] is True and fixed_alert.row["function"] == "load"
+1 -1
View File
@@ -19,7 +19,7 @@ from openultrasast.ruleset import (
def test_default_ruleset_loads_as_data() -> None: def test_default_ruleset_loads_as_data() -> None:
rules = load_ruleset(DEFAULT_RULESET_DIR) rules = load_ruleset(DEFAULT_RULESET_DIR)
php = [rule for rule in rules if rule.languages == ("php",)] php = [rule for rule in rules if rule.languages == ("php",)]
assert len(rules) == 32 + len(php) and len(php) == 13 assert len(rules) == 32 + len(php) and len(php) == 14 # php-unserialize split in two (decision-engine task 2)
assert all(rule.status == "enabled" for rule in rules if rule not in php), "PHP rules carry their measured status" assert all(rule.status == "enabled" for rule in rules if rule not in php), "PHP rules carry their measured status"
assert all(not hasattr(rule, "severity") for rule in rules) # severity is policy-governed, not rule-local assert all(not hasattr(rule, "severity") for rule in rules) # severity is policy-governed, not rule-local
+166
View File
@@ -0,0 +1,166 @@
"""Framework knowledge as tagged priors (learned-decision-engine task 2; Req 8.2, design section 2).
The lint: no untagged entry of ``ruleset/semantic/*.toml`` or of a quick ``rules.toml`` contains a symbol of
``ruleset/frameworks.toml`` (a symbol of a framework or library of the entry's language, matched as a whole token;
a quick rule's pattern is read with its regex escapes removed). ``priors="off"`` removes exactly the tagged entries
and activates the language-level fallbacks of mixed quick rules; the default ``"all"`` is today's ruleset.
"""
from __future__ import annotations
import re
import tomllib
from pathlib import Path
import pytest
from openultrasast.ruleset import DEFAULT_RULESET_DIR, load_ruleset
from openultrasast.ruleset.frameworks import FrameworksError, framework_ids, load_frameworks, symbol_in
from openultrasast.semantic.facts import DEFAULT_FACTS_DIR, load_facts
TABLES = ("source", "sink", "sanitizer", "dispatch")
LANGUAGE_FAMILY = {"typescript": "javascript", "cpp": "c"}
def _lexicon(language: str) -> dict[str, tuple[str, ...]]:
wanted = LANGUAGE_FAMILY.get(language, language)
return {row.id: row.symbols for row in load_frameworks() if wanted in {LANGUAGE_FAMILY.get(lang, lang) for lang in row.languages}}
def _texts(item: dict[str, object]) -> list[str]:
keys = ("patterns", "calls", "register", "apply", "returns", "request_arguments", "origin_anchors")
return [str(text) for key in keys for text in item.get(key, []) or []] # type: ignore[union-attr]
def _semantic_entries() -> list[tuple[str, str, dict[str, object]]]:
rows = []
for path in sorted(DEFAULT_FACTS_DIR.glob("*.toml")):
data = tomllib.loads(path.read_text())
rows += [
(str(data.get("language") or path.stem), f"{path.name} {table} {item['id']}", item)
for table in TABLES
for item in data.get(table, [])
]
return rows
def _rules() -> list[dict[str, object]]:
return [rule for path in sorted(DEFAULT_RULESET_DIR.glob("*/rules.toml")) for rule in tomllib.loads(path.read_text()).get("rule", [])]
def _unescaped(pattern: str) -> str:
return re.sub(r"\\(.)", r"\1", pattern)
def test_no_untagged_entry_names_a_framework_symbol() -> None:
offenders: list[str] = []
for language, where, item in _semantic_entries():
if item.get("framework") or item.get("library"):
continue
for framework, symbols in _lexicon(language).items():
hits = sorted({s for s in symbols for text in _texts(item) if symbol_in(s, text)})
if hits:
offenders.append(f"{where}: {framework} {hits}")
for rule in _rules():
if rule.get("framework") or rule.get("library"):
continue
text = _unescaped(str(rule["pattern"]))
for language in rule.get("languages") or []: # type: ignore[union-attr]
for framework, symbols in _lexicon(str(language)).items():
hits = sorted(s for s in symbols if symbol_in(s, text))
if hits:
offenders.append(f"rule {rule['rule_id']}: {framework} {hits}")
assert not offenders, "untagged framework knowledge (tag it, or split the entry):\n" + "\n".join(sorted(set(offenders)))
def test_the_lint_catches_an_untagged_framework_entry() -> None:
item = {"id": "x", "calls": ["$wpdb->get_var", "mysqli_query"]}
assert any(symbol_in(s, t) for s in _lexicon("php")["wordpress"] for t in _texts(item))
assert not any(symbol_in(s, "maybe_unserialized_value") for s in ("maybe_unserialize",)), "whole tokens only"
assert symbol_in("wpdb->", _unescaped(r"\$(?!wpdb->)[A-Za-z_]")), "a symbol inside an exclusion still counts"
def test_every_tag_names_a_listed_id_and_the_lexicon_is_well_formed() -> None:
ids = framework_ids()
tags = [item.get("framework") or item.get("library") for _, _, item in _semantic_entries()]
tags += [rule.get("framework") or rule.get("library") for rule in _rules()]
assert {t for t in tags if t} <= ids
assert {"wordpress", "flask", "django", "express", "spring", "jakarta-servlet"} <= ids
for row in load_frameworks():
assert row.symbols and row.languages and row.kind in ("framework", "library"), row.id
def _signature(facts: object) -> set[tuple[str, str, str, tuple[str, ...]]]:
rows = set()
for kind in ("sources", "sinks", "sanitizers", "dispatches"):
for fact in getattr(facts, kind):
texts = tuple(getattr(fact, "patterns", ()) or getattr(fact, "calls", ()) or getattr(fact, "register", ()))
rows.add((kind, fact.language, fact.id, texts))
return rows
def test_priors_off_removes_exactly_the_tagged_entries() -> None:
every, off = load_facts(), load_facts(priors="off")
assert load_facts(priors="all") is every, "the default is today's facts, memoised"
tagged = {
row for row in _signature(every)
for fact in getattr(every, row[0]) if (fact.language, fact.id) == row[1:3]
and tuple(getattr(fact, "patterns", ()) or getattr(fact, "calls", ()) or getattr(fact, "register", ())) == row[3]
and (fact.framework or fact.library)
} # fmt: skip
assert tagged, "the semantic facts carry tagged priors"
assert _signature(every) - _signature(off) == tagged and _signature(off) <= _signature(every)
assert off.layouts == every.layouts, "layouts are product filters, not detection"
rules_all = {r.rule_id: r for r in load_ruleset(DEFAULT_RULESET_DIR)}
rules_off = {r.rule_id: r for r in load_ruleset(DEFAULT_RULESET_DIR, priors="off")}
tagged_rules = {rid for rid, r in rules_all.items() if r.framework or r.library}
fallbacks = {rid for rid, r in rules_off.items() if r.fallback_for}
assert tagged_rules and set(rules_all) - set(rules_off) == tagged_rules
assert set(rules_off) - set(rules_all) == fallbacks and {rules_off[f].fallback_for for f in fallbacks} <= tagged_rules
assert not any(r.fallback_for for r in rules_all.values()), "a fallback is inactive while its prior is on"
def test_split_entries_keep_their_language_level_calls() -> None:
off = load_facts(priors="off")
def calls(language: str, kind: str, ident: str) -> set[str]:
scoped = off.for_language(language)
return {c for f in getattr(scoped, kind) if f.id == ident for c in (getattr(f, "calls", ()) or getattr(f, "patterns", ()))}
assert calls("php", "sinks", "unserialize") == {"unserialize"}
assert calls("php", "sanitizers", "sql_escape") == {"mysqli_real_escape_string", "mysql_real_escape_string", "pg_escape_string"}
assert calls("php", "sanitizers", "coerce") == {"intval", "floatval", "filter_var", "settype"}
assert calls("php", "sinks", "wpdb") == set() and not off.for_language("php").dispatches
assert calls("python", "sinks", "pickle.loads") == {"pickle.loads", "pickle.load", "marshal.loads", "cPickle.loads"}
assert calls("python", "sinks", "path.open") == {"open", "os.remove", "os.unlink", "shutil.copy", "shutil.move", "shutil.rmtree"}
assert calls("python", "sinks", "outbound.request") == {"urlopen", "urlretrieve"} and calls("python", "sources", "request") == set()
assert calls("javascript", "sinks", "outbound.request") == {"fetch", "http.get", "http.request", "https.get", "https.request"}
assert calls("javascript", "sinks", "prototype.merge") == {"Object.assign"}
assert calls("javascript", "sanitizers", "html.escape") == {"encodeURIComponent", "encodeURI", "escape"}
assert calls("java", "sinks", "outbound.request") == {"openConnection", "openStream"}
assert calls("java", "sanitizers", "java.escape") == {"getCanonicalPath", "normalize", "URLEncoder.encode"}
assert calls("java", "sources", "servlet") == {"readLine"}
rules = {r.rule_id: r for r in load_ruleset(DEFAULT_RULESET_DIR, priors="off")}
assert "php-unserialize" in rules and "php-maybe-unserialize" not in rules
assert re.search(rules["php-unserialize"].pattern, "$x = unserialize($_POST['a']);")
assert not re.search(rules["php-unserialize"].pattern, "$x = maybe_unserialize($_POST['a']);")
assert re.search(rules["python-unsafe-deserialization-language"].pattern, "pickle.loads(data)")
assert not re.search(rules["python-unsafe-deserialization-language"].pattern, "yaml.load(data)")
def test_a_chosen_prior_keeps_only_its_own_entries() -> None:
wordpress = load_facts(priors=frozenset({"wordpress"}))
assert {c for f in wordpress.for_language("php").sinks if f.id == "wpdb" for c in f.calls} >= {"$wpdb->get_var"}
assert not any(f.framework == "flask" for f in wordpress.sources)
rules = {r.rule_id for r in load_ruleset(DEFAULT_RULESET_DIR, priors=frozenset({"wordpress"}))}
assert "php-wpdb-sql-composition" in rules and "python-ssti" not in rules and "python-unsafe-deserialization-language" in rules
with pytest.raises(FrameworksError, match="unknown priors"):
load_facts(priors=frozenset({"no-such-framework"}))
with pytest.raises(FrameworksError):
load_ruleset(DEFAULT_RULESET_DIR, priors="none") # type: ignore[arg-type]
def test_a_tag_outside_frameworks_toml_is_refused(tmp_path: Path) -> None:
(tmp_path / "x.toml").write_text('language = "php"\n[[sink]]\nid = "s"\nframework = "nope"\ncwe = "CWE-89"\ncalls = ["q"]\n')
with pytest.raises(ValueError, match="not a framework id"):
load_facts(tmp_path)