Every README and docs page checked against ousast --help, the code and the committed records. New docs/deployment.md (in the nav): what runs where today, this host's setup (up.sh, doctor, smoke Run, footprint), step-by-step guidance for a separate Kubernetes cluster with implemented vs planned stated per item (no cluster deployment has been made; kube context and doctor's registry check are hard-coded in doctor.py, egress.py, router.py), and what the plane/ manifests need changed. Decision-engine pages state slice 2 (six families, pooled and within-pair AUC, BLOCK offered nowhere, canary below 0.9 for three families, positives-only inflation) and that the engine is not adopted; exp-002 registered, not run, no result quoted. Fixed: model/judge.py reference (now model/pipeline.py), missing-key behaviour, .env reference (OPENROUTER_EMBEDDING_MODEL, store variables), docs extra, plane memory-normalise and ousast index listed, ax README's cluster table made precise. RELEASE_NOTES gets an Unreleased entry. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
8.9 KiB
Architecture
How a scan works, how a finding earns its place, how severity and the project score are
decided, and how the ruleset improves itself. State as of 2026-10-02; module paths are under
src/openultrasast/.
The scan pipeline
The CLI is thin; HarnessRuntime owns the lifecycle and writes an event trace for every stage
(trace/events.jsonl). The stages a mode requests are MODE_STAGES in stages.py:
quick runs STATIC, standard adds MAP, deep adds REGRESS.
policy_load ─▶ ruleset_load ─▶ policy_check (fail loud on an ungoverned CWE)
─▶ static_mapping (SARIF ingest) ─▶ preprocess ─▶ entry_point_mapping
─▶ rank ─▶ calibrate (demote previously rejected scopes)
─▶ findings quick, deep: quick_findings (language-scoped pattern rules)
│ standard: hunter_pool (the same rules, scheduled per ranked target)
─▶ verify ─▶ record_calibration ─▶ fusion (standard only, deterministic) ─▶ score
─▶ MAP (standard, deep)
│ map complexity map and hotspots
│ overlay semantic overlay over the findings (tree-sitter with the `semantic` extra)
│ obligations absence bugs: an obligated operation with no governing guard
│ model one Joern CPG per repository; taint, dominance and configuration arbiters
│ tool hunter only with `[models] hunter`
─▶ REGRESS (deep) sandboxed regression snippets ─▶ verdicts.json, worth-fixing
─▶ report.md, report.sarif, manifest.json
The model layer (model/, cpg/) is the core of standard. It builds one code property
graph per scan with Joern (joern-parse, then batched joern --script queries from
cpg/queries/*.sc) and dispatches each question to the arbiter of its spec type: taint
(source to sink with no discharging sanitizer), dominance (does a guard govern the obligated
operation) and configuration (constant abstraction over a value). Taint facts ship for C,
Java, JavaScript, PHP and Python (ruleset/semantic/*.toml). Without Joern the layer records
cpg_unavailable and the scan is otherwise unchanged; the Docker image ships Joern and
php-cli (which the PHP frontend needs).
The model layer reports on a rung ladder:
execution_confirmed a test or sandbox reproduced it
model_entailed the graph decided: a flow with no discharge, an obligation with no
guard, a permissive value
model_corroborated the graph found the shape; a judge agreed on the residual question
suspicion proposed, nothing corroborated it
The LLM (model/pipeline.py, residual_question) is asked only the residual question the graph
cannot settle, for a flow the graph already found; it can confirm or contradict, never create a
flow. When the graph resolved nothing, the enumerator's candidates (at most
MAX_JUDGED_CANDIDATES, 8) are put to it, and anything it affirms is reported at suspicion.
Without a provider key neither question is asked and only the graph's entailed findings are
reported.
REGRESS (regress/, sandbox/) runs only when docker info succeeds. Each promoted
candidate gets a small language recipe (Python, JavaScript, C) that loads the candidate's
source inside a container with no network, a read-only /workspace, a non-root user and the
[sandbox] limits. A structural deny-list rejects snippets mentioning sockets, curl, host
networking or writes under /workspace before anything runs.
Evidence ladder: how a false positive is eliminated
Evidence level is a state machine, not a label a model may assign. The verifier
(verification.py) runs on independent context and enforces:
suspicion ─▶ static_corroboration ─▶ crash_reproduced ─▶ root_cause_explained
─▶ exploit_demonstrated ─▶ patch_validated
- below
static_corroboration:NEEDS_EVIDENCE; static_corroborationwith reachability unknown:REJECTED, with a tie-breaker demanding call-graph, route, CLI, parser or dynamic evidence;static_corroborationwith function-level reachability:ACCEPTED.
A rejected finding becomes a scoped false-positive learning (calibration.py) with a
reason from a fixed taxonomy (unreachable_path, missing_attacker_control,
sanitizer_disproved, static_rule_mismatch, incorrect_model_assumption, duplicate,
insufficient_impact, unsupported, contradicted, unverified) and a scope. A rejection in
auth/ demotes only auth/..., never the whole class
(tests/test_calibration.py::test_scoped_false_positive_demotion_does_not_suppress_class_globally).
How a false negative is surfaced
Every expected vulnerability that no finding matched becomes a BenchmarkCalibrationRecord in
calibration_records.json, with a next_improvement_candidate (a static rule, SARIF source,
entry-point mapping, retrieval package, hunter prompt, dynamic reproducer or skill route).
Once a miss is closed the detection gate keeps it closed in CI.
Central CWE policy and the project score
Policy (policy/verycode.py): a vendored CWE_Score.tsv (verycode-policies) is the single
source of truth. Each CWE carries a flaw category, a severity (0-5) and static/dynamic scope
flags. A finding's severity comes only from policy, keyed on CWE. CWEs that are not static
resolve to 0 and are report-only. assert_rules_resolve runs as the policy_check stage: an
enabled rule naming an ungoverned CWE aborts the scan. Verycode has no CWE-120, so the C/C++
buffer rules carry CWE-121.
Score (scoring/project_score.py): each finding's penalty is
SEV_WEIGHT[severity] x REACH_MULT[reachability], and the score is 100 * e^(-total/k) with
k = 60.
| Severity | Weight | Reachability | Multiplier | |
|---|---|---|---|---|
| 5 | 50 | reachable |
1.0 | |
| 4 | 25 | inferred-file-surface |
0.6 | |
| 3 | 10 | unknown |
0.4 | |
| 2 / 1 / 0 | 2 / 1 / 0 |
A confirmed false positive lowers a finding's reachability multiplier instead of deleting the
rule. A severity-5 reachable finding always fails the score gate; score < min_score
(default 80) fails it only with [score] blocking = true. The score stage writes
score.json and merges it into manifest.json.
Self-improvement
Scoped calibration (every scan)
scan ─▶ verify ─▶ non-accepted findings ─▶ scoped learnings
(.openultrasast/calibration/false_positive_learnings.json)
next scan ─▶ rank ─▶ calibrate (demote rejected scopes) ─▶ findings
record_calibration (after verify) merges this run's non-accepted outcomes into the ledger,
de-duplicated by finding ID; calibrate (after rank) demotes the priority of every scope
that produced a rejection and writes applied_calibrations.json. Demotion is scoped and
reversible (tests/test_pipeline_calibration.py).
Rule-level loop: ousast improve
Each round (improve/evolve.py): benchmark with the current ledger, per-rule signals
(rule_signals.json), bounded proposals, validation (improve/validator.py), replay smoke,
re-benchmark, and a hard gate. The levers are rule status (enabled -> shadow before
disabled) and score constants; the loop can never edit pattern text
or the authoritative CWE severity. A round is accepted only if recall >= 90%, FP < 10%, the
project score did not regress and no matched finding was lost; otherwise the ledger is not
written. With --pair-catalog, every round is also gated per provenance profile on the
catalog's holdout pairs (--no-pair-gate skips this and is not for accepted ledgers). The same
detection gate runs in CI (python -m openultrasast.gate).
Proposals from plane memory
With --memory [STORE], ousast improve also reads the rows plane runs stored per repository
and pin (improve/memory.py). Two deterministic rules turn them into rule-status proposals: a
rule whose alerts are repeatedly false across repositories is shadowed, and a shadow rule that
keeps hitting agreed, declared vulnerable sites is re-enabled. Rows from holdout pairs, from the
gated manifest's own cases and from any --qualify-population are dropped before any rule sees
them. Each proposal records the rows it came from (memory_proposals.jsonl next to the
journal) and goes through the same validator and gate.
The loop as a plane Run
ousast plane workspaces <population> --validation-set ... --loop appends the improvement loop
to a generated Run: alerts per case (the quick scan on the vulnerable and fixed pins), then
the steps snapshot (task memory-snapshot), measure, propose and improve (plane/tasks/loop.py), each model-free
with a zero budget. The snapshot applies the same train-on-test guard before any row leaves the
store. A recorded live run: benchmarks/measurements/2026-09-30-harnessx-removal-loop/.
For PHP, which quick mode covered with zero rules when that run was made, ousast plane alerts-engine produces a Run's alerts from the engine image on the host instead
(benchmarks/measurements/2026-09-30-php-engine-alerts/).