mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
916 lines
70 KiB
Markdown
916 lines
70 KiB
Markdown
# Design Document
|
|||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
Detection decisions move out of hand edits and into a trained, calibrated model. The instruments stay and emit
|
||
|
|
signals. A model-free feature builder turns those signals into one record per candidate. A label builder attaches
|
||
|
|
ground truth from sources we can defend. A small logistic model, evaluated only on repositories it never saw,
|
||
|
|
turns a record into a probability, and two thresholds on that probability (BLOCK, ADVISORY) are what `pre-push`,
|
||
|
|
`scan` and plane Runs report. Pipeline changes are variants in pre-registered, paired experiments.
|
||
|
|
|
||
|
|
Six facts from the repository and the recorded results shape the design:
|
||
|
|
|
||
|
|
- **The deployment target is a repository nobody trained on.** Maintainer: "the tool will never run against the
|
||
|
|
test cases it's being trained with. it's purpose is a repo safety net." (Req 7). The primary unit is a candidate
|
||
|
|
in changed code (`ousast pre-push`, base..head), and whole-repository `scan` is the secondary mode. Every
|
||
|
|
reported number therefore comes from folds grouped by repository across all corpora. Features are
|
||
|
|
repository-agnostic by an allow-list. Fit to a user's own repository is an opt-in layer outside the reported
|
||
|
|
numbers.
|
||
|
|
- **Labels are scarce, and full plane signals exist on 43 rows.** The only recorded plane run with
|
||
|
|
verify/agree/tie-break is `validation-46`: 15 cases, 43 candidates after triage, 19 of them with
|
||
|
|
`site_match` true, all from population v2
|
||
|
|
(`~/ousast-results/plane/validation-46/*-final/agreed.json`). The pair corpus is larger (427 pairs and 452
|
||
|
|
labelled findings in the catalog today, 317/342 in the 2026-09-30 baseline
|
||
|
|
`benchmarks/measurements/2026-09-30-harnessx-removal-baseline/pairs.json` `overall`). Measured by
|
||
|
|
repository rather than by row, it is 146 groups, and two families are nearly single-repository (section 3). A
|
||
|
|
model over verify signals cannot be trained today. The design trains on the signals every labelled row can
|
||
|
|
have, and harvests the rest at a stated cost.
|
||
|
|
- **The core install is PyYAML only** (`pyproject.toml:11`). numpy, scipy and scikit-learn are not installed in
|
||
|
|
`.venv`. Inference must be pure Python, and training should be too (section 4).
|
||
|
|
- **`pre-push` already has the decision seam.** `admit_candidates` (`push/policy.py:879`) applies one evidence
|
||
|
|
bar to `CandidateDelta`s with `novelty` (`push/policy.py:61-74`). Its capability registry is empty by design
|
||
|
|
(`push/policy.py:802-823`: "No shipped declaration is enabled"), so `pre-push` blocks nothing today. The
|
||
|
|
engine's operating point replaces the capability-eligibility reasons, and the structural reasons stay.
|
||
|
|
- **The memory store is the system of record and has the needed shape.** Rows are keyed by repository and pin,
|
||
|
|
with a closed `KINDS` tuple (`plane/memory.py:52`). There are two backends (FileStore, MinioStore) behind one
|
||
|
|
interface (`plane/memory.py:176-291`), content-addressed blobs (`put_facts`, `plane/memory.py:249-255`), and a
|
||
|
|
train-on-test `Guard` (`improve/memory.py:95-133`). Features, labels, decisions, model artifacts and experiment
|
||
|
|
outcomes become new row kinds and blob prefixes. No second store is added.
|
||
|
|
- **Hand-written framework knowledge is embedded in the detectors** (Req 8). Examples are WordPress `$wpdb`,
|
||
|
|
hooks and `maybe_unserialize` in `ruleset/semantic/php.toml` and `ruleset/php/rules.toml`, and Flask and
|
||
|
|
Django names in `ruleset/semantic/python.toml`. Section 2 tags each entry. Priors become optional features
|
||
|
|
that are off by default. Framework roles are inferred per repository instead.
|
||
|
|
|
||
|
|
Decisions in one place:
|
||
|
|
|
||
|
|
| Question | Decision |
|
||
|
|
| --- | --- |
|
||
|
|
| Candidate key | `(repo, pin, path, function, family)` in the row envelope only; `pin` is the head for a delta |
|
||
|
|
| Features | allow-listed, typed, repository-agnostic; explicit `null` + instrument state for missing; two profiles (`static`, `plane`) |
|
||
|
|
| Where features are built | plane task `features` (no model) after `agree`/`final` and `alerts`; host path `learn.features.build_for_scan` for `scan`/`pre-push` |
|
||
|
|
| Labels | host-only `ousast learn labels`; site/fix-range matcher for populations, vulnerable/fixed side for pairs, benign deltas, adjudications; fail-closed source allow-list keeps v3 out |
|
||
|
|
| Model | L2-regularised logistic regression, pure Python (Newton/IRLS), per family at >= 20 positive and >= 20 negative repositories, pooled at >= 10, otherwise "insufficient data" |
|
||
|
|
| Calibration | Platt scaling on inner grouped out-of-fold scores; checked per outer fold (reliability, slope, intercept); if it fails, ADVISORY only |
|
||
|
|
| Folds | outer grouped K-fold by repository (repeated), plus leave-one-source-out and leave-one-framework-out; nested inner folds for lambda, calibration, thresholds |
|
||
|
|
| Operating points | BLOCK: lowest threshold whose inner-fold Wilson lower bound on precision is >= 0.95; ADVISORY: threshold reaching 0.90 inner-fold recall; reported with repository-bootstrap intervals |
|
||
|
|
| Artifacts | content-addressed JSON under `models/` in the store; adopted version exported into the package by a maintainer commit |
|
||
|
|
| Experiments | manifest `plane/experiments/<id>.yaml` + Run annotation `openultrasast.io/experiment`; paired; repository-cluster bootstrap CI (primary), exact McNemar (secondary); O'Brien-Fleming two-look stopping |
|
||
|
|
| First experiment | `exp-001-known-callers`: callers line on vs off, development corpus (v1, pmpro, MediaWiki, WP Statistics, full-repository pointer pairs), about $8 expected, $15 ceiling |
|
||
|
|
| Framework knowledge | tagged `framework`/`library` priors, off by default; roles inferred by a `roles` plane task (model) + deterministic wrapper inference; leave-one-framework-out evaluation |
|
||
|
|
|
||
|
|
## Architecture
|
||
|
|
|
||
|
|
```
|
||
|
|
instruments (signal producers, unchanged as standalone tools)
|
||
|
|
quick rules Joern engine repo-facts roles (new) verify a/b/c + agree
|
||
|
|
alerts task engine_alerts / callers, entry model role labels (plane only, Model-bound)
|
||
|
|
pre-push scan distance + wrapper inference
|
||
|
|
\ | | | /
|
||
|
|
+--------------+------------------+-------------------+--------------------+
|
||
|
|
|
|
||
|
|
features (new, no model): allow-listed record per candidate
|
||
|
|
plane: task after final + alerts host: learn.features.build_for_scan
|
||
|
|
|
|
||
|
|
+---------------------+----------------------+
|
||
|
|
| |
|
||
|
|
labels (new, host only, never in a sandbox) decide (new, no model)
|
||
|
|
populations: matches_v2 / fix ranges load adopted model -> p, operating point,
|
||
|
|
pairs: vulnerable vs fixed side top features -> decision rows / report fields
|
||
|
|
benign deltas, adjudications ^
|
||
|
|
source allow-list: v3 absent |
|
||
|
|
| |
|
||
|
|
learn train/evaluate (host, pure Python) ---- model artifact (models/<sha>.json) ---- adopt (maintainer)
|
||
|
|
grouped folds, Platt, thresholds, curves |
|
||
|
|
| packaged: ruleset/decision/<family>-<profile>.json
|
||
|
|
experiments (plane Runs with arms) -> arm_outcome rows -> analysis -> adopt | reject | inconclusive
|
||
|
|
```
|
||
|
|
|
||
|
|
A plane Run per case becomes `facts -> va, vb -> agree -> vc -> final -> alerts -> roles? -> features -> decide
|
||
|
|
-> remember`. `roles` is optional and Model-bound. `features` and `decide` are model-free with budget
|
||
|
|
`{usd: 0, calls: 0}`, as `remember` and the loop steps are (`plane/tasks/loop.py:1-5`). `remember` ingests the new
|
||
|
|
`features` and `decision` rows. Labels are never computed in a Run. They are built on the host from ground-truth
|
||
|
|
files the sandbox never receives, so no task that produces a feature can read a label.
|
||
|
|
|
||
|
|
New code: `src/openultrasast/learn/` (`schema.py`, `features.py`, `roles.py`, `labels.py`, `folds.py`,
|
||
|
|
`train.py`, `calibrate.py`, `evaluate.py`, `decide.py`, `experiments.py`, `sources.toml`),
|
||
|
|
`src/openultrasast/plane/tasks/{features,decide,roles}.py`, `ruleset/frameworks.toml`,
|
||
|
|
`ruleset/decision/`, and CLI groups `ousast learn ...` and `ousast plane experiment ...`. The reconciler is not
|
||
|
|
touched. Its 500-line budget stays (`tests/test_plane_reconciler.py`).
|
||
|
|
|
||
|
|
## Components and Interfaces
|
||
|
|
|
||
|
|
### 1. Candidate key and feature record (Requirements 1, 7.2)
|
||
|
|
|
||
|
|
**Candidate.** A candidate is `(repo, pin, path, function, family)`. `repo` is `memory.repo_key`
|
||
|
|
(`plane/memory.py:69`), `function` is the enclosing declaration by the `repo-facts` patterns
|
||
|
|
(`plane/tasks/repo_facts.py:131`, `<global>` outside any function), and `family` is a `families.toml` id. It is the
|
||
|
|
key that already joins `alert` and `verdict` rows (`improve/memory.py:7`: "repo, pin and `path::function`"). For a
|
||
|
|
delta the pin is the head, and the record carries `base` beside it. The key lives in the row **envelope**, for
|
||
|
|
joining labels and grouping folds. It is never a feature.
|
||
|
|
|
||
|
|
A candidate exists when at least one instrument emitted a signal of that family in that function: a quick-rule
|
||
|
|
alert, an engine finding, a `roles` sink, or a verify candidate. A labelled positive with no candidate is a
|
||
|
|
**reach miss**. It counts against recall and is reported separately as `reach`, so the engine is never credited
|
||
|
|
for sites no instrument surfaced.
|
||
|
|
|
||
|
|
**Delta mapping (Req 7.3).** For base..head, changed hunks (`git diff -U0 base head`) map to the head functions
|
||
|
|
that enclose a changed line. Functions whose engine operation `compare_evidence` marks `new`/`worsened`
|
||
|
|
(`push/policy.py:489`, `CandidateDelta.novelty`) are added. Each such function yields one candidate per family
|
||
|
|
that has a signal there. The base side of the same function supplies the `delta.*` features. A whole-repository
|
||
|
|
scan is the same with no base (`delta.*` is `not_applicable`).
|
||
|
|
|
||
|
|
**Features.** The feature set is closed and listed in `learn/schema.py` as `FEATURES: tuple[FeatureSpec, ...]`.
|
||
|
|
Each spec has a name, a source instrument, a type (`bool`, `int` with a cap, `float`, or `enum` with a closed
|
||
|
|
vocabulary), a profile (`static` or `plane`), and whether it is a prior. There are no free-text values, no
|
||
|
|
identifiers and no raw counts of repository-specific tokens.
|
||
|
|
|
||
|
|
| Feature | Source (file) | Type | Profile |
|
||
|
|
| --- | --- | --- | --- |
|
||
|
|
| `lang.*` | `preprocess.detect_language` | one-hot over the languages the engine or quick mode covers | static |
|
||
|
|
| `qr.enabled_hits`, `qr.shadow_hits` | quick scan (`plane/tasks/alerts.py:117`, `findings.quick_scan_findings`) | int, cap 20 | static |
|
||
|
|
| `qr.mechanism.<m>` | the hit rules' `tags` mapped to the closed mechanism vocabulary (`benchmarks/pairs/mechanisms.toml` ids) | int, cap 10 | static |
|
||
|
|
| `qr.max_precision_estimate` | `PatternRule.precision_estimate` (`ruleset/store.py:33`) | float | static |
|
||
|
|
| `qr.prior_hits` | hits of rules tagged `framework`/`library` (section 2) | int, cap 10 | static, **prior** |
|
||
|
|
| `eng.findings` | engine findings in the function (`engine_alerts.engine_rows`, `plane/engine_alerts.py:160`; pre-push scan) | int, cap 10 | static |
|
||
|
|
| `eng.rung_max` | finding rung | enum `suspicion, model_entailed, entailed` | static |
|
||
|
|
| `eng.witness_steps_min` | witness path length | int, cap 50 | static |
|
||
|
|
| `eng.sink_kind` | sink entry id mapped to an operation kind | enum (sql, command, code_eval, deserialize, file_path, file_inclusion, outbound_request, redirect, html_output, other) | static |
|
||
|
|
| `eng.source_kind` | source entry kind | enum (request, server, raw_body, argv_stdin, inferred_role, prior, other) | static |
|
||
|
|
| `eng.sink_origin`, `eng.source_origin` | where the vocabulary entry came from | enum (language, inferred_wrapper, model_role, prior) | static |
|
||
|
|
| `eng.sanitizer_on_path` | discharge seen but not accepted | bool | static |
|
||
|
|
| `eng.completion` | questions completed / total for the file | float | static |
|
||
|
|
| `eng.degraded`, `eng.unstable` | degradations; present in only one of two runs | bool | static |
|
||
|
|
| `facts.callers` | `repo-facts` call sites (`repo_facts.py:151`, cap 40) | int, cap 40 | static |
|
||
|
|
| `facts.caller_files` | distinct caller files | int, cap 20 | static |
|
||
|
|
| `facts.entry_distance` | caller-graph hops to an entry point (`mapping.analyze_entry_points`, `mapping.py:123`) | int, cap 6 | static |
|
||
|
|
| `facts.is_global` | candidate is `<global>` | bool | static |
|
||
|
|
| `facts.function_lines` | declaration span | int, cap 500 | static |
|
||
|
|
| `roles.sink_confidence`, `roles.source_in_function` | `roles` task (section 2) | float / bool | static (deterministic part), plane (model part) |
|
||
|
|
| `delta.novelty` | `CandidateDelta.novelty` or diff of base/head signals | enum `new, worsened, unchanged, unknown, not_applicable` | static |
|
||
|
|
| `delta.changed_lines` | changed lines inside the function | int, cap 200 | static |
|
||
|
|
| `delta.sanitizer_removed` | a sanitizer call present at base is absent at head | bool | static |
|
||
|
|
| `verify.flag_a`, `verify.flag_b` | pass verdicts (`units.jsonl`, `plane/tasks/verify.py`) | bool | plane |
|
||
|
|
| `verify.flag_c` | tie-break pass (asked only when a/b disagree) | bool | plane |
|
||
|
|
| `verify.votes` | flags over the passes asked | int 0-3 | plane |
|
||
|
|
| `verify.turns_mean`, `verify.site_offset` | tool turns; lines between the model's reported site and the candidate line | float / int cap 200 | plane |
|
||
|
|
| `agree.final` | `agreed, disputed, rejected` (`plane/tasks/remember.py:73`) | enum | plane |
|
||
|
|
| `ms.flagged`, `ms.operation` | model sink classification (`benchmarks/independent/model_sinks.py`, moved into `roles`) | bool / enum | plane |
|
||
|
|
|
||
|
|
**Deliberately excluded from features** (each is label information or an identity):
|
||
|
|
|
||
|
|
- `site_match` (`plane/tasks/agree.py:200`) and `in_fix_range` (`plane/tasks/alerts.py:137`). Both are computed
|
||
|
|
from declared sites and fix ranges.
|
||
|
|
- Every fixed-pin alert. A deployed scan has no fixed pin.
|
||
|
|
- Declared-site counts and `case.json`.
|
||
|
|
- Costs (`usd`), which vary with provider pricing, not with the code.
|
||
|
|
- Rule ids, paths, file and function names, commit ids and repository names.
|
||
|
|
|
||
|
|
Rule ids enter only through closed mechanism buckets and the prior count. A rule written after one case's miss
|
||
|
|
would otherwise identify that case.
|
||
|
|
|
||
|
|
**Missing is not negative (Req 1.2).** Each record carries `instruments: {name: {state, version}}` with `state` in
|
||
|
|
`ran | none | failed | not_applicable`, and `x: {feature: value | null}`. `none` means no coverage for the language
|
||
|
|
(`plane/tasks/alerts.py` `coverage`, `remember.coverage_rows`, `plane/tasks/remember.py:79-96`). `failed` means
|
||
|
|
the instrument broke or did not read its input: the engine-alert rule "no result file, no `read N files` line, or
|
||
|
|
`questions == 0` fails the case loudly" (`plane/engine_alerts.py` docstring) maps here, never to zero findings.
|
||
|
|
`not_applicable` covers two cases: `delta.*` on a whole-repository scan, and `facts.*` on a pair excerpt, where no
|
||
|
|
repository exists. Every feature of an instrument whose state is not `ran` is `null`. At training time a `null`
|
||
|
|
becomes 0 plus a per-instrument indicator `missing.<instrument>` (one indicator per instrument, not per feature,
|
||
|
|
to keep the parameter count down).
|
||
|
|
|
||
|
|
**Profiles.** `static` is what `pre-push` and `scan` can compute locally without a model: quick rules, engine,
|
||
|
|
facts, deterministic roles and delta. `plane` adds verify, agree and model roles. A model is trained per
|
||
|
|
(family, profile). A `static` model never sees verify features, so it cannot learn to lean on an instrument that
|
||
|
|
is absent in deployment.
|
||
|
|
|
||
|
|
**Enforcement (Req 7.2).** The builder emits only `FEATURES` names. `validate_record` rejects any other key, any
|
||
|
|
string value outside an enum's vocabulary, and any number outside its type. Tests:
|
||
|
|
|
||
|
|
- `test_learn_schema.py::test_features_are_allow_listed`: every emitted key is in `FEATURES`, and no spec's type
|
||
|
|
is free text.
|
||
|
|
- `::test_record_is_name_invariant`: two fixture repositories that differ only in repository name, paths, file
|
||
|
|
names, function names and commit ids give byte-identical `x`.
|
||
|
|
- `::test_label_fields_never_reach_features`: `site_match`, `in_fix_range`, fixed-pin rows and `case.json`
|
||
|
|
fields in the inputs change nothing in `x`.
|
||
|
|
- `::test_missing_is_not_zero`: `coverage: none` gives `null` + `state: none`, and a covered language with no hit
|
||
|
|
gives `0` + `state: ran`.
|
||
|
|
|
||
|
|
**Versioning.** `schema_version` (an integer, bumped on any spec change) and `feature_set_digest` (sha256 of the
|
||
|
|
canonical `FEATURES` list). `instruments.*.version` records the ruleset digest, the engine image id
|
||
|
|
(`engine_alerts._image_id`, `plane/engine_alerts.py:177`), the runner image digest (`memory.image_digest`), the
|
||
|
|
verify model id and a prompt digest, and the `roles` model id. That makes every record rebuildable (Req 1.3).
|
||
|
|
|
||
|
|
**Where records live.** A new memory row kind `features` (`KINDS` at `plane/memory.py:52`), per repository and
|
||
|
|
pin like every other row:
|
||
|
|
`{candidate, family, language, profile, schema_version, feature_set_digest, x, instruments, unit: "pin"|"delta",
|
||
|
|
base?}`. The row id is `row_id("features", run, task, candidate+family)`.
|
||
|
|
|
||
|
|
**Who builds them.**
|
||
|
|
|
||
|
|
- *Plane*: task `features` (`plane/tasks/features.py`), no Model, one per case, after `final` and `alerts` (and
|
||
|
|
`roles` when present). Inputs are `facts.json`, the pass `units.jsonl`, `agreed.json` (with `site_match` dropped
|
||
|
|
on read), `alerts.jsonl` (vulnerable pin only, `in_fix_range` dropped), the alerts `summary.json` coverage, and
|
||
|
|
`roles.json`. It writes `features.jsonl`, which `remember` turns into rows (one more input in
|
||
|
|
`remember.rows_for`, `plane/tasks/remember.py:99`).
|
||
|
|
- *Host*: `learn.features.build_for_scan(root, findings, engine_result, *, base=None)`, called by `scan` and
|
||
|
|
`pre-push` (section 8) and by `ousast learn features <corpus>` to build records for the pair corpus and the
|
||
|
|
development corpus without a Run.
|
||
|
|
|
||
|
|
### 2. Framework knowledge: tagged priors and inferred roles (Requirement 8)
|
||
|
|
|
||
|
|
**Rule.** Hand-written knowledge stays at the language level: superglobals, built-ins, process execution, raw
|
||
|
|
database drivers (DB-API `execute`, `mysqli_*`, `pg_*`, JDBC), deserialisers and the standard library.
|
||
|
|
Framework and application roles (request objects, query builders, hooks, sanitisers, guards) are either
|
||
|
|
**inferred per repository** or kept as **tagged priors**, which are features that are off by default.
|
||
|
|
|
||
|
|
**Tagging.** Every `[[source]]`, `[[sink]]`, `[[sanitizer]]` and `[[dispatch]]` entry in `ruleset/semantic/*.toml`
|
||
|
|
and every quick `[[rule]]` gains an optional `framework = "<id>"` or `library = "<id>"`. An entry that mixes
|
||
|
|
language-level and framework calls is split, so a tag always covers a whole entry. `ruleset/frameworks.toml`
|
||
|
|
lists the ids and, per id, a lexicon of symbols. The lexicon has two uses only: the lint test, which fails on an
|
||
|
|
untagged entry containing a lexicon symbol, and repository stratification (below). It never feeds detection.
|
||
|
|
Loaders take `priors: Literal["off", "all"] | frozenset[str]`. The default for the engine model is `off`. Today's
|
||
|
|
`scan` behaviour (fallback, Req 6.2) keeps `all`.
|
||
|
|
|
||
|
|
Current framework-specific entries and their disposition:
|
||
|
|
|
||
|
|
| Entry | Where | Tag / action |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `wpdb` sink (`$wpdb->get_var`, ...) | `ruleset/semantic/php.toml:47-58` | `framework = "wordpress"` |
|
||
|
|
| `unserialize` sink: `unserialize` + `maybe_unserialize` | `php.toml:136-138` | split: `unserialize` language; `maybe_unserialize` wordpress |
|
||
|
|
| `sql_escape`: `mysqli_real_escape_string`, ..., `esc_sql` | `php.toml:169-170` | split: `esc_sql` wordpress |
|
||
|
|
| `wpdb_prepare` sanitizer | `php.toml:194-196` | wordpress |
|
||
|
|
| `coerce`: `intval`, ..., `absint` | `php.toml:207-208` | split: `absint` wordpress |
|
||
|
|
| `wordpress_hooks` dispatch (`add_action`, `apply_filters`, ...) | `php.toml:225-242` | wordpress |
|
||
|
|
| layout `php_composer_and_wordpress` | `php.toml:244-247` | product filter (vendored/test paths), not detection; renamed `php_layout`, untagged |
|
||
|
|
| rule `php-wpdb-sql-composition` | `ruleset/php/rules.toml:20-29` | wordpress |
|
||
|
|
| rule `php-unserialize` (pattern includes `maybe_unserialize`) | `php/rules.toml:114-125` | split into `php-unserialize` (language) and `php-maybe-unserialize` (wordpress) |
|
||
|
|
| Flask `request` source (`request.args`, `.form`, `.json`, ...) | `ruleset/semantic/python.toml:5-17` | flask |
|
||
|
|
| `django-request` source | `python.toml:24-30` | django |
|
||
|
|
| `orm.raw` (`objects.raw`, `RawSQL`, `extra`; `text`, `from_statement`) | `python.toml:102-104` | split: django / sqlalchemy (library) |
|
||
|
|
| `path.open` (`open`, `os.*`, `shutil.*`; `send_file`, `send_from_directory`; `FileResponse`) | `python.toml:110-112` | split: language / flask / django |
|
||
|
|
| `html.unescaped` (`render_template_string`, `Markup`; `mark_safe`, `format_html`, `HttpResponse`) | `python.toml:119-121` | flask / django |
|
||
|
|
| `outbound.request` (`requests.*`, `httpx.*`; `urlopen`, `urlretrieve`) | `python.toml:127-129` | split: library requests/httpx / language |
|
||
|
|
| `redirect` (`redirect`, `HttpResponseRedirect`, `url_for`) | `python.toml:134-136` | flask / django |
|
||
|
|
| `url_allowlist` (`url_has_allowed_host_and_scheme`, `is_safe_url`) | `python.toml:145-146` | django |
|
||
|
|
| `pickle.loads` entry incl. `yaml.load` | `python.toml:55-57` | split: `yaml.load` library pyyaml |
|
||
|
|
| Express `req` source (`req.query`, `req.body`, ...) | `ruleset/semantic/javascript.toml:5-6` | express |
|
||
|
|
| `html.response` (`res.send`; `res.write`/`res.end`) | `javascript.toml:80-82` | split: express / language (Node core) |
|
||
|
|
| `path.open` (`sendFile`; `readFile`, ...) | `javascript.toml:87-89` | split: express / language |
|
||
|
|
| `redirect` (`redirect`; `location.*`) | `javascript.toml:129-131` | split: express / language (DOM) |
|
||
|
|
| `prototype.merge`, `outbound.request`, `html.escape` | `javascript.toml:57-59, 109-111, 148-149` | library (lodash, axios/needle, escape-html/DOMPurify) |
|
||
|
|
| `servlet` source, `response.write` | `ruleset/semantic/java.toml:5-6, 47-49` | `jakarta-servlet` |
|
||
|
|
| `outbound.request` (`getForObject`, `exchange`; `sendRedirect`; `openConnection`) | `java.toml:54-56` | split: spring / jakarta-servlet / language |
|
||
|
|
| `java.escape` (`ESAPI.encoder`, `StringEscapeUtils`; `getCanonicalPath`, `normalize`) | `java.toml:61-62` | split: library / language |
|
||
|
|
| hook-as-source rule | `cpg/queries/taint.sc:918-949`, `semantic/facts.py:73-90` | general code driven by the dispatch fact ("The registry's vocabulary arrives as a FACT", `taint.sc:921`); with priors off it is inert unless `roles` infers a registry |
|
||
|
|
|
||
|
|
The maintainer's term counts (php.toml 24, php rules 9, python 16, javascript 5, java 3, 15 WordPress mentions
|
||
|
|
in `semantic/*.py` and `cpg/queries/*.sc`) are covered by these entries. The remaining mentions in `.sc` and
|
||
|
|
`.py` are comments (`taint.sc:161, 249, 322-327, 588, 911, 1296`; `dominance.sc:130`; `census.sc:9`;
|
||
|
|
`facts.py:345`). The tagging commit's lint test, not this table, is the authority.
|
||
|
|
|
||
|
|
**Role inference (Req 8.1).** Two parts, both producing per-repository vocabulary with provenance:
|
||
|
|
|
||
|
|
1. *Wrapper inference* (deterministic, `learn/roles.py`, no model). A function whose parameter reaches, within its
|
||
|
|
body, a call already in the role set becomes a derived sink or sanitizer of the same operation kind, with
|
||
|
|
`depth + 1`. The role set starts from language-level entries plus any inferred roles. Derivation iterates over
|
||
|
|
the `repo-facts` caller map to depth 3. When dependency source is present (`vendor/`, `node_modules/`,
|
||
|
|
site-packages in the checkout), it is scanned the same way, so `$wpdb->query` becomes a sink because
|
||
|
|
WordPress's own `wpdb::query` reaches `mysqli_query`, not because a table says so. That holds only when WordPress
|
||
|
|
core is present. A plugin checkout usually lacks it, which is why part 2 exists.
|
||
|
|
2. *Model roles* (plane task `roles`, Model-bound, `deepseek-flash`). The source-only classification of
|
||
|
|
`benchmarks/independent/model_sinks.py` moves into the package: per product file chunk, which functions and
|
||
|
|
call sites perform an operation of a closed kind on non-constant data. The v2 record: declared site among the
|
||
|
|
candidates in 15/17 cases, fix or site file in 16/17, $14.42 for 17 repositories
|
||
|
|
(`selection-v2-model-sinks.json`). The output extends to all roles: `sink`, `source`, `sanitizer`, `guard`,
|
||
|
|
`dispatch` (a register/apply pair such as a hook registry), with `operation`, line and a one-line evidence
|
||
|
|
quote. A chunk whose call fails is `unclassified`, never "no roles" (the `model_sinks.py` rule). In `pre-push`
|
||
|
|
only the changed files and the definitions they call are classified.
|
||
|
|
|
||
|
|
Roles reach the engine as a per-scan vocabulary overlay in the shape of `ruleset/semantic/<lang>.toml` entries
|
||
|
|
with `origin = "inferred_wrapper" | "model_role"`. The engine already takes vocabulary as facts
|
||
|
|
(`taint.sc:921`). They also reach the feature record as `eng.*_origin` and `roles.*`. Roles are signals. A
|
||
|
|
model-labelled sink alone is not a finding.
|
||
|
|
|
||
|
|
**Stratification metadata.** Each repository gets `frameworks: [ids]` from its dependency manifests
|
||
|
|
(`composer.json`, a WordPress plugin header, `requirements*.txt`/`pyproject.toml`, `package.json`,
|
||
|
|
`pom.xml`/`build.gradle`) matched against `frameworks.toml`. This is envelope metadata for folds and reports,
|
||
|
|
never a feature.
|
||
|
|
|
||
|
|
**Evaluation (Req 8.2-8.4).** The engine is always trained and reported with `priors=off`. A prior group (one
|
||
|
|
framework id) is kept only through an engine-level A/B (section 6): priors on vs off, paired on the same outer
|
||
|
|
folds, metric recall at the BLOCK operating point. It is adopted only if the repository-bootstrap CI excludes zero.
|
||
|
|
Every report carries, per family, the metric with priors off, with the adopted priors, and the difference: the
|
||
|
|
share of the result that depends on priors. Leave-one-framework-out folds (section 4) give the "framework unseen
|
||
|
|
in training" row.
|
||
|
|
|
||
|
|
### 3. Label builder (Requirement 2)
|
||
|
|
|
||
|
|
`ousast learn labels` (`learn/labels.py`) runs on the host only. It writes `label` rows (a new kind) and
|
||
|
|
`labels/<digest>.jsonl` snapshots.
|
||
|
|
|
||
|
|
**Sources are an allow-list, fail-closed.** `learn/sources.toml` names each usable source, the file that proves
|
||
|
|
its evaluation was recorded, and the date. A population not listed cannot produce a label. The builder never
|
||
|
|
opens a population file that is not listed, so population v3 (`population-v3-php.toml`, reserved) is excluded
|
||
|
|
without being read. It becomes usable only when a later commit adds it after the v3 result is recorded
|
||
|
|
(Req 2.3).
|
||
|
|
|
||
|
|
| Source | Evaluation record | Status |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `population-v1` | `benchmarks/independent/results-v1.json` | spent (retuned on, `population-v2.toml:9-11`) |
|
||
|
|
| `population-v2` | `results-v2.json`, `results-v2-hunter.json`, `plane-increment-2.json` | spent for tuning (`model_sinks.py`: "v2 is spent for tuning"); its fold is marked *design-informed* (section 4) |
|
||
|
|
| `dev-php` | `benchmarks/push/inputs.json` (pmpro introduce/repair/benign), MediaWiki and WP Statistics checkouts | development |
|
||
|
|
| `pairs` | `benchmarks/pairs/**/catalog.toml` via `load_pair_catalog` (`pairs.py:229`) | development; catalog split recorded, not used for folds |
|
||
|
|
| `adjudications` | `results-v1.json` `precision_review`, `results-v2.json` / `results-v2-hunter.json` `precision_sample`, `verifier-check-2026-09-29.json` | recorded verdicts |
|
||
|
|
|
||
|
|
**Rules per source.**
|
||
|
|
|
||
|
|
- *Populations.* Positive: a candidate on the vulnerable pin that matches the case. Matching uses v2 declared sites
|
||
|
|
through `agree.matches_declared` (`plane/tasks/agree.py:45`, the parity-proven copy of `evaluate.matches_v2`).
|
||
|
|
v1 has no declared sites (0 in `population-v1.toml`), so it uses the fix-range rule `evaluate.matches`
|
||
|
|
(`benchmarks/independent/evaluate.py:162`). Negative: the same `(path, function)` on the fixed pin when the fix
|
||
|
|
changed that function (inside the new-side `fixed_ranges`, `plane/generate.py:106, 227-229`). A function the fix
|
||
|
|
did not touch is the same code at both pins and is not a negative. Benign: a candidate in a function changed
|
||
|
|
between the benign base and tip pins that has a finding at the tip and none at the base. It is negative
|
||
|
|
**by protocol assumption** (protocol v2 "Benign false alert"), with source `benign_control`. A candidate on a
|
||
|
|
vulnerable pin that matches no site is **unlabelled**, never negative.
|
||
|
|
- *Pairs.* Positive: the labelled function (`ExpectedFinding.function`/`line`, `benchmark.py:20-30`; 407 of 452
|
||
|
|
labels name a function) on the vulnerable side. Negative: the same function on the fixed side. Pairs marked
|
||
|
|
`unscorable` (identical twins, known limits, `pairs.py:130`; 7 today) give no labels. Tier `title`
|
||
|
|
(`agent-vfc`, 59 pairs, labels read from advisory titles; labelled recall 3.6% in the baseline) is excluded by
|
||
|
|
default and used only as a sensitivity arm.
|
||
|
|
- *Deltas.* Every population case and every pair also gives delta units. fixed..vulnerable is the introducing
|
||
|
|
direction, and its labelled function is a positive delta candidate. vulnerable..fixed is a repair, whose
|
||
|
|
candidates are negative for BLOCK. benign base..tip is a benign push. These are the primary units for the
|
||
|
|
safety net (Req 7.3).
|
||
|
|
- *Benign history (optional, flagged).* Non-security commits from the history of each training repository at
|
||
|
|
least 90 days away from any fix date give benign deltas at no model cost. They are negative by assumption, carry
|
||
|
|
source `benign_history` and a weight of 0.5, and every metric is reported with and without them. This is the
|
||
|
|
only cheap way to estimate false blocks per benign push.
|
||
|
|
- *Adjudications.* `True` and `True, privileged` are positive (privileged is kept as a label attribute), and
|
||
|
|
`False, *` is negative. Each keeps the adjudicator (maintainer, or a judge task with its evidence) and the
|
||
|
|
recorded text.
|
||
|
|
- A plane verdict (`agreed`, `rejected`, `disputed`) is never a label (Req 2.2). It enters only as `agree.final`.
|
||
|
|
|
||
|
|
**Label row.** `{candidate, family, label: 1|0, source, source_ref (case id / pair name / adjudication index),
|
||
|
|
unit: pin|delta, direction?, weight, split (catalog split or population split), group (repository group key),
|
||
|
|
frameworks, created, evidence}`. `group` is the normalised repository, the lower-cased `owner/name` as the guard
|
||
|
|
uses (`improve/memory.py:89-92`). Candidates that share an advisory id (CVE/GHSA) across corpora are merged into
|
||
|
|
one group, so a repository mirrored under two names cannot straddle folds.
|
||
|
|
|
||
|
|
**What exists today** (counted 2026-09-30 with a scratch script over `load_pair_catalog` +
|
||
|
|
`split_by_repository`; the label-snapshot commit makes the count reproducible):
|
||
|
|
|
||
|
|
| Family | Pair repos (positive) | Pairs | Population repos (v1 + v2 + dev) | Adjudicated | Per-family model? |
|
||
|
|
| --- | --- | --- | --- | --- | --- |
|
||
|
|
| injection | 43 | 56 | 7 + 10 + 3 | most of the 43 (per-family split not counted) | yes |
|
||
|
|
| untrusted_destination | 41 | 41 | 3 + 6 + 0 | some | yes |
|
||
|
|
| path | 24 | 24 | 0 | 0 | yes (Python-heavy) |
|
||
|
|
| config_secrets | 23 | 24 | 1 + 1 | some | yes, borderline |
|
||
|
|
| deserialization | 23 | 23 | 0 | 0 | yes (all Python) |
|
||
|
|
| access_control | 21 | 24 | 0 | 0 | yes, borderline |
|
||
|
|
| output_encoding | 17 | 58 (40 from one repository, OWASP BenchmarkJava) | 0 | 0 | pooled |
|
||
|
|
| memory | 7 | 176 (132 openssl, 40 curl) | 0 | 0 | **insufficient** |
|
||
|
|
| prototype | 6 | 7 | 0 | 0 | **insufficient** |
|
||
|
|
|
||
|
|
Totals: 427 pairs and 452 labels in 146 repository groups (142 named). Tiers: advisory 250, seeded 115, title 59,
|
||
|
|
reviewed 3. Provenance: human 237, agent 139, synthetic 51. 59 repositories straddle the catalog's train/holdout
|
||
|
|
split (`split_by_repository` report). The engine ignores that split for folds and records it on the label.
|
||
|
|
|
||
|
|
By language, the strata with at least 10 positive repositories are Python (untrusted_destination 30, injection
|
||
|
|
23, deserialization 23, path 17, access_control 15, output_encoding 15) and JavaScript injection (16). **PHP has
|
||
|
|
no pairs.** Its labels are v1 (4 repositories), v2 (6) and dev-php (3): about 9 injection and 4
|
||
|
|
untrusted_destination repositories.
|
||
|
|
|
||
|
|
Plane-profile rows (verify/agree present) exist for 43 candidates in 15 repositories, all v2. The adjudicated
|
||
|
|
rows are mostly negative: v1 1/12 true, v2 0/17, v2 hunter 2/14 (`results-v1.json` `summary.precision`,
|
||
|
|
`results-v2*.json` `precision`).
|
||
|
|
|
||
|
|
What this means:
|
||
|
|
|
||
|
|
- The `static` profile can be trained for six families, pooled for output_encoding, and not at all for memory or
|
||
|
|
prototype.
|
||
|
|
- The `plane` profile cannot be trained until verify has run on labelled units. The harvest Run in the commit
|
||
|
|
sequence (step 5) runs passes a and b on the 78 scorable, non-title pairs in the verify families (injection,
|
||
|
|
untrusted_destination, config_secrets; `verify.OPERATIONS`, `plane/tasks/verify.py:38-50`) on both sides, and on
|
||
|
|
the development cases. That is about 312 + 110 hunts at $0.014 each (validation-46: $0.8567 over 61 hunts),
|
||
|
|
**about $6**.
|
||
|
|
- PHP is reported as its own stratum only where it reaches 10 positive repositories. Today that is injection at
|
||
|
|
the boundary, and untrusted_destination is "insufficient data".
|
||
|
|
|
||
|
|
### 4. The decision engine (Requirements 3, 7.1, 7.3, 7.4)
|
||
|
|
|
||
|
|
**Model family.** L2-regularised logistic regression on standardised features plus the instrument-missing
|
||
|
|
indicators. There are about 40 parameters per model. Reasons:
|
||
|
|
|
||
|
|
- With 20-60 repositories per family, a shallow gradient-boosted model fits interactions the folds cannot
|
||
|
|
validate and calibrates worse on small data.
|
||
|
|
- A linear model's contributions are its explanation (Req 3.5).
|
||
|
|
- Inference is a dot product, so `scan` and `pre-push` need no dependency.
|
||
|
|
|
||
|
|
Gradient-boosted trees stay a possible future arm of an engine-level experiment, not a default.
|
||
|
|
|
||
|
|
**Dependency trade-off.**
|
||
|
|
|
||
|
|
- *scikit-learn as an optional extra* (`openultrasast[learn]`) brings numpy, scipy and joblib, about 100 MB in
|
||
|
|
the runner image, and a second numerical stack whose version changes model bytes.
|
||
|
|
- *Pure Python (chosen)*: `learn/train.py` fits by Newton/IRLS with a dense solve of a matrix of about 40x40
|
||
|
|
(Gaussian elimination with partial pivoting). At about 2,000 rows that is milliseconds. It is deterministic with
|
||
|
|
no seeds inside the fit, and it is about 200 lines.
|
||
|
|
|
||
|
|
scikit-learn is a **test-only** extra: `test_learn_train.py::test_matches_sklearn` (skipped unless installed)
|
||
|
|
checks coefficients against `LogisticRegression(penalty="l2", solver="lbfgs")` to 1e-6. Training and inference
|
||
|
|
stay pure Python in core.
|
||
|
|
|
||
|
|
**Per family vs pooled.** A (family, profile) model is trained when the training data holds at least 20
|
||
|
|
repositories with a positive and 20 with a negative in that family. A pooled model (all families, with a family
|
||
|
|
one-hot) serves families with 10-19. Below 10 the family is "insufficient data": no engine decision, and the
|
||
|
|
fallback applies (section 8). The same 10-repository floor applies to any reported stratum (family x language,
|
||
|
|
family x framework).
|
||
|
|
|
||
|
|
**Weights.** One repository must not dominate (openssl has 132 pairs, OWASP BenchmarkJava 43). A row weighs
|
||
|
|
`w_source * min(1, 5 / n_rows(repo, family, label))`, so each repository contributes at most 5 effective positives
|
||
|
|
and 5 effective negatives per family. `benign_history` has `w_source = 0.5`.
|
||
|
|
|
||
|
|
**Folds (Req 3.3, 7.1).** Every split is by `group` (repository), across all corpora at once. A repository in an
|
||
|
|
evaluation fold contributes no row, from any corpus, to training.
|
||
|
|
|
||
|
|
- *Outer*: grouped K-fold with K = min(10, repositories in the family), stratified by family and label, repeated 5
|
||
|
|
times with seeds 0-4. This gives the reported out-of-repository numbers.
|
||
|
|
- *Leave-one-source-out*: each population (v1, v2, dev-php) and each pair slice (vfc, vibe-py, owasp, vfc-js,
|
||
|
|
agent-vfc, sast, github, local) held out whole, with its repositories removed from training. This is the
|
||
|
|
"population-level fold" of Req 3.3 and the data behind the learning curve over the number of sources.
|
||
|
|
- *Leave-one-framework-out*: all repositories tagged with framework F held out, including WordPress, Django,
|
||
|
|
Flask and Express wherever that framework has at least 10 repositories. This gives the "framework unseen" row of
|
||
|
|
Req 8.3.
|
||
|
|
- *Inner* (inside each outer training set): grouped 5-fold. It selects lambda from {0.01, 0.1, 1, 10} by grouped
|
||
|
|
log-loss, produces the out-of-fold scores for calibration, and picks thresholds. Nothing is tuned on an outer
|
||
|
|
fold.
|
||
|
|
- The v2 fold is marked *design-informed*: the tie-break and the callers line were chosen after seeing v2
|
||
|
|
(`plane-increment-2.json` `reading`). Plane-profile numbers are reported with and without it.
|
||
|
|
|
||
|
|
**Calibration (Req 3.1, 7.4).** Platt scaling (a two-parameter logistic on the model's logit) is fit on the inner
|
||
|
|
out-of-fold logits. Isotonic regression is not used: too few positives per family. The check is per outer fold
|
||
|
|
and pooled:
|
||
|
|
|
||
|
|
- a reliability table with 5 quantile bins;
|
||
|
|
- calibration slope and intercept (a logistic of the label on the calibrated logit), with repository-bootstrap
|
||
|
|
95% CIs;
|
||
|
|
- the expected calibration error.
|
||
|
|
|
||
|
|
Calibration **holds** for a family when the slope CI contains 1, the intercept CI contains 0, and the pooled
|
||
|
|
out-of-fold expected calibration error is at most 0.05. If it fails, the model is recalibrated once on grouped
|
||
|
|
out-of-fold scores of the full training set. If it still fails, the family gets **ADVISORY only**, the artifact
|
||
|
|
records `block.offered = false, reason = "calibration"`, and the report says so.
|
||
|
|
|
||
|
|
**Prevalence.** Training mixes are enriched: pairs are 1:1, and validation-46 is 19/43. In a push, real
|
||
|
|
vulnerabilities are rare: v2 has 25 declared sites among 4,757 model-sink candidates. So the probability is
|
||
|
|
reported as calibrated **on the labelled mix**. The safety-net figures that do not depend on the mix are primary:
|
||
|
|
|
||
|
|
- recall at an operating point, and
|
||
|
|
- false BLOCKs per 100 benign deltas (population benign pins, pmpro-benign, benign history).
|
||
|
|
|
||
|
|
Precision at BLOCK is also reported at the benign-delta false-alarm rate, as `precision(pi) = pi*TPR /
|
||
|
|
(pi*TPR + (1-pi)*FPR)` for pi in {0.01, 0.05}.
|
||
|
|
|
||
|
|
**Operating points (Req 3.4, 7.3).** Both are chosen on inner out-of-fold scores only:
|
||
|
|
|
||
|
|
- **BLOCK**: the lowest threshold whose inner-fold precision has a Wilson 95% lower bound of at least 0.95, among
|
||
|
|
delta units. If none exists (expected today for most families), `block.offered = false, reason =
|
||
|
|
"precision_unreachable"`.
|
||
|
|
- **ADVISORY**: the highest threshold whose inner-fold recall is at least 0.90 (the M4 recall target). Its
|
||
|
|
precision is reported, not assumed.
|
||
|
|
- **DROP**: below ADVISORY.
|
||
|
|
|
||
|
|
Both points are reported on outer folds with recall, precision and false BLOCKs per 100 benign deltas. Each has a
|
||
|
|
repository-cluster bootstrap 95% interval (2,000 resamples of repositories) and a Wilson interval as a
|
||
|
|
cross-check when each repository gives one unit.
|
||
|
|
|
||
|
|
**Explanations (Req 3.5).** A feature's contribution is `coef_i * (x_i - mean_i) / sd_i`, and a missing
|
||
|
|
instrument contributes through its indicator. The top 5 by absolute contribution, with sign and the feature's
|
||
|
|
display value, are stored in the `decision` row and on the finding. Missing-instrument indicators are shown as,
|
||
|
|
for example, "engine: no coverage".
|
||
|
|
|
||
|
|
### 5. Model artifacts and versioning (Requirement 3.2)
|
||
|
|
|
||
|
|
A trained model is one canonical JSON (sorted keys, floats rendered with `repr`), stored as a content-addressed
|
||
|
|
blob `models/<sha256>.json`. The store gains `put_blob(prefix, data) -> sha` and `get_blob(prefix, sha)`,
|
||
|
|
generalising `put_facts`/`get_facts` (`plane/memory.py:219-255`). Both backends get it unchanged. On MinIO the
|
||
|
|
object has tags `kind=model`, `family` and `profile`, and bucket versioning records the version id.
|
||
|
|
`models/index.jsonl` lists every version. `models/adopted.json` maps (family, profile) to a sha: a single
|
||
|
|
overwritten object (a new version under MinIO versioning, and history kept in `index.jsonl` on FileStore).
|
||
|
|
|
||
|
|
```
|
||
|
|
{"model_id": "<sha256>", "schema_version": 1, "feature_set_digest": "...", "family": "injection" | "pooled",
|
||
|
|
"profile": "static" | "plane", "kind": "logistic-l2", "lambda": 1.0, "features": [{"name", "mean", "sd"}],
|
||
|
|
"coef": {...}, "intercept": ..., "calibration": {"method": "platt", "a": ..., "b": ..., "check": {...}},
|
||
|
|
"operating_points": {"block": {"threshold", "offered", "reason", "target_precision": 0.95},
|
||
|
|
"advisory": {"threshold", "target_recall": 0.90}},
|
||
|
|
"priors": "off" | ["wordpress", ...], "roles": "off" | "deterministic" | "model",
|
||
|
|
"data": {"store_index_digest", "label_set_digest", "sources": [...], "excluded": {...}, "rows", "repositories"},
|
||
|
|
"evaluation": {"folds_digest", "outer": {...}, "leave_one_source_out": {...}, "leave_one_framework_out": {...},
|
||
|
|
"strata": {...}, "curves": "curves/<sha>.json"},
|
||
|
|
"instruments": {"ruleset_digest", "engine_image", "runner_image", "verify_model", "prompt_digest", "roles_model"},
|
||
|
|
"seed": 0, "code_commit": "<git sha>", "created": "<iso>"}
|
||
|
|
```
|
||
|
|
|
||
|
|
`store_index_digest` is `improve.memory.index_digest` (`improve/memory.py:351`), and `label_set_digest` is the
|
||
|
|
sha256 of the sorted label rows used. Training reads rows through the store, so the same digests with the same
|
||
|
|
code commit give the same bytes (Req 3.2). `test_learn_train.py::test_reproducible` asserts this on both
|
||
|
|
backends, with the MinIO contract test gated as today.
|
||
|
|
|
||
|
|
**Adoption** is a maintainer commit. `ousast learn adopt <model_id>` writes `models/adopted.json` and exports the
|
||
|
|
artifact to `src/openultrasast/ruleset/decision/<family>-<profile>.json`, the copy that ships to users who have no
|
||
|
|
store. The mirror is the existing ledger rule: "adopting an accepted ledger stays a maintainer commit"
|
||
|
|
(`plane/tasks/loop.py:22-23`). A model whose feature set enables a prior group, or changes `roles`, can be adopted
|
||
|
|
only with a recorded `adopt` result of the engine-level experiment that justified it (section 6).
|
||
|
|
|
||
|
|
### 6. A/B experiments (Requirement 4)
|
||
|
|
|
||
|
|
**Manifest.** `plane/experiments/<id>.yaml`, a companion file, is referenced by the Run annotation
|
||
|
|
`openultrasast.io/experiment: <id>`. Each arm's Tasks carry `openultrasast.io/arm: A|B`. Annotations are stripped
|
||
|
|
before ax (`plane/manifests.py:4-6`), as `memory-key` is.
|
||
|
|
|
||
|
|
```
|
||
|
|
id: exp-001-known-callers
|
||
|
|
hypothesis: removing the known-callers line changes the rate at which verify flags a true site
|
||
|
|
arms: {A: {task_env: {}}, B: {task_env: {OUSAST_VERIFY_CALLERS: "off"}}} # everything else identical
|
||
|
|
unit: candidate # candidate | file | repository
|
||
|
|
pairing: paired # every unit in both arms
|
||
|
|
cluster: repository # resampling unit for intervals
|
||
|
|
order: {randomise: per_case, seed: 1} # which arm runs first per case
|
||
|
|
metric: {primary: "flag_any_pass_rate(positive units)", secondary: ["flag_any_pass_rate(fixed-pin negatives)", "usd_per_candidate"]}
|
||
|
|
test: {primary: "cluster_bootstrap_paired_diff", secondary: "exact_mcnemar", alpha: 0.05, sides: 2}
|
||
|
|
mde: 0.15
|
||
|
|
stopping: {looks: [0.5, 1.0], boundary: "obrien_fleming"}
|
||
|
|
budget_usd: {total: 15, per_arm: 7.5}
|
||
|
|
units_file: plane/experiments/exp-001-known-callers.units.jsonl # frozen list, digest recorded
|
||
|
|
```
|
||
|
|
|
||
|
|
`ousast plane experiment register <id>` checks the manifest, freezes the units file, and records the sha256 of
|
||
|
|
both as an `experiment` row (a new kind, with repo `experiments/<id>`). It refuses unless both files are committed
|
||
|
|
at `HEAD`. `ousast plane run` refuses a Run with an experiment annotation whose manifest or units digest differs
|
||
|
|
from the registered one.
|
||
|
|
|
||
|
|
**Randomisation and pairing.** Paired is the default: every unit runs in both arms, and a seeded per-case coin
|
||
|
|
decides which arm's tasks run first, so provider drift over a run does not align with arms. Only when a variant
|
||
|
|
cannot run on the same unit (for example, a change to candidate generation itself) are repositories assigned to
|
||
|
|
arms by a seeded, family-stratified shuffle. Assignment is recorded in the units file.
|
||
|
|
|
||
|
|
**Outcomes.** The `experiment-outcome` task (no model), after the arms, writes one `arm_outcome` row per (unit,
|
||
|
|
arm): outcome, usd from `unit_cost`, and pass details. Spend per arm is also in the plane's attribution table
|
||
|
|
(`ousast plane status`).
|
||
|
|
|
||
|
|
**Statistics.**
|
||
|
|
|
||
|
|
- *Primary*: the paired difference B - A in the per-unit outcome, with a 95% CI from a repository-cluster
|
||
|
|
bootstrap (10,000 resamples of repositories, with the units inside each). Units in one repository are
|
||
|
|
correlated, so a unit-level test would overstate certainty.
|
||
|
|
- *Secondary*: the exact McNemar test on discordant units (a binomial test with p = 0.5), reported beside it.
|
||
|
|
- *Sequential*: two looks, at half and all of the repositories (in a pre-shuffled order), with O'Brien-Fleming
|
||
|
|
boundaries (|z| >= 2.797 at the first look, 1.977 at the final). Stopping for futility is not binding.
|
||
|
|
- *Decision*: **adopt B** if the final CI excludes 0 in B's favour, and **reject B** if it excludes 0 against it.
|
||
|
|
For a cost-only change, **equivalent** means the TOST 90% CI of the detection difference lies within +-0.05 and
|
||
|
|
the cost CI shows a saving. Otherwise **inconclusive**, and the incumbent stays.
|
||
|
|
- *Engine-level experiments* (priors on/off, roles, feature groups, model variants) cost no model spend. The arms
|
||
|
|
are two training configurations evaluated on the same outer folds, and the unit is the repository. The metric is
|
||
|
|
recall at BLOCK (or at ADVISORY where BLOCK is not offered), plus false BLOCKs per 100 benign deltas.
|
||
|
|
|
||
|
|
**Where results land.** An `experiment_result` row, and `benchmarks/experiments/<id>/result.json` (arms, units,
|
||
|
|
the estimates at each look, decision, spend, manifest digest, code commit) in the same commit that records the
|
||
|
|
decision. Adoption of a winning pipeline variant is a maintainer commit that changes the default (a Task
|
||
|
|
template's env, or a `[models]` value) and cites the result's digest. The memory proposer's M1/M2 status edits
|
||
|
|
(`improve/memory.py`) become one variant family: a proposed status change runs as an engine-level experiment
|
||
|
|
instead of going straight to the benchmark gate.
|
||
|
|
|
||
|
|
**First experiment, fully specified (Req 4.4).** The question: `plane-increment-2.json` records declared sites
|
||
|
|
flagged in any pass as 17 for the plane vs 19 for the reference, of 20. The plane added the known-callers line
|
||
|
|
(`plane/tasks/verify.py:1-9, 164`, `CALLERS_CAP = 8` at :34), and the comparison is confounded (other changes,
|
||
|
|
stochastic passes, v2 is where it was seen).
|
||
|
|
|
||
|
|
- **Arms.** A is `verify` as today. B is `verify` with `OUSAST_VERIFY_CALLERS=off`, a new switch (about 3 lines)
|
||
|
|
that makes `callers_line` return empty. Same model (`deepseek-flash`), `PER_HUNT = 6`, `MAX_STEPS = 6`, same
|
||
|
|
candidates, same `facts.json`, same runner image digest, two passes (a, b) per arm.
|
||
|
|
- **Units: the development corpus, not v2** (v2 generated the hypothesis):
|
||
|
|
- population v1, 11 cases, whose engine scans are recorded under `~/ousast-results/independent-v1/scans/` (44
|
||
|
|
scans);
|
||
|
|
- pmpro, MediaWiki, WP Statistics (dev-php);
|
||
|
|
- the 27 full-repository pointer pairs in the verify families (`recipe` pairs of vibe-py and agent-vfc,
|
||
|
|
tier != title).
|
||
|
|
|
||
|
|
That is 41 repositories. No development repository has recorded verify candidates today. They are generated
|
||
|
|
model-free and frozen in the units file: per pin, the fix-changed functions, plus the top engine- and
|
||
|
|
quick-alert functions, up to 8 candidates per pin, on the vulnerable and the fixed pin. Only units where arm A's
|
||
|
|
callers line is non-empty are *treated*. The primary analysis is restricted to them, and the rest are reported.
|
||
|
|
- **Metric.** Primary: the per-positive-unit rate of "flagged in any pass" (the 17-vs-19 metric), with positives
|
||
|
|
from the label builder (fix-range functions on the vulnerable pin). Secondary: the flag rate on fixed-pin
|
||
|
|
negatives (false flags), and usd per candidate.
|
||
|
|
- **Sample size and MDE.** About 55 positive units across 41 repositories (about 28 dev + 27 pointer pairs). With
|
||
|
|
a discordance rate of about 0.15, alpha 0.05 and power 0.8, the paired MDE is `(1.96 + 0.84) * sqrt(0.15) /
|
||
|
|
sqrt(55)`, about **0.15**. The observed gap of 0.10 needs about 115 positive units, so an inconclusive result is
|
||
|
|
the likely and honest outcome. It is registered as such in advance, and a follow-up needs new units, not a
|
||
|
|
continuation.
|
||
|
|
- **Budget.** Hunts per pass per arm: 14 dev repositories x 2 pins x up to 3 files (84) + 27 pairs x 2 sides (54)
|
||
|
|
= 138. Four arm-passes give 552 hunts x $0.014, **about $7.7 expected**. The ceiling is **$15 total, $7.5 per
|
||
|
|
arm**, enforced by the task budgets (`plane/budget.py`). Candidate generation reuses the recorded v1 engine
|
||
|
|
scans. For dev-php it runs the engine once per pin on the host, one container at a time, from a frozen source
|
||
|
|
export.
|
||
|
|
|
||
|
|
### 7. Extrapolation and the one-time v3 check (Requirement 5)
|
||
|
|
|
||
|
|
**Learning curves (5.1).** `ousast learn curve --family F --profile P` works in two directions:
|
||
|
|
|
||
|
|
- *By repositories*: training on random subsets of 20, 40, 60, 80 and 100% of the outer-training repositories,
|
||
|
|
20 subsets per size, each evaluated on the same outer folds.
|
||
|
|
- *By sources*: training on 1..S of the leave-one-source-out sources, in all orders, capped at 50.
|
||
|
|
|
||
|
|
Per point: recall at ADVISORY, recall at BLOCK (where offered), PR-AUC, Brier score, and false BLOCKs per 100
|
||
|
|
benign deltas. Bands are the 2.5/97.5% quantiles over subsets x repository bootstrap. An inverse power law
|
||
|
|
`m(n) = a - b * n^-c` is fit to each curve. The projection to 1.5x and 2x the repositories is reported only as a
|
||
|
|
band, and only where the fit's own bootstrap CI for `c` excludes 0.
|
||
|
|
|
||
|
|
**Expected detection on unseen code (5.2).** Per family and per language (and per framework, Req 8.3): the
|
||
|
|
outer-fold and leave-one-source-out intervals at both operating points, with priors off and with adopted priors.
|
||
|
|
A stratum with fewer than 10 positive repositories or 10 negative repositories, or whose 95% interval on recall is
|
||
|
|
wider than 0.5, is printed as **"insufficient data (n repositories)"**, with no number. Today that applies to
|
||
|
|
memory, prototype, PHP untrusted_destination, and every plane-profile family until the harvest Run.
|
||
|
|
|
||
|
|
**The v3 check (5.3), exactly once.**
|
||
|
|
|
||
|
|
1. *Before.* The engine and every operating point are adopted, and population v3 is still reserved and unread by
|
||
|
|
this spec's code (it is absent from `learn/sources.toml`). `ousast learn predict --population-name
|
||
|
|
population-v3-php` writes `benchmarks/independent/prediction-v3.json` and it is committed:
|
||
|
|
- the adopted model ids and shas per (family, profile);
|
||
|
|
- `feature_set_digest`, priors and roles settings;
|
||
|
|
- the operating points;
|
||
|
|
- the frozen analyzer source hash (`benchmarks/push/freeze_source.sh`);
|
||
|
|
- for each family v3 may contain, the predicted recall and precision intervals from the PHP stratum where
|
||
|
|
sufficient, else the pooled-language interval with the label "no PHP stratum";
|
||
|
|
- the predicted false BLOCKs per 100 benign deltas;
|
||
|
|
- the reference to `protocol-v3.md`, written by the population's owner, not by this spec.
|
||
|
|
|
||
|
|
Only the population's name is needed; its file is not opened.
|
||
|
|
2. *Run.* v3 is scored under protocol v3, once, by its owner's runner, with the engine's decisions added to the
|
||
|
|
recorded outputs.
|
||
|
|
3. *After.* `benchmarks/independent/result-v3-engine.json` holds, per family, the measured point and interval
|
||
|
|
beside the predicted interval, `inside | outside`, the calibration check on v3, and the reach misses. v3 is
|
||
|
|
then added to `learn/sources.toml` as spent, and the next model may train on it. Its numbers never again
|
||
|
|
qualify anything.
|
||
|
|
|
||
|
|
### 8. Integration (Requirements 6, 7.3, 7.5)
|
||
|
|
|
||
|
|
**Lookup.** The model comes from, in order:
|
||
|
|
|
||
|
|
1. `[decision] model` in the config (a path);
|
||
|
|
2. the store's `models/adopted.json` when `OUSAST_MEMORY` is set;
|
||
|
|
3. the packaged `ruleset/decision/<family>-<profile>.json`;
|
||
|
|
4. none.
|
||
|
|
|
||
|
|
`learn/decide.py` is pure Python: `load_models(...) -> Models` and `decide(record, models) -> Decision(p,
|
||
|
|
operating_point, model_id, top, offered)`.
|
||
|
|
|
||
|
|
**`ousast scan`.** A `decide` stage runs after the findings are complete and before `findings.json` is written
|
||
|
|
(`cli.py:517-520`). It builds `static` records with `build_for_scan` from the quick findings, the engine result,
|
||
|
|
`analyze_entry_points` and the `repo-facts` functions (source-only), plus deterministic roles. It scores each
|
||
|
|
candidate and attaches `decision` to the finding: `{p, operating_point: block|advisory|drop, model_id,
|
||
|
|
top_features, offered}`. Candidates at or above ADVISORY are reported. Dropped ones go to `dropped_by_engine.json`,
|
||
|
|
beside `shadow_findings.json`. Shadow-status findings enter as candidates too, so a rule's enabled/shadow status
|
||
|
|
changes the report only if the decision changes (Req 6.3). The shadow split at `cli.py:503-508` applies only in
|
||
|
|
the fallback. `--fail-on blocked` is a new choice (exit 1 on any BLOCK), and `findings` keeps its meaning.
|
||
|
|
Markdown, SARIF (`properties.decision`) and `manifest.json` (`decision: {model_ids, fallback, reason}`) carry the
|
||
|
|
fields.
|
||
|
|
|
||
|
|
**`ousast pre-push`.** Candidates are the `CandidateDelta`s from `compare_evidence`, enriched with quick-rule
|
||
|
|
hits and deterministic roles on the changed functions of head and base. `admit_candidates` (`push/policy.py:879`)
|
||
|
|
gains `engine: Models | None`:
|
||
|
|
|
||
|
|
- With a model, the capability-eligibility reasons `capability_unavailable`, `capability_ambiguous`,
|
||
|
|
`capability_disabled`, `capability_unevaluated` and `operation_semantics_mismatch` are replaced by the engine's
|
||
|
|
reasons: `engine_below_block`, `engine_block_not_offered` (family insufficient or calibration failed), or none.
|
||
|
|
- Every structural reason stays a hard requirement: `comparison_provenance_mismatch`, `insufficient_rung`,
|
||
|
|
`unchanged`, `change_unsupported`, `witness_unresolved`, `location_unresolved`, `family_semantics_mismatch`,
|
||
|
|
`claim_discharged` and `dependency_unresolved` (`push/policy.py:909-960`). `PushReport` invariants
|
||
|
|
(`push/report.py:48-97`) require witnesses, locations, consequence and repair for actionable defects.
|
||
|
|
- With no capability, consequence and repair come from family-level templates shipped beside the model:
|
||
|
|
language-level text, never framework text.
|
||
|
|
- `--mode blocking` blocks only on BLOCK. `--mode advisory` lists ADVISORY and above. Each disposition carries
|
||
|
|
`{p, operating_point, model_id, top_features}` in the artifact and one line in the compact report
|
||
|
|
(`render_report`, `push/report.py:214`).
|
||
|
|
- The 30 s default deadline (`cli.py`, `--deadline 30.0`) covers feature building. Everything in the `static`
|
||
|
|
profile is source-only or already computed by the scan. Model roles run only with `--model-config`.
|
||
|
|
|
||
|
|
**Plane Runs.** The `decide` task (no model) writes `decisions.jsonl`, `remember` stores `decision` rows, and
|
||
|
|
`ousast plane status` prints BLOCK/ADVISORY counts per case.
|
||
|
|
|
||
|
|
**Fallback (6.2).** With no model, or a family below the floor, today's behaviour applies unchanged: rule
|
||
|
|
statuses, quick/engine findings as they are, pre-push with the empty capability registry. The report says so in
|
||
|
|
one line: "decision engine: not used (no adopted model | family insufficient data | calibration failed) --
|
||
|
|
findings are today's rule output". A family whose BLOCK is not offered still gets ADVISORY decisions.
|
||
|
|
|
||
|
|
**User adaptation (7.5)** is opt-in: `[decision] adapt = true`. It keeps the user's dismissals in
|
||
|
|
`.openultrasast/decisions/dismissals.jsonl` in their repository, as finding fingerprints with the model id, and
|
||
|
|
applies them after the engine: a dismissed fingerprint is suppressed, and optionally a per-repository intercept
|
||
|
|
offset is fitted on at least 10 dismissals. It never writes to the memory store. The label builder refuses any row
|
||
|
|
with `origin: user`. It is evaluated separately: on held-out training repositories, by replaying the first half
|
||
|
|
of their labels as dismissals and measuring the second half. It is reported as "adaptation", never merged into the
|
||
|
|
generalisation numbers.
|
||
|
|
|
||
|
|
## Data Models
|
||
|
|
|
||
|
|
- `features` row: envelope (`id, kind, repo, pin, run, task, population, split, image`) + `{candidate, family,
|
||
|
|
language, profile, schema_version, feature_set_digest, unit, base?, x, instruments}`.
|
||
|
|
- `label` row: envelope + `{candidate, family, label, source, source_ref, unit, direction?, weight, split, group,
|
||
|
|
frameworks, created, evidence}`. Written only by `ousast learn labels`.
|
||
|
|
- `decision` row: envelope + `{candidate, family, profile, model_id, p, operating_point, offered, top: [{feature,
|
||
|
|
value, contribution}]}`.
|
||
|
|
- `experiment` row: `{experiment, manifest_sha256, units_sha256, registered, code_commit}`. `arm_outcome` row:
|
||
|
|
`{experiment, arm, unit, outcome, passes, usd}`. `experiment_result` row: `{experiment, look, estimate, ci,
|
||
|
|
mcnemar_p, decision, spend}`.
|
||
|
|
- `KINDS` (`plane/memory.py:52`) gains `features, label, decision, experiment, arm_outcome, experiment_result`,
|
||
|
|
and `TAG_FIELDS` is unchanged (`family` is already a tag).
|
||
|
|
- Blobs: `models/<sha>.json`, `models/index.jsonl`, `models/adopted.json`, `curves/<sha>.json`,
|
||
|
|
`labels/<sha>.jsonl`.
|
||
|
|
- Files: `learn/sources.toml`, `ruleset/frameworks.toml`, `ruleset/decision/*.json`,
|
||
|
|
`plane/experiments/<id>.yaml` + `.units.jsonl`, `benchmarks/experiments/<id>/result.json`,
|
||
|
|
`benchmarks/independent/prediction-v3.json`, `result-v3-engine.json`.
|
||
|
|
|
||
|
|
## Error Handling
|
||
|
|
|
||
|
|
- Every failure of the feature builder is loud. An instrument that did not read its input records `state:
|
||
|
|
failed`, and a Run's `features` task fails when every instrument failed for a case. A plausible all-zero record
|
||
|
|
is impossible because zero requires `state: ran`.
|
||
|
|
- `validate_record` rejects unknown features, out-of-vocabulary enums and label fields, naming the feature and the
|
||
|
|
source file.
|
||
|
|
- The label builder exits 2 on a source that is not in `sources.toml`, a population whose status is not recorded,
|
||
|
|
or a group that straddles an outer fold after the merges. The error names the repository.
|
||
|
|
- Training refuses a (family, profile) below the floor and writes an "insufficient data" artifact stub, so
|
||
|
|
`decide` can say why. It refuses when a label's `group` appears in both an outer training and evaluation fold,
|
||
|
|
as an assertion before fitting. Newton non-convergence after 50 iterations fails with the lambda and the
|
||
|
|
condition number.
|
||
|
|
- `decide` with a model whose `schema_version` or `feature_set_digest` differs from the builder's falls back,
|
||
|
|
with the reason in the report, and never scores a mismatched vector.
|
||
|
|
- Experiments: a registration mismatch refuses the Run. A budget ceiling ends an arm `unfinished` (`budget.py`),
|
||
|
|
and the analysis then reports "incomplete: arm B stopped at $x" and records no decision.
|
||
|
|
- `put_blob` on FileStore applies the 1 GiB free-space refusal (`plane/memory.py:341`).
|
||
|
|
|
||
|
|
## Testing Strategy
|
||
|
|
|
||
|
|
- `tests/test_learn_schema.py`: allow-list, name invariance, label fields excluded, missing vs zero (section 1).
|
||
|
|
- `tests/test_learn_labels.py`, on fixture populations and pairs:
|
||
|
|
- positives and negatives per rule, including a fixed-pin function the fix did not touch (not a negative), and
|
||
|
|
an unmatched vulnerable-pin candidate (unlabelled);
|
||
|
|
- `unscorable` and title-tier exclusion;
|
||
|
|
- an adjudication mapping;
|
||
|
|
- a source not in `sources.toml`, which cannot label, checked with a fixture population named like v3 and a test
|
||
|
|
that asserts the builder never opens its file (`open` monkeypatched);
|
||
|
|
- CVE-based group merge.
|
||
|
|
- `tests/test_learn_folds.py`:
|
||
|
|
- no group in both train and test, for outer, leave-one-source-out and leave-one-framework-out folds;
|
||
|
|
- inner folds nested;
|
||
|
|
- determinism by seed.
|
||
|
|
- `tests/test_learn_train.py`:
|
||
|
|
- Newton fit on a separable-with-noise fixture;
|
||
|
|
- `test_matches_sklearn` (optional extra);
|
||
|
|
- reproducible bytes from the same store snapshot on FileStore and on MinioStore (gated as today);
|
||
|
|
- weights cap per repository;
|
||
|
|
- floor refusals.
|
||
|
|
- `tests/test_learn_calibrate.py`: Platt on a known miscalibration, the slope/intercept check, and the
|
||
|
|
ADVISORY-only downgrade.
|
||
|
|
- `tests/test_learn_decide.py`: operating points, explanations (top-5 order and signs), the missing-instrument
|
||
|
|
display, and version-mismatch fallback.
|
||
|
|
- `tests/test_learn_experiments.py`:
|
||
|
|
- registration digest and refusal;
|
||
|
|
- paired bootstrap CI on a fixture with a known difference;
|
||
|
|
- exact McNemar values against hand-computed binomials;
|
||
|
|
- O'Brien-Fleming boundaries;
|
||
|
|
- adopt / reject / inconclusive / equivalent decisions.
|
||
|
|
- `tests/test_semantic_priors.py`:
|
||
|
|
- the lint test (no untagged entry contains a `frameworks.toml` symbol);
|
||
|
|
- `priors="off"` removes exactly the tagged entries;
|
||
|
|
- the split entries keep their language-level calls.
|
||
|
|
- `tests/test_learn_roles.py`: wrapper inference to depth 3 on a fixture, and unclassified chunks are never "no
|
||
|
|
roles".
|
||
|
|
- `tests/test_plane_features.py`, `test_plane_decide.py`: fixture run directories from the validation-46 shapes.
|
||
|
|
`features` drops `site_match`/`in_fix_range`, and `remember` stores `features`/`decision` rows.
|
||
|
|
- `tests/test_push_decision.py`: admission with an engine replaces only the capability reasons, and every
|
||
|
|
structural reason still blocks admission. `PushReport` invariants hold with engine templates.
|
||
|
|
- `tests/test_scan_decision.py`: the scan fallback line, `dropped_by_engine.json`, and that a shadow/enabled
|
||
|
|
status flip without a decision change leaves the report unchanged.
|
||
|
|
- Regression net: the existing gate, pair and plane tests. `test_benchmark_output_is_byte_identical_to_golden_baseline`
|
||
|
|
holds with no model adopted (the fallback is today's path).
|
||
|
|
- Joern-gated tests run only with the mount recipe (memory note "Joern-gated tests run nowhere"). The engine-alert
|
||
|
|
harvest is verified by its own read-count check, not assumed.
|
||
|
|
|
||
|
|
## Requirement Traceability
|
||
|
|
|
||
|
|
| Requirement | Design section | Verified by |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| 1.1 one record per candidate from every instrument | 1 | `test_plane_features.py`, `test_learn_schema.py` |
|
||
|
|
| 1.2 missing is not negative | 1 (instrument state, `null`) | `test_missing_is_not_zero` |
|
||
|
|
| 1.3 versions and digests for rebuild | 1 (versioning), 5 | `test_learn_train.py::test_reproducible` |
|
||
|
|
| 2.1 positives/negatives by source | 3 | `test_learn_labels.py` |
|
||
|
|
| 2.2 plane verdict is a feature, not a label | 1, 3 | `test_label_fields_never_reach_features`; label rules |
|
||
|
|
| 2.3 provenance; reserved populations unusable | 3 (`sources.toml`, fail-closed) | `test_learn_labels.py` (v3-named fixture never opened) |
|
||
|
|
| 3.1 simple, calibrated, per family or pooled | 4 | `test_learn_train.py`, `test_learn_calibrate.py` |
|
||
|
|
| 3.2 reproducible, versioned | 5 | reproducibility test on both backends |
|
||
|
|
| 3.3 population-level folds, no repository in both | 4 (folds) | `test_learn_folds.py` + pre-fit assertion |
|
||
|
|
| 3.4 operating points on training folds | 4 | `test_learn_decide.py` |
|
||
|
|
| 3.5 explanations stored | 4, 8 | `test_learn_decide.py`, `test_scan_decision.py` |
|
||
|
|
| 4.1 pre-registered experiment | 6 (manifest, register) | `test_learn_experiments.py` |
|
||
|
|
| 4.2 paired, assignment and spend recorded | 6 | `arm_outcome` rows; `ousast plane status` |
|
||
|
|
| 4.3 adopt only on a CI excluding zero or equivalence | 6 (decision rule) | `test_learn_experiments.py` |
|
||
|
|
| 4.4 known-callers experiment first | 6 (exp-001) | commit 7 record |
|
||
|
|
| 5.1 learning curves with bands | 7 | `ousast learn curve` output; unit test on a fixture |
|
||
|
|
| 5.2 intervals per family and language; insufficient data | 7 | `test_learn_decide.py` (floor), report fixture |
|
||
|
|
| 5.3 v3 checked once, after freezing | 7 | `prediction-v3.json` committed before `result-v3-engine.json` |
|
||
|
|
| 6.1 probability, operating point, top features in reports | 8 | `test_scan_decision.py`, `test_push_decision.py`, `test_plane_decide.py` |
|
||
|
|
| 6.2 fallback said in report | 8 | `test_scan_decision.py` |
|
||
|
|
| 6.3 rule status no longer changes output alone | 8 | status-flip test |
|
||
|
|
| 7.1 repository-grouped folds across corpora | 3 (group, CVE merge), 4 | `test_learn_folds.py` |
|
||
|
|
| 7.2 repository-agnostic allow-list with test | 1 | `test_learn_schema.py` |
|
||
|
|
| 7.3 BLOCK/ADVISORY on unseen repositories; delta unit | 1 (delta), 4, 8 | outer-fold report; `test_push_decision.py` |
|
||
|
|
| 7.4 calibration checked out of repository; else ADVISORY only | 4 | `test_learn_calibrate.py` |
|
||
|
|
| 7.5 opt-in adaptation outside the numbers | 8 | label builder refuses `origin: user`; separate report |
|
||
|
|
| 8.1 language-level knowledge; roles inferred | 2 | `test_learn_roles.py` |
|
||
|
|
| 8.2 tagged priors, off by default, kept by A/B | 2, 6 | `test_semantic_priors.py`; engine-level experiment records |
|
||
|
|
| 8.3 leave-one-framework-out, per framework | 2, 4 | `test_learn_folds.py`; report strata |
|
||
|
|
| 8.4 share depending on priors | 2, 7 | report columns priors off / adopted / difference |
|
||
|
|
|
||
|
|
## Commit sequence, mapped to future tasks
|
||
|
|
|
||
|
|
Each commit is gated on the full suite's own exit code, `ruff` and `mypy`. None changes a reported number until
|
||
|
|
commit 9 adopts a model, and without an adopted model every path is today's.
|
||
|
|
|
||
|
|
1. **Schema and features** (1.1-1.3, 7.2): `learn/schema.py`, `learn/features.py` (host builder), the new
|
||
|
|
`KINDS`, and `plane/tasks/features.py` + the `remember` input, with tests.
|
||
|
|
2. **Framework tags** (8.2, 8.4): `ruleset/frameworks.toml`, the tags and splits of section 2, `priors=` in the
|
||
|
|
semantic and quick loaders (default `all` for today's scan), and the lint test. Verify that
|
||
|
|
`test_benchmark_output_is_byte_identical_to_golden_baseline` is unchanged with `priors=all`.
|
||
|
|
3. **Roles** (8.1): `learn/roles.py` (wrapper inference), `plane/tasks/roles.py` (model roles from
|
||
|
|
`model_sinks.py`), and the vocabulary overlay for the engine. Fixture tests, no model call in tests.
|
||
|
|
4. **Labels** (2.1-2.3, 7.1): `learn/sources.toml`, `learn/labels.py`, `ousast learn labels`, group
|
||
|
|
normalisation and CVE merge, and a first label snapshot committed as a measurement record (counts only).
|
||
|
|
5. **Harvest** (1.1, data): a plane Run of verify a/b on the 78 verify-family pairs and the development cases
|
||
|
|
(about $6, ceiling $10), model roles on the candidate files (ceiling $10), and engine features for the pair
|
||
|
|
corpus on the host (frozen source, one container at a time, in the background).
|
||
|
|
6. **Train, calibrate, evaluate** (3.1-3.5, 5.1-5.2, 7.3-7.4, 8.3): `learn/{folds,train,calibrate,evaluate}.py`,
|
||
|
|
the model blob API in `plane/memory.py`, `ousast learn train|evaluate|curve`, and the first evaluation report
|
||
|
|
(priors off), committed under `benchmarks/measurements/<date>-decision-engine-v1/`.
|
||
|
|
7. **Experiments** (4.1-4.4): `learn/experiments.py`, the `register` command, `experiment-outcome`, the Run
|
||
|
|
annotation checks, `OUSAST_VERIFY_CALLERS`, and exp-001 registered, run and recorded.
|
||
|
|
8. **Integration** (6.1-6.3, 7.3, 7.5): the `decide` task, the `scan` stage, `pre-push` admission with the engine,
|
||
|
|
report fields, the fallback line, and the opt-in adaptation layer.
|
||
|
|
9. **Adopt** (3.2, 8.2): `ousast learn adopt`, the packaged models for families that pass, and the priors-on/off
|
||
|
|
engine experiment per framework with a recorded decision.
|
||
|
|
10. **v3 check** (5.3): `prediction-v3.json` is committed first, then v3 is run by its owner under protocol v3, and
|
||
|
|
`result-v3-engine.json` is committed. Nothing between the two commits may touch the engine, features, priors
|
||
|
|
or thresholds.
|
||
|
|
|
||
|
|
## Risks
|
||
|
|
|
||
|
|
- **Label scarcity decides what can be trained.** The status of each family today:
|
||
|
|
- memory (7 repositories, 172 of its 176 pairs from openssl and curl) and prototype (6) are **insufficient
|
||
|
|
data**. Users get today's behaviour there, labelled as such.
|
||
|
|
- PHP has no pairs, and only about 13 population repositories. The v3 prediction will mostly be the pooled
|
||
|
|
interval, marked "no PHP stratum", and it will be wide.
|
||
|
|
- The plane profile has 43 rows from one population until the harvest Run.
|
||
|
|
- BLOCK at 95% precision is likely **not reachable** for most families on this data. The design reports "BLOCK
|
||
|
|
not offered" instead of lowering the target.
|
||
|
|
- **Weak signals.** The pair baseline has quick-rule labelled recall 0.24 and Youden -0.013 overall
|
||
|
|
(`pairs.json` `overall`). The v2 engine recall is 0/17 (`results-v2.json`). A calibrated model over weak signals
|
||
|
|
is honest but may be close to the base rate. Recall at ADVISORY 0.90 may need an ADVISORY threshold so low that
|
||
|
|
precision is poor. That is reported, not tuned away on outer folds.
|
||
|
|
- **Leakage paths and their guards.**
|
||
|
|
- Label fields in features (`site_match`, `in_fix_range`, fixed-pin alerts): the allow-list and test.
|
||
|
|
- The same repository under two names or corpora: normalised group plus CVE merge.
|
||
|
|
- Hand rules and framework priors written after seeing a training repository (the `$wpdb` rule from PMPro, the
|
||
|
|
hook source from WP Statistics, `taint.sc:911-949`): the priors-off default, and leave-one-framework-out.
|
||
|
|
Rules may also carry `taught_by = [repo]`, which masks their features in folds that evaluate those
|
||
|
|
repositories. It is backfilled best-effort from comments and git history, and its absence is stated.
|
||
|
|
- The mechanism exporter's train-on-test leak (`closed-loop-train-on-test-leak.md`): no exporter output is a
|
||
|
|
feature, and mechanism buckets come from the fixed vocabulary.
|
||
|
|
- Verify and tie-break design fitted on v2: the v2 fold is marked design-informed.
|
||
|
|
- Threshold and lambda selection on outer folds: nested folds, and a pre-fit assertion.
|
||
|
|
- v3: fail-closed sources, never opened.
|
||
|
|
- User dismissals: refused by the label builder.
|
||
|
|
- **Prevalence shift.** Calibration on 1:1 pairs overstates the probability in a real push. The primary safety-net
|
||
|
|
figures (recall; false BLOCKs per 100 benign deltas) do not depend on the mix. The benign-delta sample is small:
|
||
|
|
28 population benign deltas plus pmpro, so the 95% upper bound for 0 false BLOCKs is about 0.12 per push. Benign
|
||
|
|
history deltas are the planned remedy and are reported with and without.
|
||
|
|
- **Framework inference is itself a model.** Model roles cost money and can be wrong. `scan` without `[models]`
|
||
|
|
gets only wrapper inference, whose reach on framework-heavy code without dependency source is poor. The report
|
||
|
|
says which role source ran. Both are measured by the roles-on/off engine experiment. Nothing assumes they help.
|
||
|
|
- **Cost on the 7 GB host.**
|
||
|
|
- Model spend is small: the harvest about $6, exp-001 about $8, model roles on candidate files about $10, all
|
||
|
|
under task ceilings.
|
||
|
|
- Engine features are the time cost: about 427 pairs x 2 sides x 30-60 s of JVM and CPG per excerpt is 7-14
|
||
|
|
hours serial, plus dev-php full-repository scans of 200-2,000 s each (`results-v1.json` `seconds`). They run
|
||
|
|
once per engine image, from a frozen source export, one container at `--memory 3g` at a time, never beside the
|
||
|
|
kind cluster's verify pods. Results are cached by (content sha, image id) in the store. The home volume was at
|
||
|
|
98%, and records are small (about 1 KB per candidate), but each engine run's scratch output must be cleaned
|
||
|
|
per case.
|
||
|
|
- **Experiments will often be inconclusive.** With 41 development repositories the MDE is about 15 points.
|
||
|
|
Pre-registering that and recording "inconclusive" is the intended behaviour. The failure mode to avoid is
|
||
|
|
extending an experiment after looking.
|
||
|
|
- **Pure-Python numerics.** Newton on near-separable data (a feature that alone separates a small family) can
|
||
|
|
diverge. L2 with lambda >= 0.01 and the iteration cap bound it, and the sklearn cross-check catches drift.
|