From d1896cb8e73be258f9ec706a84d55fc8aea18c98 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:38:04 +0200 Subject: [PATCH 1/2] feat(learn): candidate feature schema and builders (decision-engine task 1) learn/schema.py is the closed, typed allow-list of candidate features: 66 features over 11 instruments (language one-hot, quick rules with 23 mechanism buckets and a prior count, engine, repo-facts, source span, entry-point distance, roles, delta, verify, agree, model sinks), two profiles (static 57; plane adds verify, agree and model sinks for 66), schema_version and a feature-set digest. No repository, path, file, function, rule or commit identity is a feature; site_match, in_fix_range, fixed-pin alerts, case.json fields and costs are refused. Missing is not zero: each record carries per-instrument state (ran | none | failed | not_applicable), and every feature of an instrument that did not run is null. learn/features.py builds records on the host (build_for_scan, whole repository or base..head delta); plane/tasks/features.py builds them per case in a Run from the delivered artifacts, dropping site_match and in_fix_range on read and ignoring fixed-pin alerts. The generator adds a model-free `features` entry per case before `remember`, which stores the records as `features` rows (re-validated). memory KINDS gains features, label, decision, experiment, arm_outcome and experiment_result. The committed validation-46 manifests are regenerated (entries appended; the first Run's entries are unchanged). The runner image must be rebuilt and re-pinned before a Run executes the new task. Proof on real artifacts (scratch store, not committed): the recorded validation-46 run gives 75 feature records over 15 cases (43 verify candidates plus vulnerable-pin alerts), each case's vulnerable pin exported and read (0.5k-9k files); remember stored 75 features rows beside 43 verdict, 84 alert, 61 unit_cost, 15 facts and 8 coverage rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo --- .kiro/specs/learned-decision-engine/tasks.md | 2 +- .../measurements/2026-09-08-module-audit.json | 58 +- plane/runs/validation-46.yaml | 285 +++++++++ plane/tasks/features.yaml | 18 + plane/tasks/validation-46.yaml | 345 +++++++++++ src/openultrasast/learn/__init__.py | 5 + src/openultrasast/learn/features.py | 540 ++++++++++++++++++ src/openultrasast/learn/schema.py | 258 +++++++++ src/openultrasast/model/audit.py | 1 + src/openultrasast/plane/generate.py | 41 +- src/openultrasast/plane/memory.py | 12 +- src/openultrasast/plane/tasks/features.py | 259 +++++++++ src/openultrasast/plane/tasks/remember.py | 25 +- tests/test_learn_schema.py | 196 +++++++ tests/test_plane_features.py | 83 +++ tests/test_plane_increment.py | 13 +- tests/test_plane_loop.py | 19 +- 17 files changed, 2129 insertions(+), 31 deletions(-) create mode 100644 plane/tasks/features.yaml create mode 100644 src/openultrasast/learn/__init__.py create mode 100644 src/openultrasast/learn/features.py create mode 100644 src/openultrasast/learn/schema.py create mode 100644 src/openultrasast/plane/tasks/features.py create mode 100644 tests/test_learn_schema.py create mode 100644 tests/test_plane_features.py diff --git a/.kiro/specs/learned-decision-engine/tasks.md b/.kiro/specs/learned-decision-engine/tasks.md index 52eab53..8842d68 100644 --- a/.kiro/specs/learned-decision-engine/tasks.md +++ b/.kiro/specs/learned-decision-engine/tasks.md @@ -5,7 +5,7 @@ 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 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 builder), new memory `KINDS`, `plane/tasks/features.py` and the `remember` input; tests enforcing the allow-list and the exclusion of label-carrying fields. diff --git a/benchmarks/measurements/2026-09-08-module-audit.json b/benchmarks/measurements/2026-09-08-module-audit.json index feb8686..8a2c0e7 100644 --- a/benchmarks/measurements/2026-09-08-module-audit.json +++ b/benchmarks/measurements/2026-09-08-module-audit.json @@ -1,7 +1,7 @@ { - "modules": 135, + "modules": 139, "counts": { - "load_bearing": 128, + "load_bearing": 132, "standalone_capability": 7 }, "orphaned": [], @@ -346,6 +346,33 @@ "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", "classification": "load_bearing", @@ -361,6 +388,7 @@ "gate", "hunter_tools", "improve.evolve", + "learn.features", "map_gate", "model.scan", "pairs", @@ -386,6 +414,7 @@ "cli", "cpg.backend", "cpg.session", + "learn.features", "model.candidates", "model.config_value", "model.dominance", @@ -401,6 +430,7 @@ "model.taint", "plane.budget", "plane.generate", + "plane.tasks.features", "plane.tasks.repo_facts", "preprocess", "push.assistance", @@ -626,10 +656,12 @@ "reason": "reachable from gate, map_gate, pair_gate or the CLI", "importers": [ "cli", + "learn.features", "model.candidates", "model.report", "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)", "importers": [ "cli", + "learn.features", "plane.engine_alerts", "plane.generate", "plane.memory", "plane.tasks.agree", "plane.tasks.alerts", + "plane.tasks.features", "plane.tasks.remember" ] }, @@ -787,9 +821,17 @@ "classification": "load_bearing", "reason": "harnessx-removal Req 6.3: quick-mode alerts on a case's vulnerable and fixed pins, model-free", "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", "classification": "load_bearing", @@ -811,8 +853,10 @@ "classification": "load_bearing", "reason": "ai-service-plane Req 5.1/5.2: source-only functions and callers per file; runner integration pending", "importers": [ + "learn.features", "plane.generate", "plane.tasks.alerts", + "plane.tasks.features", "plane.tasks.remember" ] }, @@ -872,6 +916,7 @@ "hunter_tools", "improve.evolve", "index", + "learn.features", "map_gate", "mapping", "model.candidates", @@ -879,6 +924,7 @@ "model.scan", "pairs", "plane.tasks.alerts", + "plane.tasks.features", "plane.tasks.repo_facts", "push.runner", "rank", @@ -1148,8 +1194,10 @@ "improve.evolve", "improve.memory", "improve.validator", + "learn.features", "pairs", "plane.tasks.alerts", + "plane.tasks.features", "plane.tasks.loop", "stage_processors" ] @@ -1229,6 +1277,7 @@ "cli", "cpg.capability", "hunter_tools", + "learn.features", "mapping", "model.candidates", "model.layout", @@ -1283,6 +1332,7 @@ "importers": [ "cli", "hunter_tools", + "learn.features", "mapping", "model.candidates", "model.layout", diff --git a/plane/runs/validation-46.yaml b/plane/runs/validation-46.yaml index a6ea9a2..f9ce7dc 100644 --- a/plane/runs/validation-46.yaml +++ b/plane/runs/validation-46.yaml @@ -939,6 +939,24 @@ spec: budget: usd: 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 task: remember-budibase-mongo-template-nosqli inputs: @@ -951,11 +969,30 @@ spec: 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 + features: budibase-mongo-template-nosqli-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-cct-studio-server-cmdi inputs: @@ -968,11 +1005,30 @@ spec: 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 + features: cct-studio-server-cmdi-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-contextforge-prompt-ssti inputs: @@ -985,11 +1041,30 @@ spec: pass_c: contextforge-prompt-ssti-vc/units.jsonl summary_c: contextforge-prompt-ssti-vc/summary.json agreed: contextforge-prompt-ssti-final/agreed.json + features: contextforge-prompt-ssti-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-froxlor-admin-ip-second-order-sqli inputs: @@ -1002,11 +1077,30 @@ spec: 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 + features: froxlor-admin-ip-second-order-sqli-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-fuxa-tdengine-escape-sqli inputs: @@ -1019,11 +1113,30 @@ spec: 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 + features: fuxa-tdengine-escape-sqli-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-lmdeploy-media-url-ssrf inputs: @@ -1036,11 +1149,30 @@ spec: 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 + features: lmdeploy-media-url-ssrf-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-medplum-external-auth-open-redirect inputs: @@ -1053,11 +1185,30 @@ spec: 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 + features: medplum-external-auth-open-redirect-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-pgadmin-maintenance-sqli inputs: @@ -1070,11 +1221,30 @@ spec: pass_c: pgadmin-maintenance-sqli-vc/units.jsonl summary_c: pgadmin-maintenance-sqli-vc/summary.json agreed: pgadmin-maintenance-sqli-final/agreed.json + features: pgadmin-maintenance-sqli-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-qwed-sympy-eval inputs: @@ -1087,11 +1257,30 @@ spec: pass_c: qwed-sympy-eval-vc/units.jsonl summary_c: qwed-sympy-eval-vc/summary.json agreed: qwed-sympy-eval-final/agreed.json + features: qwed-sympy-eval-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-reactpress-findall-column-sqli inputs: @@ -1104,11 +1293,30 @@ spec: 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 + features: reactpress-findall-column-sqli-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-unleash-addon-webhook-ssrf inputs: @@ -1121,11 +1329,30 @@ spec: 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 + features: unleash-addon-webhook-ssrf-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-winml-cli-serve-cors inputs: @@ -1138,11 +1365,30 @@ spec: 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 + features: winml-cli-serve-cors-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-wp-directorist-avatar-ssrf inputs: @@ -1155,11 +1401,30 @@ spec: 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 + features: wp-directorist-avatar-ssrf-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-wp-gopay-log-filter-sqli inputs: @@ -1172,11 +1437,30 @@ spec: 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 + features: wp-gopay-log-filter-sqli-features/features.jsonl outputs: - memory.jsonl budget: usd: 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 task: remember-wp-groundhogg-confirm-redirect inputs: @@ -1189,6 +1473,7 @@ spec: 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 + features: wp-groundhogg-confirm-redirect-features/features.jsonl outputs: - memory.jsonl budget: diff --git a/plane/tasks/features.yaml b/plane/tasks/features.yaml new file mode 100644 index 0000000..598de3e --- /dev/null +++ b/plane/tasks/features.yaml @@ -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" diff --git a/plane/tasks/validation-46.yaml b/plane/tasks/validation-46.yaml index 52834c6..acb36c7 100644 --- a/plane/tasks/validation-46.yaml +++ b/plane/tasks/validation-46.yaml @@ -2236,6 +2236,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-budibase-mongo-template-nosqli atespace: default @@ -2261,6 +2284,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-cct-studio-server-cmdi atespace: default @@ -2286,6 +2332,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-contextforge-prompt-ssti atespace: default @@ -2311,6 +2380,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-froxlor-admin-ip-second-order-sqli atespace: default @@ -2336,6 +2428,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-fuxa-tdengine-escape-sqli atespace: default @@ -2361,6 +2476,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-lmdeploy-media-url-ssrf atespace: default @@ -2386,6 +2524,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-medplum-external-auth-open-redirect atespace: default @@ -2411,6 +2572,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-pgadmin-maintenance-sqli atespace: default @@ -2436,6 +2620,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-qwed-sympy-eval atespace: default @@ -2461,6 +2668,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-reactpress-findall-column-sqli atespace: default @@ -2486,6 +2716,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-unleash-addon-webhook-ssrf atespace: default @@ -2511,6 +2764,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-winml-cli-serve-cors atespace: default @@ -2536,6 +2812,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-wp-directorist-avatar-ssrf atespace: default @@ -2561,6 +2860,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-wp-gopay-log-filter-sqli atespace: default @@ -2586,6 +2908,29 @@ spec: --- apiVersion: ax.io/v1alpha1 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: name: remember-wp-groundhogg-confirm-redirect atespace: default diff --git a/src/openultrasast/learn/__init__.py b/src/openultrasast/learn/__init__.py new file mode 100644 index 0000000..862ec7a --- /dev/null +++ b/src/openultrasast/learn/__init__.py @@ -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. +""" diff --git a/src/openultrasast/learn/features.py b/src/openultrasast/learn/features.py new file mode 100644 index 0000000..76a9c95 --- /dev/null +++ b/src/openultrasast/learn/features.py @@ -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.+?) -> (?P.+?)(?: in \S+)? \(line") +_CALL = re.compile(r"(? 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. ```` 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"(?= 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 diff --git a/src/openultrasast/learn/schema.py b/src/openultrasast/learn/schema.py new file mode 100644 index 0000000..d4342ea --- /dev/null +++ b/src/openultrasast/learn/schema.py @@ -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.`` 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 diff --git a/src/openultrasast/model/audit.py b/src/openultrasast/model/audit.py index 71120bf..0c321f6 100644 --- a/src/openultrasast/model/audit.py +++ b/src/openultrasast/model/audit.py @@ -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.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.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.runner": "ai-service-plane Req 4.1-4.4: the ax-task-runner entrypoint (PID 1 of the task image); delivers the output directory", } diff --git a/src/openultrasast/plane/generate.py b/src/openultrasast/plane/generate.py index 3cffc7f..45733d5 100644 --- a/src/openultrasast/plane/generate.py +++ b/src/openultrasast/plane/generate.py @@ -15,7 +15,8 @@ under ``--plane`` (default ``plane/``), everything a Run over the set needs besi (``triage.json``, for the measurement); - ``tasks/.yaml``: per case, one Task per template (two for ``verify``: ``OUSAST_PASS=a|b``) binding the two Workspaces and naming the files as env; -- ``runs/.yaml``: the Run, per case ``facts -> verify a, verify b -> agree -> verify c -> final -> remember``, +- ``runs/.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' stems) the memory rows carry. @@ -86,7 +87,7 @@ CASE_PATH = "/workspace/case" INPUTS_PATH = "/workspace/inputs" FIXED_PATH = "/workspace/fixed" 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 , 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_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 -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) 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)]) +def _chain_inputs(case_id: str, alerts: bool) -> dict[str, str]: + """Every artifact of a case's chain -- facts, the three passes with their summaries, the final agree -- and, in a + loop, its alerts with their coverage.""" inputs = {"facts": f"{case_id}-facts/facts.json", "facts_summary": f"{case_id}-facts/summary.json"} 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"}) @@ -315,6 +313,28 @@ def _remember(item: CaseInputs, templates: Mapping[str, Task], population: str, if alerts: 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 + 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()} return task, entry @@ -382,7 +402,7 @@ def render( ) -> dict[str, str]: """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 - 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.""" header = f"# generated by `{command}`; do not edit, regenerate.\n" out: dict[str, str] = {} @@ -435,6 +455,9 @@ def render( out[f"workspaces/{fixed['metadata']['name']}.yaml"] = _dump(header, [fixed]) tasks.append(alerts_task) 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) tasks.append(remember_task) entries.append(remember_entry) diff --git a/src/openultrasast/plane/memory.py b/src/openultrasast/plane/memory.py index 5c8df05..841e2b6 100644 --- a/src/openultrasast/plane/memory.py +++ b/src/openultrasast/plane/memory.py @@ -1,8 +1,9 @@ """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 -of six kinds (``facts``, ``verdict``, ``unit_cost``, ``alert``, ``coverage``, ``proposal_outcome``) that ``remember`` -(:mod:`.tasks.remember`) derives from a run's delivered artifacts. Layout, identical for both backends:: +of the kinds in ``KINDS`` (``facts``, ``verdict``, ``unit_cost``, ``alert``, ``coverage``, ``features``, ...) that +``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 facts/.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" SPLIT_ANNOTATION = "openultrasast.io/split" 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") TAG_FIELDS = ("repo", "pin", "kind", "family", "run", "population", "split") MIN_FREE_BYTES = 1 << 30 diff --git a/src/openultrasast/plane/tasks/features.py b/src/openultrasast/plane/tasks/features.py new file mode 100644 index 0000000..4b8427b --- /dev/null +++ b/src/openultrasast/plane/tasks/features.py @@ -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()) diff --git a/src/openultrasast/plane/tasks/remember.py b/src/openultrasast/plane/tasks/remember.py index 867b89b..d501b12 100644 --- a/src/openultrasast/plane/tasks/remember.py +++ b/src/openultrasast/plane/tasks/remember.py @@ -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 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`, -`unit_cost`, `alert` and `coverage` (per language and pin: whether any rule could have fired there; the kind +`alerts.jsonl` with the alerts' `summary.json`, and the `features` task's `features.jsonl`, and writes `memory.jsonl` +-- 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 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), `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`, -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 `OUSAST_POPULATION`/`OUSAST_SPLIT` (the generator copies the Run's `openultrasast.io/population` and `openultrasast.io/split` annotations). Exit 0 done, 2 failed. @@ -38,6 +39,7 @@ from typing import Any 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 .repo_facts import _digest @@ -105,8 +107,10 @@ def rows_for( agreed: Mapping[str, Any] | None, alerts: Sequence[Mapping[str, Any]] = (), alerts_summary: Mapping[str, Any] | None = None, + features: Sequence[Mapping[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]] = [] if facts is not None: 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 "") rows.append(ctx.row("alert", subject, **fields)) 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] @@ -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} alerts = _jsonl(base / f"{case}-alerts" / "alerts.jsonl") 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 @@ -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} agreed = _json(_path(env, "OUSAST_INPUT_AGREED")) 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) 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) diff --git a/tests/test_learn_schema.py b/tests/test_learn_schema.py new file mode 100644 index 0000000..fb69b72 --- /dev/null +++ b/tests/test_learn_schema.py @@ -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", {})}) diff --git a/tests/test_plane_features.py b/tests/test_plane_features.py new file mode 100644 index 0000000..facad63 --- /dev/null +++ b/tests/test_plane_features.py @@ -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 diff --git a/tests/test_plane_increment.py b/tests/test_plane_increment.py index 665d660..a3d0c11 100644 --- a/tests/test_plane_increment.py +++ b/tests/test_plane_increment.py @@ -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: 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"]} ax_names: set[str] = set() 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] assert hashlib.sha256(first.encode()).hexdigest() == FIRST_RUN_ENTRIES 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 new] == ["vc", "final"] * 15 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): name = reconciler._ax_name(run.metadata.name, entry.name, limit=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 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["features"] == f"{case}-features/features.jsonl" 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 len(reconciler._ax_name(run.metadata.name, entry.name, limit=reconciler.TASK_NAME_LIMIT)) <= reconciler.TASK_NAME_LIMIT diff --git a/tests/test_plane_loop.py b/tests/test_plane_loop.py index f99d97e..dda741c 100644 --- a/tests/test_plane_loop.py +++ b/tests/test_plane_loop.py @@ -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: plain = render([_case()], _templates(), "validation-46", "t", population="population-v2", split="validation") 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") 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) run, manifests = reconciler.load_run(_write(tmp_path / "loop", files)) 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_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"} @@ -262,6 +265,7 @@ def test_the_loop_runs_as_one_plane_run_and_the_gate_rejects_a_demotion_that_cos fake.script({ 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")}}}, + f"{CASE}-features": {"exec": {"env": {"PYTHONPATH": SRC, "OUSAST_WORKSPACE_DIR": str(vulnerable)}}}, f"{CASE}-remember": {"exec": {"env": {"PYTHONPATH": SRC}}}, "loop-measure": {"exec": {"env": {"PYTHONPATH": SRC}}}, "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:] 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" 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()) 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") 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 ingested = FileStore(store.root) 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" [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" From af81bccc70e09343e1ca0ae9151efcd803f61b83 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:49:05 +0200 Subject: [PATCH 2/2] feat(ruleset): tag framework-specific knowledge as optional priors (decision-engine task 2) ruleset/frameworks.toml lists 19 framework and library ids (wordpress, flask, django, express, spring, jakarta-servlet, react, vue and 11 libraries), each with its languages, dependency package names and a symbol lexicon used only by the lint and for stratification. Every framework/library entry of section 2 is tagged `framework`/`library`, and mixed entries are split so a tag covers a whole entry (split pieces keep the entry's id and flags, in place): semantic facts 38 tagged entries (wordpress 6, django 6, flask 4, express 4, jakarta-servlet 3, pyyaml 2, one each for spring, sqlalchemy, requests, httpx, lodash, node-http-clients, escape-html, sanitize-html, dompurify, esapi, commons-text, react, vue); the dispatch fact behind the hook-as-source path is wordpress; the PHP layout row is renamed php_layout and stays untagged (a product filter). Quick rules: 9 tagged (wordpress 6, flask, pyyaml, node-http-clients); php-unserialize is split into php-unserialize and php-maybe-unserialize; the lint also caught WordPress escapers in exclusions and axios/yaml.load in mixed rules, so those keep their ids as tagged rules and gain a `fallback_for` language-level rule (6) that is active only when the prior is off. load_facts and load_ruleset take priors ("all" by default -- today's scan --, "off", or a set of ids). tests/test_semantic_priors.py: the lint (no untagged entry contains a lexicon symbol of its language), "off" removes exactly the tagged entries and activates the fallbacks, split entries keep their language-level calls. Rule and layout counts in three existing tests follow the split and the rename. Unchanged defaults: test_benchmark_output_is_byte_identical_to_golden_baseline passes, and the quick findings of all 15 benchmark manifests (full StaticFinding records) are byte-identical before and after. priors=off on the same manifests: 61 -> 57 findings; the 4 lost are js-ssrf (axios), php-wpdb-sql-composition, python-ssti and python yaml.load; 5 PHP and 1 Python finding move to their fallback rule at the same location. Semantic calls with priors off: php 75/92, python 32/77, javascript 42/70, java 24/41, c 27/27 (the engine-side effect needs Joern and was not measured). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo --- .kiro/specs/learned-decision-engine/tasks.md | 2 +- .../measurements/2026-09-08-module-audit.json | 15 +- src/openultrasast/ruleset/frameworks.py | 126 +++++++++++++ src/openultrasast/ruleset/frameworks.toml | 160 +++++++++++++++++ .../ruleset/javascript/rules.toml | 15 ++ src/openultrasast/ruleset/php/rules.toml | 77 +++++++- src/openultrasast/ruleset/python/rules.toml | 16 ++ src/openultrasast/ruleset/semantic/java.toml | 37 +++- .../ruleset/semantic/javascript.toml | 94 ++++++++-- src/openultrasast/ruleset/semantic/php.toml | 32 +++- .../ruleset/semantic/python.toml | 79 ++++++++- src/openultrasast/ruleset/store.py | 32 +++- src/openultrasast/semantic/facts.py | 55 +++++- tests/test_layout_facts.py | 2 +- tests/test_php_quick_rules.py | 3 +- tests/test_ruleset.py | 2 +- tests/test_semantic_priors.py | 166 ++++++++++++++++++ 17 files changed, 872 insertions(+), 41 deletions(-) create mode 100644 src/openultrasast/ruleset/frameworks.py create mode 100644 src/openultrasast/ruleset/frameworks.toml create mode 100644 tests/test_semantic_priors.py diff --git a/.kiro/specs/learned-decision-engine/tasks.md b/.kiro/specs/learned-decision-engine/tasks.md index 8842d68..c47ce4b 100644 --- a/.kiro/specs/learned-decision-engine/tasks.md +++ b/.kiro/specs/learned-decision-engine/tasks.md @@ -11,7 +11,7 @@ the maintainer confirms its budget. allow-list and the exclusion of label-carrying fields. - _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=` 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. diff --git a/benchmarks/measurements/2026-09-08-module-audit.json b/benchmarks/measurements/2026-09-08-module-audit.json index 8a2c0e7..c7f524a 100644 --- a/benchmarks/measurements/2026-09-08-module-audit.json +++ b/benchmarks/measurements/2026-09-08-module-audit.json @@ -1,7 +1,7 @@ { - "modules": 139, + "modules": 140, "counts": { - "load_bearing": 132, + "load_bearing": 133, "standalone_capability": 7 }, "orphaned": [], @@ -1199,9 +1199,20 @@ "plane.tasks.alerts", "plane.tasks.features", "plane.tasks.loop", + "ruleset.store", + "semantic.facts", "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", "classification": "load_bearing", diff --git a/src/openultrasast/ruleset/frameworks.py b/src/openultrasast/ruleset/frameworks.py new file mode 100644 index 0000000..6a0fe28 --- /dev/null +++ b/src/openultrasast/ruleset/frameworks.py @@ -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 = ""`` or ``library = ""`` 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"(?"` or `library = ""` 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"] diff --git a/src/openultrasast/ruleset/javascript/rules.toml b/src/openultrasast/ruleset/javascript/rules.toml index c2d0c06..bfba692 100644 --- a/src/openultrasast/ruleset/javascript/rules.toml +++ b/src/openultrasast/ruleset/javascript/rules.toml @@ -104,8 +104,23 @@ status = "enabled" min_evidence_level = "static_corroboration" precision_estimate = 0.0 version = "1" +library = "node-http-clients" 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_id = "js-weak-hash" title = "JavaScript weak hash needs review" diff --git a/src/openultrasast/ruleset/php/rules.toml b/src/openultrasast/ruleset/php/rules.toml index 2a57747..b46a557 100644 --- a/src/openultrasast/ruleset/php/rules.toml +++ b/src/openultrasast/ruleset/php/rules.toml @@ -26,6 +26,7 @@ status = "enabled" min_evidence_level = "static_corroboration" precision_estimate = 0.032 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)''' [[rule]] @@ -54,8 +55,23 @@ status = "enabled" min_evidence_level = "static_corroboration" precision_estimate = 0.034 version = "1" +framework = "wordpress" pattern = '''(?])(?=(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 = '''(?])(?=(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_id = "php-sql-unquoted-interpolation" title = "PHP SQL string interpolates a variable into an unquoted position" @@ -66,8 +82,23 @@ status = "enabled" min_evidence_level = "static_corroboration" precision_estimate = 0.129 version = "1" +framework = "wordpress" pattern = '''(?])(?=(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 = '''(?])(?=(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 # CVE-2026-46364). Statement-scoped, so it spans lines up to the closing `);`. [[rule]] @@ -80,8 +111,23 @@ status = "enabled" min_evidence_level = "static_corroboration" precision_estimate = 0.109 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}?\);)''' +# 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) ------------------------------------------------------------------- [[rule]] @@ -120,8 +166,22 @@ status = "shadow" min_evidence_level = "static_corroboration" precision_estimate = 0.25 version = "1" +pattern = '''(?:$\\])(?:$\\])(?:$\\])(?)''' +# 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) --------------------------------------------------------- [[rule]] diff --git a/src/openultrasast/ruleset/python/rules.toml b/src/openultrasast/ruleset/python/rules.toml index b38fb41..ccde301 100644 --- a/src/openultrasast/ruleset/python/rules.toml +++ b/src/openultrasast/ruleset/python/rules.toml @@ -80,6 +80,7 @@ status = "enabled" min_evidence_level = "static_corroboration" precision_estimate = 0.0 version = "1" +framework = "flask" pattern = '''\b(render_template_string)\s*\(''' [[rule]] @@ -92,8 +93,23 @@ status = "enabled" min_evidence_level = "static_corroboration" precision_estimate = 0.0 version = "1" +library = "pyyaml" 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_id = "python-unsafe-eval" title = "Dynamic Python execution needs review" diff --git a/src/openultrasast/ruleset/semantic/java.toml b/src/openultrasast/ruleset/semantic/java.toml index 8e11a1b..39423b5 100644 --- a/src/openultrasast/ruleset/semantic/java.toml +++ b/src/openultrasast/ruleset/semantic/java.toml @@ -3,7 +3,13 @@ language = "java" [[source]] 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]] id = "exec" @@ -45,6 +51,7 @@ rule_ids = [] [[sink]] # CWE-79. Servlet response writers: what reaches these reaches the browser unescaped. id = "response.write" +framework = "jakarta-servlet" cwe = "CWE-79" calls = ["println", "print", "write", "getWriter", "setHeader", "addHeader"] rule_ids = [] @@ -53,13 +60,37 @@ rule_ids = [] # CWE-918 / CWE-601: outbound requests and redirects to a caller-supplied destination. id = "outbound.request" 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 = [] [[sanitizer]] # CWE-22 / CWE-79. The documented normalising and encoding APIs; ESAPI is what the OWASP suite itself uses. 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]] id = "parameterized_statement" diff --git a/src/openultrasast/ruleset/semantic/javascript.toml b/src/openultrasast/ruleset/semantic/javascript.toml index 5b6d7e2..e10c1b6 100644 --- a/src/openultrasast/ruleset/semantic/javascript.toml +++ b/src/openultrasast/ruleset/semantic/javascript.toml @@ -3,6 +3,7 @@ language = "javascript" [[source]] id = "req" +framework = "express" patterns = ["req.query", "req.body", "req.params", "req.cookies", "req.headers", "request.query"] [[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. id = "prototype.merge" 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 = [] [[sink]] # CWE-79. The documented "trusted HTML" sinks in the DOM and the major frameworks. id = "html.unescaped" 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 = [] [[sink]] @@ -79,14 +101,28 @@ rule_ids = [] # limit on this family rather than guessed at here. id = "html.response" 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 = [] [[sink]] # CWE-22. Node filesystem entry points that accept a caller-supplied path. id = "path.open" 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 = [] [[sink]] @@ -109,13 +145,6 @@ rule_ids = [] id = "outbound.request" 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 "fetch", # global fetch, node-fetch, cross-fetch "http.get", "http.request", @@ -125,10 +154,34 @@ calls = [ rule_ids = [] 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]] id = "redirect" cwe = "CWE-601" -calls = ["redirect", "location.assign", "location.replace"] +calls = ["location.assign", "location.replace"] rule_ids = [] prefix_fixes_origin = true @@ -146,7 +199,22 @@ calls = ["path.normalize", "path.resolve"] [[sanitizer]] # CWE-79. The standard encoders: the DOM/global URI encoders and the common escaping helpers. 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]] diff --git a/src/openultrasast/ruleset/semantic/php.toml b/src/openultrasast/ruleset/semantic/php.toml index f9988e7..de6de92 100644 --- a/src/openultrasast/ruleset/semantic/php.toml +++ b/src/openultrasast/ruleset/semantic/php.toml @@ -46,6 +46,7 @@ patterns = ["php://input"] # them itself, so they are not injection sinks. [[sink]] id = "wpdb" +framework = "wordpress" cwe = "CWE-89" calls = [ "$wpdb->get_var", @@ -124,6 +125,12 @@ rule_ids = [] # --- deserialization --------------------------------------------------------------------------------- +[[sink]] +id = "unserialize" +cwe = "CWE-502" +calls = ["unserialize"] +rule_ids = [] + [[sink]] # `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 @@ -134,8 +141,9 @@ rule_ids = [] # 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. id = "unserialize" +framework = "wordpress" cwe = "CWE-502" -calls = ["unserialize", "maybe_unserialize"] +calls = ["maybe_unserialize"] rule_ids = [] # --- untrusted destination --------------------------------------------------------------------------- @@ -167,7 +175,14 @@ calls = ["htmlspecialchars", "htmlentities", "html_entity_decode_safe"] # a cleanser. [[sanitizer]] 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 [[sanitizer]] @@ -193,6 +208,7 @@ guard = true [[sanitizer]] id = "wpdb_prepare" +framework = "wordpress" calls = ["$wpdb->prepare"] [[sanitizer]] @@ -205,7 +221,12 @@ calls = ["basename", "realpath"] [[sanitizer]] 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 # 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. [[dispatch]] id = "wordpress_hooks" +framework = "wordpress" register = ["add_action", "add_filter", "add_shortcode"] 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 @@ -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 # 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 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]] -id = "php_composer_and_wordpress" +id = "php_layout" vendored = ["vendor/", "node_modules/", "third_party/", "third-party/", "lib/vendor/"] tests = ["tests/", "test/", "spec/", "test-*.php", "*Test.php", "*.test.php", "*_test.php", "phpunit/"] diff --git a/src/openultrasast/ruleset/semantic/python.toml b/src/openultrasast/ruleset/semantic/python.toml index 4a07f87..a16ce1d 100644 --- a/src/openultrasast/ruleset/semantic/python.toml +++ b/src/openultrasast/ruleset/semantic/python.toml @@ -3,6 +3,7 @@ language = "python" [[source]] id = "request" +framework = "flask" patterns = [ "request.args", "request.form", @@ -22,6 +23,7 @@ patterns = [ # validated for type, not for trust. [[source]] id = "django-request" +framework = "django" patterns = [ "request.GET", "request.POST", @@ -54,9 +56,16 @@ rule_ids = ["python-unsafe-eval"] [[sink]] id = "pickle.loads" 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"] +[[sink]] +id = "pickle.loads" +library = "pyyaml" +cwe = "CWE-502" +calls = ["yaml.load"] +rule_ids = [] + [[sink]] id = "execute" cwe = "CWE-89" @@ -82,6 +91,7 @@ calls = ["ast.literal_eval"] [[sanitizer]] id = "yaml.safe_load" +library = "pyyaml" calls = ["yaml.safe_load", "safe_load"] [[sanitizer]] @@ -100,8 +110,16 @@ parameterized = true # 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. id = "orm.raw" +framework = "django" 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 = [] [[sink]] @@ -109,7 +127,21 @@ rule_ids = [] # send_file/send_from_directory and Django's FileResponse. id = "path.open" 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 = [] [[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 # caller-supplied *string* is. id = "html.unescaped" +framework = "flask" 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 = [] [[sink]] @@ -126,14 +166,40 @@ rule_ids = [] # destination. requests/urllib/httpx are the documented HTTP clients; redirect is Flask's and Django's. id = "outbound.request" 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 = [] prefix_fixes_origin = true [[sink]] id = "redirect" +framework = "django" cwe = "CWE-601" -calls = ["redirect", "HttpResponseRedirect", "url_for"] +calls = ["HttpResponseRedirect"] rule_ids = [] # Django's `reverse` builds an in-site path from a route NAME; request data can only fill its path segments. 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(...)`. [[sanitizer]] id = "url_allowlist" +framework = "django" calls = ["url_has_allowed_host_and_scheme", "is_safe_url"] guard = true diff --git a/src/openultrasast/ruleset/store.py b/src/openultrasast/ruleset/store.py index 33ccf9d..156259e 100644 --- a/src/openultrasast/ruleset/store.py +++ b/src/openultrasast/ruleset/store.py @@ -6,6 +6,8 @@ from collections.abc import Iterable from dataclasses import dataclass, replace from pathlib import Path +from .frameworks import Priors, kept, normalize_priors, read_tag + DEFAULT_RULESET_DIR = Path(__file__).parent VALID_STATUS = ("enabled", "shadow", "disabled") @@ -32,14 +34,23 @@ class PatternRule: min_evidence_level: str = "static_corroboration" precision_estimate: float = 0.0 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. Rules are returned sorted by ``rule_id`` for determinism. The optional ledger (``rule_policy.json``) overlays loop-owned ``status``/``min_evidence_level``/ ``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] = {} 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: raise RulesetError(f"duplicate rule_id {rule.rule_id!r} in {path}") 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) - 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)) @@ -74,6 +94,7 @@ def write_ruleset(path: Path, rules: Iterable[PatternRule]) -> None: f"min_evidence_level = {_s(rule.min_evidence_level)}", f"precision_estimate = {float(rule.precision_estimate)}", 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}'''", ] ) @@ -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}") if not 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( rule_id=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")), precision_estimate=float(item.get("precision_estimate", 0.0)), # type: ignore[arg-type] version=str(item.get("version", "1")), + framework=framework, + library=library, + fallback_for=str(item["fallback_for"]) if item.get("fallback_for") else None, ) diff --git a/src/openultrasast/semantic/facts.py b/src/openultrasast/semantic/facts.py index f116a82..f7117c4 100644 --- a/src/openultrasast/semantic/facts.py +++ b/src/openultrasast/semantic/facts.py @@ -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 = ""`` or ``library = ""`` (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 @@ -6,6 +11,8 @@ import tomllib from dataclasses import dataclass 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" _LANGUAGE_ALIASES = { @@ -31,6 +38,8 @@ class SourceFact: id: str patterns: tuple[str, ...] language: str + framework: str | None = None + library: str | None = None @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. prefix_fixes_origin: bool = False origin_anchors: tuple[str, ...] = () + framework: str | None = None + library: str | None = None @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 # the arm of a ternary it selects. Never a cleansing call on a path. guard: bool = False + framework: str | None = None + library: str | None = None @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 # reach an apply call's arguments, the hook half of the taint query follows it from there. request_arguments: tuple[str, ...] = () + framework: str | None = None + library: str | None = None @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: root = directory if directory is not None else DEFAULT_FACTS_DIR if not root.is_dir(): @@ -193,6 +227,7 @@ def _dispatches(value: object, language: str, path: Path) -> list[DispatchFact]: returns=_returning(item, path), value_arg=_value_arg(item, path), request_arguments=_request_arguments(item, path), + **_tags(item, path), ) ) return facts @@ -272,7 +307,7 @@ def _sources(value: object, language: str, path: Path) -> list[SourceFact]: patterns = _strings(item.get("patterns"), "patterns", path) if not 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 @@ -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, prefix_fixes_origin=_flag(item.get("prefix_fixes_origin"), "prefix_fixes_origin", path, ident), origin_anchors=_strings(item.get("origin_anchors"), "origin_anchors", path), + **_tags(item, path), ) ) 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, quoted_only=_flag(item.get("quoted_only"), "quoted_only", path, ident), guard=_flag(item.get("guard"), "guard", path, ident), + **_tags(item, path), ) ) return rows @@ -372,11 +409,19 @@ def _stat_of(path: Path) -> tuple[int, int]: 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 if not root.is_dir(): - return _load_facts_uncached(directory) + return with_priors(_load_facts_uncached(directory), chosen) key = _ruleset_state(root, "*.toml") if key not in _FACTS_MEMO: _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] diff --git a/tests/test_layout_facts.py b/tests/test_layout_facts.py index 24005e8..c8fc2d5 100644 --- a/tests/test_layout_facts.py +++ b/tests/test_layout_facts.py @@ -11,7 +11,7 @@ def test_layout_rows_load_and_match() -> None: from openultrasast.model.layout import is_test_path, is_vendored, layout_facts 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("vendor/autoload.php", rows) assert not is_vendored("classes/class.mail.php", rows) diff --git a/tests/test_php_quick_rules.py b/tests/test_php_quick_rules.py index e5677ef..0cfd257 100644 --- a/tests/test_php_quick_rules.py +++ b/tests/test_php_quick_rules.py @@ -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: 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(): assert rule.languages == ("php",) and rule.status in {"enabled", "shadow"} 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: measured = json.loads(MEASUREMENT.read_text())["rules"] 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.precision_estimate == measured[rule_id]["precision_lower_bound"], rule_id diff --git a/tests/test_ruleset.py b/tests/test_ruleset.py index 08bc05f..7de381b 100644 --- a/tests/test_ruleset.py +++ b/tests/test_ruleset.py @@ -19,7 +19,7 @@ from openultrasast.ruleset import ( def test_default_ruleset_loads_as_data() -> None: rules = load_ruleset(DEFAULT_RULESET_DIR) 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(not hasattr(rule, "severity") for rule in rules) # severity is policy-governed, not rule-local diff --git a/tests/test_semantic_priors.py b/tests/test_semantic_priors.py new file mode 100644 index 0000000..f4c21fd --- /dev/null +++ b/tests/test_semantic_priors.py @@ -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)