Files
Open_Ultra_SAST/docs/architecture.md
T
Marius CiepluchandClaude Fable 5.1 d0ac8b5f3d docs: detection techniques -- representations, taint, obligations, ranking, the model's role
New page docs/detection-techniques.md: the three representations (regex over the
file, tree-sitter IR, Joern CPG) and what each can see; Joern as a section of its
own (frontends, one graph per language partition, census as the instrument check,
batching against the measured cost curve, deadlines, degradations, where the engine
sits in the plane, cost and misses with records); taint from fact tables to query to
verdict with each refinement traced to its record or commit; obligations, dominance,
constant abstraction, census; regions, ranking ties and budgets; the residual
question and MAX_JUDGED_CANDIDATES; REGRESS verdicts and the unassigned
execution_confirmed rung; an honest table of what each technique misses.

Nav entry under "How it works" after Scanning; one link from the index; one
cross-link line each in scanning.md and architecture.md.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
2026-10-02 19:48:32 +02:00

165 lines
9.0 KiB
Markdown

# 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 representations and techniques behind each stage
are explained in [Detection techniques](detection-techniques.md).
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_corroboration` with reachability unknown: `REJECTED`, with a tie-breaker demanding
call-graph, route, CLI, parser or dynamic evidence;
- `static_corroboration` with 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/`).