48 Commits
Author SHA1 Message Date
Marius Ciepluch 32aa92191a chore(release): 1.1.0
Three-stage scan: static inventory, complexity map with test-gap hints,
and Docker-isolated regression with --fail-on worth-fixing. Split-sink
and mutation corpora gate stage 2 so regex hit-count ranking cannot claim
success.
2026-09-04 12:00:15 +02:00
Marius Ciepluch ac3da41c95 feat(three-stage-scan): prove split-sink triggerable in sandbox 2026-09-04 11:54:01 +02:00
Marius Ciepluch 9af356797f feat(three-stage-scan): gate stage-2 maps against split-sink ranking 2026-09-04 11:53:06 +02:00
Marius Ciepluch 7398063b79 feat(three-stage-scan): add aliased-sink mutation the regex scan misses 2026-09-04 11:50:44 +02:00
Marius Ciepluch fe300e6352 feat(three-stage-scan): add split-sink fixtures the regex scan misses 2026-09-04 11:49:44 +02:00
Marius Ciepluch 818a0b10d3 feat(three-stage-scan): report map and honor fail-on worth-fixing 2026-09-04 00:18:33 +02:00
Marius Ciepluch a916e2d173 feat(three-stage-scan): attach tool hunter to map hotspots 2026-09-04 00:04:36 +02:00
Marius Ciepluch 2efbbb8024 feat(three-stage-scan): run or skip deep regression with visible degradation 2026-09-03 23:43:15 +02:00
Marius Ciepluch 803312e75b feat(three-stage-scan): run complexity map on standard scans 2026-09-03 23:26:54 +02:00
Marius Ciepluch 66d985e9e6 feat(three-stage-scan): keep quick as stage-1 inventory 2026-09-03 23:14:00 +02:00
Marius Ciepluch 331b62f1c1 feat(three-stage-scan): map sandbox outcomes to worth-fixing verdicts 2026-09-03 22:59:01 +02:00
Marius Ciepluch 62c7990e49 feat(three-stage-scan): add language recipes with inconclusive fallback 2026-09-03 22:48:10 +02:00
Marius Ciepluch 7a584d4578 feat(three-stage-scan): select bounded regress candidates with sev-5 extras 2026-09-03 22:36:02 +02:00
Marius Ciepluch eb87ef85ec feat(three-stage-scan): reject unsafe regression snippets before execution 2026-09-03 22:12:45 +02:00
Marius Ciepluch f78ea55e5d feat(three-stage-scan): add Docker CLI sandbox runner with isolation flags 2026-09-03 21:56:02 +02:00
Marius Ciepluch 5b7e58ea3a feat(three-stage-scan): add docker sandbox availability probe 2026-09-03 21:29:38 +02:00
Marius Ciepluch b34a75a821 feat(three-stage-scan): add tool-using hunter that records suspicion only 2026-09-03 21:18:22 +02:00
Marius Ciepluch 90e3b91cf8 feat(three-stage-scan): add path-clamped hunter read grep and refs 2026-09-03 21:01:57 +02:00
Marius Ciepluch ad16e22c85 feat(three-stage-scan): overlay complexity ledger without hiding sev-5 2026-09-03 20:48:03 +02:00
Marius Ciepluch 8d5599ae65 feat(three-stage-scan): attach test-gap hints to hotspots 2026-09-03 20:33:48 +02:00
Marius Ciepluch b05fbbc3a5 feat(three-stage-scan): emit ordered complexity map artifact 2026-09-03 20:16:23 +02:00
Marius Ciepluch 3d643040bd feat(three-stage-scan): compute complexity signals for hotspot ranking 2026-09-03 20:05:13 +02:00
Marius Ciepluch c5a80c2817 feat(three-stage-scan): add fail-on worth-fixing 2026-09-03 19:44:04 +02:00
Marius Ciepluch 926c8c273e feat(three-stage-scan): add complexity and regression config caps 2026-09-03 19:29:54 +02:00
Marius Ciepluch 723e3a76cf docs(three-stage-scan): add approved three-stage scan spec 2026-09-03 19:19:58 +02:00
Marius Ciepluch 6fdd9b3aba feat(three-stage-scan): encode stage plan and mode-to-stage table 2026-09-03 19:19:50 +02:00
Marius CiepluchandClaude Opus 4.8 4f9026167d chore(release): 1.0.0
First stable release. Both roadmaps complete: zero-dependency detection core
(>=90% recall / <10% FP gate), central CWE policy + project score, rules-as-data
self-improvement loop (`ousast improve`), optional HarnessX agentic plane
(configurable provider + budgets), two-panel fusion adjudication, narrow MCP server
(`ousast mcp`), Trail of Bits skill router, and hardening (secret redaction,
provider retry/backoff, cost budgets, threat-model docs).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 20:29:58 +02:00
Marius CiepluchandClaude Opus 4.8 b5e40318db feat(hardening): Phase 15 — redaction, retry/backoff, budgets, threat-model docs
Release-readiness hardening.

- redaction.py (zero-dep): redact_secrets masks provider API keys, AWS/GitHub/Slack/
  Google tokens, bearer tokens, URL-embedded credentials, PEM private keys, and
  sensitive key=value assignments. Wired into the trace writer and the markdown report
  (on by default), so scan artifacts never leak a credential quoted from source.
- provider/openrouter.py: call_with_retry — exponential backoff on transient failures
  (HTTP 429/5xx, connection errors, timeouts); non-transient errors fail fast. Both the
  chat and embedding clients use it (max_attempts/retry_base_delay configurable).
- config [hardening]: redact_secrets (default true) + max_findings (bounded-CI output
  cap, 0 = unlimited; truncation is severity-ordered and disclosed as a `budget`
  degradation). Agentic spend stays bounded by [harnessx] max_cost_usd/token_threshold.
- docs/threat-model.md (trust boundaries, controls, sandbox limits) + docs/examples.md
  (six end-to-end walkthroughs); README "Security & hardening" section links both.
- CI lint/typecheck/test/detection-gate/dagger gates already existed.

tests/test_redaction.py, test_provider_retry.py, test_hardening_pipeline.py + config
tests. 206 passed, 4 skipped; ruff + mypy clean; detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 20:02:34 +02:00
Marius CiepluchandClaude Opus 4.8 3fd0057e5f feat(skills): Phase 14 — Trail of Bits skill index + router
Replace the hardcoded skill-name stub with a real, routable skill index.

- skills.py (zero-dep): SkillDescriptor inventory (~22 ToB skills with routing
  metadata — languages, tags, vulnerability classes, stages — plus a short operating
  checklist each), aligned with the spec's routing examples. route_skills scores by
  vulnerability-class/tag/stage signal with a language filter+bonus, and never selects
  on a pure language match. route_skills_for_target derives vuln classes from tags;
  select_skill_context packs checklists into a char budget; skill_chunks chunks the
  guidance into the `skills` retrieval namespace.
- hunter.select_skill_snippets now delegates to the router (existing C-memory and
  crypto+auth routing expectations preserved).
- hunter_harness._describe feeds the budget-capped routed skill context into the
  HarnessX hunter prompt (the genuine consumer of skill_budget_chars).
- 8.4 zero-dep guard extended to cover skills.py.

tests/test_skills.py (9 tests: C parser → memory/fuzzing, crypto → constant-time/
vector, language-mismatch + language-only exclusion, stage filtering, budget cap,
namespace chunking). openrouter-sast-harness Phase 14 checked off.

188 passed, 4 skipped; ruff + mypy clean; detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 19:51:04 +02:00
Marius CiepluchandClaude Opus 4.8 9c917dd6aa feat(mcp): Phase 12 — narrow MCP server + ousast mcp
A zero-dependency MCP server over stdio (newline-delimited JSON-RPC 2.0) for
OpenCode/IDE integration, exposing only stable project-level operations.

- mcp.py: McpServer (pure handle() dispatch) + serve() stdio loop, stdlib-only.
  Ten tools: openultrasast.scan/status/findings/get_finding/evidence/artifacts/
  benchmark/explain/propose_patch/export_report. scan/benchmark run the bounded
  analysis pipeline; NO tool accepts a free-form command — no shell, Docker, or
  internal hunter tools are exposed. propose_patch degrades visibly (patch oracle
  is a later phase). initialize/tools/list/tools/call/ping handled; notifications
  get no response; errors surface as JSON-RPC errors instead of crashing the loop.
- cli: `ousast mcp` subcommand launches the stdio server (lazy import → keeps the
  mcp↔cli cycle one-directional).
- tests/test_mcp.py: tool listing is exactly the narrow surface, surface-safety
  (no shell/exec/command tool), scan→status→findings→explain→evidence flow,
  export_report, propose_patch degradation, and the stdio loop round-trip.
- README "MCP server" section; openrouter-sast-harness Phase 12 checked off.

Verified end-to-end over real stdio (initialize → tools/list → scan). 179 passed,
4 skipped; ruff + mypy clean; detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 19:42:01 +02:00
Marius CiepluchandClaude Opus 4.8 867bd536a2 feat(fusion): Phase 13 — two-panel fusion adjudication
OpenUltraCode-style deepening for findings that need more reasoning than the
ranker→hunter→verifier→mapping loop provides.

- fusion.py (zero-dep core + lazy LLM path): a deterministic trigger policy
  (critical/high severity, verifier disagreement, static-vs-semantic conflict,
  gates-risky-fix, high-assurance request); two panels that steel-man the
  vulnerability and false-positive cases; a deterministic, auditable decider that
  reconciles to one of five dispositions (accepted/rejected/mitigated/deferred/
  blocked); full disclosure (votes, model IDs, degradations, warnings, decision
  source). fuse_findings_dispatch routes panels through the configured HarnessX
  provider when panel_model + extra are present, else deterministic + a degradation.
- config [fusion]: enabled (default true), panel_model, decider_model,
  high_assurance.
- cli: standard-mode fusion stage runs on triggered findings, writes fusion.json
  and a manifest `fusion` block; quick mode never fuses. reports.write_manifest
  gained the fusion summary param.
- 8.4 zero-dep guard extended to cover fusion.py (lazy harnessx import only).
- README fusion section marked implemented + documented; openrouter-sast-harness
  Phase 13 checked off.

tests/test_fusion.py (16 tests) + config tests. 169 passed, 4 skipped; ruff + mypy
clean; detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 18:57:03 +02:00
Marius CiepluchandClaude Opus 4.8 b9caeeb835 feat(slots): Phase 3 task 6.3 — model-disabled, slot-contracted deterministic stages
Express the deterministic scan stages as model-disabled processors carrying work in
named slots under a read/write allow-list validated on every lifecycle hook.

- slot_contract.py (zero-dep): SlotContractError, SlotContractMixin (reads_slots/
  writes_slots/skip_model=True, validates reads pre-hook and writes post-hook),
  SlotPipeline (runs stages in order, enforces the model-disabled invariant, emits
  stage_start/stage_end lifecycle hooks). Preserves the ProcessorSpec.reads/writes
  provenance discipline without forcing deterministic stages through HarnessX's agent
  loop ("bridge, do not map").
- stage_processors.py: Preprocess/EntryPoint/Rank/QuickFindings/PolicyScoring/Score as
  skip_model slot processors; run_quick_pipeline is byte-identical to the quick path.
  host_under_harnessx is the lazy, capability-gated bridge that disables the model on
  HarnessX's BeforeModelEvent.skip_model (proven against the real API when the extra
  is present).
- 8.4 zero-dep guard hardened: now hermetic (subprocess) and covers the two new
  modules; the module-level import scan flags only column-0 imports so the intended
  lazy import inside host_under_harnessx is allowed.

The sync HarnessRuntime stays the top-level driver (design "stays through Phase 4");
these slot processors are the model-disabled representation, ready for Phase 5.

tests/test_slot_contract.py (8 tests). 153 passed, 3 skipped; ruff + mypy clean;
detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 18:39:09 +02:00
Marius CiepluchandClaude Opus 4.8 bebfc72cf7 test(pipeline): Phase 4 task 9 — scan-pipeline integrity & serialization
9.1 — end-to-end fallback parity: a requested-but-unavailable HarnessX standard
scan (models set, extra absent) produces byte-identical findings to the plain
deterministic path, differing only by the recorded hunter_pool/verify degradations;
quick mode is unaffected by the extra (identical findings present-or-absent, never a
degradation).

9.2 — per-slot serialization round-trip: StaticFinding / RankingScore / FileTarget /
VerificationResult / ScoreArtifact round-trip through JSON without loss, plus the real
findings.json reload path preserves nested slots (reachability evidence/conditions,
tags) rather than restoring them empty. Covers the current artifact-persistence model;
the HarnessX processor slot-contract variant stays deferred with task 6.3.

tests/test_scan_pipeline_integrity.py (8 tests). 145 passed, 3 skipped; ruff + mypy
clean; detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 18:20:57 +02:00
Marius CiepluchandClaude Opus 4.8 e255eb264f feat(harnessx): configurable provider + per-task budget caps
Add a [harnessx] config block so the agentic plane can be steered from
openultrasast.toml, and make the LLM provider selectable (the hunter and verifier
previously hardcoded AnthropicProvider).

- config.HarnessxConfig: provider ("anthropic"|"openai"|"litellm", default
  anthropic), max_cost_usd (2.0), token_threshold (120000); parsed from [harnessx].
- harness_ext.build_provider: single lazy, capability-guarded provider factory.
  The provider name is validated before any import, so an unknown choice fails with
  a clear ValueError even when the extra is absent; each provider reads its own
  standard key env var (openai -> OPENAI_API_KEY, anthropic -> ANTHROPIC_API_KEY).
- HxScanOrchestrator and verify_findings_dispatch take a provider and route through
  build_provider; cli threads config.harnessx.{provider,max_cost_usd,token_threshold}
  into both the hunter and the verifier.
- README: "Configuring the HarnessX agentic plane" section (install, models,
  provider, budgets, fallback/degradation).

Test isolation: convert the "harnessx not in sys.modules" import-guards to a
hermetic subprocess fixture (tests/conftest.py assert_cold_of_harnessx) and
simulate extra-absent in the CLI dispatch tests, so the suite is deterministic
whether or not the optional extra is installed (it now is, locally). Verified the
OpenAI path constructs end-to-end (build_provider -> OpenAIProvider; orchestrator
-> Harness) with the budget threaded through.

137 passed, 3 skipped; ruff + mypy clean; detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 17:59:44 +02:00
Marius CiepluchandClaude Opus 4.8 760df7c809 feat(cli): add ousast improve — run the self-improvement loop from the CLI
Expose the bounded, gated self-improvement loop as a first-class subcommand so it
can be steered from the command line / opencode.

- `ousast improve <manifest>` resolves the target from the benchmark manifest,
  runs the loop to convergence, and writes accepted edits to the loop-owned ledger
  at <target>/.openultrasast/calibration/rule_policy.json — exactly where `scan`
  and `benchmark` read it, so a subsequent scan of that target automatically
  picks up the improved ruleset (the loop closes end-to-end).
- Flags: --max-rounds, --recall-floor, --fp-ceiling, --ledger, --journal,
  --ruleset-dir, and --dry-run (runs against a throwaway ledger, touches nothing).
- Per-round output: reason, recall/fp/score deltas, and the edits applied.
- tests/test_cli_improve.py: accept path writes the loop-owned ledger (noisy rule
  auto-shadowed), --dry-run never touches it, missing manifest fails loudly.
- README: quickstart entry + a "Rule-level loop: ousast improve" subsection
  documenting the bounded levers, hard acceptance gate, and the HarnessX MetaAgent
  as the optional proposer over the same gated machinery.

131 passed, 3 skipped; ruff + mypy clean; detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 17:36:58 +02:00
Marius CiepluchandClaude Opus 4.8 649bd42fc3 feat(gate): Phase 4 task 8 — lock the detection gate into CI
Make the >=90% recall / <10% FP detection gate a first-class, score-independent
CI step with non-regression guards.

- openultrasast.gate: canonical corpus map + DetectionMetrics/DetectionGateVerdict,
  evaluate_corpus/detection_gate (no score input — cannot be unblocked by score),
  merge_gate (ANDs the hard detection gate with the advisory-first score gate),
  within_tolerance, and a `python -m openultrasast.gate` entrypoint that exits
  non-zero on a breach. Wired as an explicit step in both the GitHub and Dagger
  pipelines.
- 8.1: pipeline tests prove a high/passing score cannot unblock a recall-breaching
  change; the gate fails below 90% recall or at/above 10% FP.
- 8.2: tests/golden/detection_baseline.json + a byte-identical baseline test for
  the default ruleset / empty ledger (44/47 = 93.62% recall, 0% FP).
- 8.3: within_tolerance band logic + a capability-gated HarnessX tolerance test.
- 8.4: zero-dependency guard — governance/scoring/benchmark planes never import
  HarnessX (sys.modules check + module-scope import scan).
- test_detection_benchmarks now sources the corpus/aggregation from the gate module.

128 passed, 3 skipped; ruff + mypy clean; detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 17:13:02 +02:00
Marius CiepluchandClaude Opus 4.8 6e87a58465 feat(improve): Phase 3 task 7 — bounded, gated self-improvement loop
Stand up the deterministic self-improvement loop over the ruleset/policy
governance data. Zero-dependency and fully tested offline; detection gate intact.

- 7.1/7.2 bounded levers + validator (improve/validator.py): two levers (rule,
  policy); typed edits make pattern-text and 0-5 severity changes structurally
  impossible; EvolveValidator rejects bad status, unknown rule_ids (loop cannot add
  rules), enabled->disabled jumps (shadow staging mandatory), unresolved CWEs, and
  out-of-bounds K/MIN_SCORE, raising StrictValidationError.
- 7.3 novelty + replay + journal (improve/journal.py): a JSON round journal records
  every edit/lever/outcome; reverted edits are blocked from re-proposal (novelty);
  a replay smoke gate boots the candidate ruleset before re-benchmarking.
- 7.4 benchmark deltas -> rule_signals.json (recall side: miss; precision side: fp).
- 7.5 hard acceptance gate + byte-for-byte revert (improve/evolve.py): a round is
  accepted only if recall >= 90% AND FP < 10% AND project score did not regress AND
  no matched-finding regression; otherwise the ledger is never written (byte-for-byte
  revert). run_round/run_improvement drive rounds to convergence.
- 7.6 auto-shadow precision-draggers (enabled rules with FPs -> shadow, never
  delete), nominate coverage gaps via rule_signals, and keep the project score as the
  reward separate from the recall/FP feasibility gate (the loop cannot raise the
  score by shadowing rules that breach the gate — proven by the revert test).

Design note: the loop's safety machinery is deterministic and benchmark-driven
(the proposer is the per-rule signal); the LLM MetaAgent.evolve is the optional
richer proposer that plugs into this same validated/gated machinery when the
HarnessX extra is present (offline-untestable). This mirrors how DocuHarnessX
realizes self-improvement.

9 new tests (validator, proposer/signals, accept, byte-for-byte revert, convergence);
118 pass total, 2 construction smokes skip without the extra; ruff + mypy clean;
detection gate 93.6%/0% intact. Tasks 7.1-7.6 checked off.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 16:52:37 +02:00
Marius CiepluchandClaude Opus 4.8 ef59c0ae3d feat(harnessx): Phase 3 task 6 — hunter sub-harness + llm-judge verifier + fallback
Adopt HarnessX at the hunter loop and verifier, capability-gated so the default
zero-dependency scan is byte-identical and CI (no extra) exercises the fallback.

- 6.1/6.2 build_sast() (single, lazy HarnessX composition site in harness_ext.py,
  mirrors DocuHarnessX make_docgen) + HxScanOrchestrator (hunter_harness.py): a
  drop-in for run_hunter_pool that runs one bounded HarnessX agent task per ranked
  target (ModelConfig(main=AnthropicProvider).agentic(build_sast()).run(BaseTask)),
  parses findings from a JSON structured-output contract, returns the existing
  HunterPoolResult, maps scan_id->session_id and emits per-target exit_reason/cost/
  steps to the outer trace.
- 6.4 verify_judge.verify_findings_dispatch: a drop-in superset of
  verification.verify_findings — structural verifier verbatim by default (rejects
  unknown reachability), HarnessX llm-judge (build_judge_prompt/parse_judge_response)
  when the extra + a verifier model are present; verdict mapped to VerificationResult
  evidence-gated (plausible>=0.6 accepted; unsupported/refused rejected; infra
  failure never auto-rejects).
- 6.5 cli capability gate (hx_hunter/hx_verify): selects HxScanOrchestrator /
  judge only in standard mode with a configured model AND has_harnessx(); otherwise
  falls back and records a {stage,reason:harnessx_extra_unavailable} degradation in
  the manifest (new append-only write_manifest degradations kwarg). With no model
  configured the manifest stays byte-identical.
- pyproject: mypy override ignore_missing_imports for harnessx.* (absent in the
  type-check env).

Verified the HarnessX path constructs against the real extra in a throwaway venv
(build_sast -> HarnessConfig; orchestrator -> Harness; build_judge_prompt/
parse_judge_response/verdict mapping) — only the live agent.run()/provider.complete()
LLM calls are offline-untestable and stay gated. 109 tests pass (2 construction
smokes skip without the extra), ruff + mypy clean, detection gate 93.6%/0% intact.
Tasks 6.1/6.2/6.4/6.5 done; 6.3 (deterministic stages as HarnessX processors) deferred.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 16:29:52 +02:00
Marius CiepluchandClaude Opus 4.8 5e206df166 feat(harnessx): Phase 3 task 5.2 — SHA-pinned optional extra + capability guard
Wire HarnessX as an optional, SHA-pinned extra with a lazy capability guard,
keeping the core install zero-dependency.

- pyproject: add [project.optional-dependencies] harnessx pinned to commit
  274c512 (Darwin-Agent/HarnessX). Core dependencies stays []. uv_build emits
  `Requires-Dist: harnessx @ git+...@<sha> ; extra == 'harnessx'` +
  `Provides-Extra: harnessx` (verified via uv build); release ships to GitHub
  Releases, not PyPI, so the direct-reference is fine.
- harness_ext.py: the capability seam. has_harnessx() probes via find_spec
  (never imports harnessx); require_harnessx() imports lazily on demand and
  raises HarnessXUnavailableError with install guidance when absent. Every
  Phase 3+ HarnessX path routes through here so heavy imports stay cold by default.
- uv.lock refreshed: the universal lock now resolves the harnessx tree (149 pkgs)
  but `uv sync --group dev` installs only 57 — harnessx is NOT importable in the
  default/CI venv (verified), so CI stays lean and never re-locks.
- Tests: core deps empty; the extra is a 40-hex SHA-pinned git URL; importing
  openultrasast.cli does not import harnessx; has_harnessx probes without
  importing; require_harnessx raises with guidance when absent.

98 tests pass; ruff + mypy clean; detection gate 93.6%/0% intact. Spec task 5
(5.1 + 5.2) checked off; next is task 6 (build_sast hunter sub-harness).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 16:08:43 +02:00
Marius CiepluchandClaude Opus 4.8 97da477424 ci: retry dagger pipeline on transient registry/image-pull failures
The dagger job failed pulling python:3.11-bookworm from Docker Hub with a 520
(transient auth/CDN outage) — the residual risk noted in the Phase 3 dependency
audit. The earlier hardening pre-pulled only the dagger engine image
(registry.dagger.io); the base-image pull happens inside the engine via
client.container().from_(...), which the host pre-pull cannot cover.

Wrap the whole dagger pipeline (_run_checks) in a 3-attempt retry that re-runs on
transient registry/pull markers (Docker Hub 5xx/429, oauth/authorize failures,
TransportQueryError, i/o timeout, connection reset, context deadline). Permanent
failures (lint/type/test) still fail immediately on the first attempt.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 16:02:50 +02:00
Marius CiepluchandClaude Opus 4.8 6aa5a1bc96 docs(spec): Phase 3 task 5.1 — HarnessX dependency audit + DocuHarnessX blueprint
Run and record the HarnessX dependency dry-run audit and assess blueprint
alignment with the author's DocuHarnessX, to steer OpenUltraSAST's HarnessX
adoption from opencode/claude-code.

- dependency-audit.md: records the realized transitive graph (118 packages from
  30 mandatory HarnessX deps). Key finding: every heavy backend (docker/e2b/
  playwright/office-PDF) is lazily imported and the gateway is a separate dist, so
  the tree is an install-time (not runtime) cost; ~55-60 packages are dead weight
  for SAST. Decision: ACCEPT as the optional SHA-pinned extra openultrasast[harnessx]
  (core stays zero-dep; lazy heavy imports; matches DocuHarnessX precedent), with a
  fully-specified vendored lean subset as the triggered fallback and upstream
  dependency-extras as the preferred long-term slimming path. Records the
  security-tool residual risks (browser binary, docker socket, web server, CVE
  surface) and their mitigations.
- blueprint-alignment.md: DocuHarnessX <-> OpenUltraSAST assessment. They share the
  spine (one composition factory, model bound in CLI via ModelConfig.agentic,
  content-neutral processors over typed slots, project-loaded domain/"ontology"
  seam, fail-closed LLM-judge firewall, Kiro fresh-subagent steering). DocuHarnessX
  realizes "self-improving multi-agent" via the firewall + claude-code/opencode
  steering loop, NOT MetaAgent.evolve (Train is future there); OpenUltraSAST builds
  the evolve loop on the same substrate. Three principled divergences (hybrid runtime
  confining HarnessX to the hunter pool; evolve reward/feasibility split + ledger;
  zero-dep core with opt-in extra) with reconciliation, plus a concrete Phase 3
  blueprint mirroring DocuHarnessX.

Checks off spec task 5.1.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 15:58:30 +02:00
Marius CiepluchandClaude Opus 4.8 2d7ef48e52 feat(benchmark): Phase 2 (task 4) — actionable per-rule signals, ledger, FP knob
Complete Phase 2: make benchmark output drive per-rule improvement, wire the
loop-owned ledger, and turn a confirmed false positive into a score knob. Zero
new dependencies; detection stays byte-identical (recall 44/47 = 93.6%, FP 0%)
and the 90/10 gate holds.

- Per-rule attribution (4.1): BenchmarkMiss/BenchmarkFalsePositive carry rule_id;
  BenchmarkMetrics.per_rule aggregates matched/missed/false_positives per rule.
- Recommendation delta (4.2): evaluate_benchmark emits RuleRecommendation
  (keep/shadow/loosen) written to rule_policy_delta.json; the inert
  next_improvement_candidate stub is now rule-aware ("rule:<id>" or
  "language_hunter").
- Loop ledger (4.3): cli loads the ruleset with the rule_policy.json overlay
  (empty -> byte-identical; an entry overlays a rule's status/floors at load);
  read_rule_ledger/write_rule_ledger added for the evolve loop. A disabled rule
  in the ledger no longer fires (covered by an integration test).
- FP -> reachability knob (4.4): a confirmed false-positive learning lowers a
  finding's effective reachability multiplier in the score
  (fp_reachability_overrides + scoring reachability_override) instead of deleting
  the rule, so recall stays high while the score stops over-penalizing.

93 tests pass; ruff + mypy clean. Spec tasks 4.1-4.4 checked off — Phase 2
(Core: rules-as-data + benchmark signals) is complete. The loop-driven decision
to apply ledger flips remains the gated evolve loop (Phase 3/4).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 15:41:17 +02:00
Marius CiepluchandClaude Opus 4.8 444d8c9490 feat(ruleset): Phase 2 (task 3) — rules as governed data, severity from policy
Externalize detection rules to versioned TOML and remove rule-local severity, so
rules detect and the central CWE policy decides severity — fixing "rules too
influential / not centrally managed". Zero-dependency; detection is byte-identical
(benchmark recall 44/47 = 93.6%, FP 0% unchanged) and the 90/10 gate holds.

- RulesetStore (ruleset/store.py + ruleset/<lang>/rules.toml): PatternRule as data
  (rule_id, title, languages, cwe, tags, pattern, status, min_evidence_level,
  precision_estimate, version) with NO severity; load_ruleset (sorted, with
  optional rule_policy.json ledger overlay) and a stdlib TOML write_ruleset. The
  32 rules were generated from the prior PATTERN_RULES with a verified exact
  round-trip (pattern/cwe/languages/tags/title).
- findings.py: drop the hardcoded PatternRule class + PATTERN_RULES tuple; load the
  default ruleset from TOML; quick_scan_findings(root, targets, rankings, ruleset,
  policy) resolves each finding's severity from policy via resolve_severity
  (SEVERITY_LABEL maps 0-5 -> string) and tags findings with their rule status.
- Three-status volume control: StaticFinding gains status (default "enabled");
  disabled rules never fire; shadow findings are partitioned out of the report and
  score in the CLI (written to shadow_findings.json) — no shadow rules by default.
- Emission floors (RulesetConfig.min_emit_priority / min_emit_precision, default
  0.0 = emit all) replace "every match is a finding".
- CLI loads the ruleset as a stage and uses it for the fail-loud policy check,
  detection, hunter pool, and the score CWE map. Wheel ships the ruleset TOML.

85 tests pass; ruff + mypy clean. Spec tasks 3.1-3.4 checked off.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 15:27:16 +02:00
Marius CiepluchandClaude Opus 4.8 fb7594a3ed ci: harden flaky dagger job; docs: document Phase 1 project scores
CI: the dagger job repeatedly failed (and once hung 10 min) pulling the engine
image from registry.dagger.io. Pin the dagger CLI to 0.21.7 (matching dagger-io
and the SDK's CLI_VERSION so the engine tag is deterministic), pre-pull
registry.dagger.io/engine:v0.21.7 with a dependency-free retry loop (5 attempts,
90s/pull, 20s backoff) so the action's `docker image inspect` finds it cached and
skips its own flaky pull, and add a 20-minute job timeout so a stalled pull can
no longer hang the run. The canonical `checks` job is unchanged.

Docs: add a "Project scores and central CWE policy" README section documenting
the implemented Phase 1 — central verycode CWE policy, policy-governed severity
(one CWE = one severity), the fail-loud invariant, the 0-100 project score and
two-condition advisory-first gate, the score.json/manifest artifacts, and the
CWE-120 -> CWE-121 remap.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 13:25:22 +02:00
Marius CiepluchandClaude Opus 4.8 ae365f6cb7 feat(governance): Phase 1 — CWE policy store + project score (zero-dep)
First implementation slice of the approved harnessx-self-improving-rulesets spec
(Foundation: governance + scoring planes). Additive and zero-dependency; findings
stay byte-identical and the >=90% recall / <10% FP detection gate is unchanged.

- PolicyStore (policy/verycode.py): vendors verycode-policies CWE_Score.tsv (167
  CWEs, CRLF + trailing-space header parsed defensively); CwePolicy(severity 0-5,
  static/dynamic); load_policy, resolve_severity (severity is policy-governed,
  keyed on CWE — one CWE = one severity), assert_rules_resolve (fail-loud if an
  enabled rule's CWE is unmapped). Remap the C/C++ buffer rules CWE-120 -> CWE-121
  (CWE-120 is absent from verycode; 121 = Stack-Based Buffer Overflow, severity 5).
- ScoreModel (scoring/project_score.py): SEV_WEIGHT, REACH_MULT, finding_penalty,
  project_score (0-100 exponential decay; one sev-5 reachable -> ~43), two-condition
  gate (sev-5+reachable always fails; score<min_score fails only when blocking),
  build_score_artifact.
- Wire into _run_scan: policy_load + fail-loud policy_check stages; score stage
  writes score.json and merges a score block into the manifest (advisory-first).
- ScoreConfig (k/min_score/block_severity_reachable/blocking) in config.py.
- pyproject uv_build ships the vendored TSV in the wheel (verified).
- Tests: test_policy, test_scoring, test_score_pipeline (artifact + fail-loud).
  85 passed; ruff + mypy clean. Spec phases 1.1-2.4 checked off; spec approved.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 12:31:35 +02:00
Marius CiepluchandClaude Opus 4.8 9b399fdc60 docs(spec): kick off harnessx-self-improving-rulesets Kiro spec
Spec-driven plan to (1) adopt HarnessX (Darwin-Agent/HarnessX) directly where it
pays off — the LLM hunter loop, the LLM-judge verifier, and a meta-harness
self-improvement loop — and migrate scan flows onto it stage-by-stage; (2) make
detection rules versioned, centrally-governed data whose severity comes from a
vendored verycode CWE policy (CWE_Score.tsv) instead of the rule itself, fixing
"rules too influential / not centrally managed"; (3) adapt rules/policy inside
HarnessX's MetaAgent.evolve loop under bounded levers with replay/novelty/
validator gates; (4) make benchmarks actionable per-rule signals with a hard
accept gate and byte-for-byte revert; (5) compute a 0-100 verycode-style project
score with a two-condition CI gate.

Key decisions captured in research.md: HarnessX is an LLM-agent harness (not a
generic stage pipeline), so it is adopted only on the agentic plane behind an
optional SHA-pinned extra while the governance/scoring/benchmark planes stay
zero-dependency; the >=90% recall / <10% FP detection gate is a hard constraint
separate from the score reward and must hold at every phase; CWE-120 is unmapped
in verycode so C/C++ buffer rules remap to CWE-121 (severity 5).

Artifacts: spec.json (phase tasks-generated, approvals pending), requirements.md
(7 requirements, EARS), design.md (three-plane architecture), tasks.md (4
phases), research.md (D1-D9 decisions). Awaiting human approval per the kiro
3-phase gate; not yet implemented.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 12:07:54 +02:00
Marius Ciepluch 0d24033f32 docs: humanize README and code comments 2026-06-21 21:02:18 +02:00
Marius CiepluchandClaude Opus 4.8 0c0137e591 ci: upload only wheel and sdist in releases
`files: dist/*` also picked up the `dist/.gitignore` uv writes, attaching a
stray `default.gitignore` asset to the release. Restrict to dist/*.whl and
dist/*.tar.gz.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 20:50:34 +02:00
130 changed files with 17722 additions and 509 deletions
+36 -1
View File
@@ -24,17 +24,52 @@ jobs:
- run: uv run ruff check . - run: uv run ruff check .
- run: uv run mypy src/openultrasast - run: uv run mypy src/openultrasast
- run: uv run pytest - run: uv run pytest
- name: Detection gate (>=90% recall, <10% FP — hard, score-independent)
run: uv run python -m openultrasast.gate
- name: Split-sink map gate (stage 2 beats pattern-hit ranking)
run: uv run python -m openultrasast.map_gate
- run: uv run python -m compileall src tests - run: uv run python -m compileall src tests
dagger: dagger:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20
env:
# Must match dagger-io (0.21.7) and dagger/_engine/_version.py CLI_VERSION
# so the engine image tag is deterministic. The dagger-for-github action
# runs `docker image inspect registry.dagger.io/engine:v${DAGGER_VERSION}`
# and skips its own (flaky) pull when the image is already in the local
# docker cache, so the pinned tag below MUST equal the action's version.
DAGGER_VERSION: 0.21.7
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v7 - uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
- name: Pre-pull dagger engine image (retry transient registry timeouts)
timeout-minutes: 8
run: |
set -euo pipefail
image="registry.dagger.io/engine:v${DAGGER_VERSION}"
attempts=5
for i in $(seq 1 "${attempts}"); do
echo "::group::Pull attempt ${i}/${attempts}: ${image}"
if timeout 90 docker pull "${image}"; then
echo "Pulled ${image} on attempt ${i}"
docker image inspect "${image}" >/dev/null
echo "::endgroup::"
exit 0
fi
echo "Pull attempt ${i} failed."
echo "::endgroup::"
if [ "${i}" -lt "${attempts}" ]; then
echo "Retrying in 20s..."
sleep 20
fi
done
echo "All ${attempts} pull attempts failed for ${image}" >&2
exit 1
- uses: dagger/dagger-for-github@v7 - uses: dagger/dagger-for-github@v7
with: with:
version: latest version: 0.21.7
verb: run verb: run
args: uv run python dagger/ci.py args: uv run python dagger/ci.py
+3 -1
View File
@@ -27,5 +27,7 @@ jobs:
- run: uv build - run: uv build
- uses: softprops/action-gh-release@v2 - uses: softprops/action-gh-release@v2
with: with:
files: dist/* files: |
dist/*.whl
dist/*.tar.gz
generate_release_notes: true generate_release_notes: true
@@ -0,0 +1,96 @@
# DocuHarnessX ↔ OpenUltraSAST — Blueprint Alignment
How OpenUltraSAST's HarnessX integration (Phase 3+) aligns with **DocuHarnessX**
(`/home/mc/Source/DocuHarnessX`), the author's other HarnessX project. Both are HarnessX
bundles steered from claude-code/opencode via Kiro spec-driven development. Goal: one shared
architecture the steering layer drives identically.
## Orientation
DocuHarnessX is **agent-first** — an 8-stage pipeline (`ingest → analyze → classify → plan →
write → review → assemble → deploy`) that *is* the agent loop. OpenUltraSAST is a
**deterministic-pipeline + agentic-plane hybrid** — a sync `HarnessRuntime` driving 13 stages,
with HarnessX confined to the one stage that is genuinely an agent loop (the hunter pool). They
converge on the same load-bearing patterns and diverge only where SAST's determinism and
supply-chain posture require it.
**What "self-improving multi-agent system" means in DocuHarnessX (important):** it does **not**
use `meta_harness` / `MetaAgent.evolve` at all (the `Train` loop is explicit future / out of
scope). Its self-improvement is realized two ways: (1) an in-product **fail-closed LLM-judge
quality firewall** (COBESY: fixed criteria, threshold-re-derived verdicts, unavailable → fail),
and (2) the **claude-code/opencode Kiro steering loop** (fresh implementer → adversarial
reviewer → debugger subagents per task). OpenUltraSAST inherits both substrates and *additionally*
builds the `MetaAgent.evolve` loop DocuHarnessX leaves unbuilt.
## Shared blueprint (the spine)
| Concern | DocuHarnessX | OpenUltraSAST | Verdict |
|---|---|---|---|
| Composition seam | `make_docgen` in `docuharnessx/bundle.py` — the only HarnessX import site; `control \| stages_builder()`; returns model-less `HarnessConfig` | `build_sast()` in `harness/build_sast.py` (mirrors `examples/coding/build_coding`), lazy behind `_has_harnessx()` | **shared** |
| Model binding | CLI `prepare_run()`: `model_config.agentic(make_docgen(...))`; model on `harness.model_config.main`, never in config | `HxScanOrchestrator`: `asyncio.run(model_config.agentic(cfg).run(ScanTask(...)))` | **shared** |
| Processor base | `NoOpStage(MultiHookProcessor)`, content-neutral on `step_end`, real module-level classes for `_target_` resolution | `MultiHookProcessor(skip_model=True)` + `SlotContractMixin`, real module-level classes | **shared** |
| Inter-stage seam | `RunContext` over append-only slot constants; frozen versioned handoff dataclasses | typed `sast.*` slots + `SlotContractMixin` (`reads_slots`/`writes_slots`) | **shared** |
| Domain / "ontology" seam | `.docuharnessx/ontology.yaml` → `Vocabulary` (Role×Subject×Intent), loaded at run start | central **CWE policy + ruleset** (`policy/CWE_Score.tsv` + `ruleset/*.toml` + `rule_policy.json`), loaded at run start | **analogous** |
| Acceptance firewall | COBESY all-of gate; judge `passed` untrusted/re-derived; unavailable → fail | `VerifyProcessor` (llm-judge + structural fallback, rejects unknown reachability, fail-closed) | **analogous** |
| Steering | Kiro SDD; `CLAUDE.md`; `.kiro/{steering,specs}`; `kiro-impl` fresh-subagent + adversarial review; `kiro-verify-completion` | same Kiro convention; `.kiro/specs/harnessx-self-improving-rulesets/*`; OpenCode steers, kiro-* maintainer-only | **shared** |
| Self-improvement (`evolve`) | **none** — future seam | `improve/evolve.py → MetaAgent.evolve`, bounded `rule`/`policy` levers, `rule_policy.json` ledger | **diverges (OUS builds it)** |
| Dependency posture | `harnessx` direct git dep + pyyaml | core zero-dep; `harnessx` optional SHA-pinned extra, lazy | **diverges (by design)** |
## Three principled divergences (and reconciliation)
1. **Agent-first vs hybrid.** OUS keeps the sync `HarnessRuntime` through Phases 0–4 and confines
HarnessX to the hunter pool (the runtimes are "incompatible at every load-bearing point —
bridge, do not map"). Reconcile by treating `build_sast()` as building **only the hunter
sub-harness**, applying DocuHarnessX's `make_docgen` pattern *at the hunter-pool boundary*.
Optional Phase 5 converges OUS toward DocuHarnessX's agent-first end-state (retire
`HarnessRuntime`) — the divergence is temporal, and DocuHarnessX is the north star.
2. **Editable ontology vs authoritative severity.** DocuHarnessX's whole vocabulary is editable
presets. OUS splits its domain seam into a **mutable detection vocabulary** (`ruleset/*.toml`,
loop- and human-editable) and an **immutable severity/scope authority** (`CWE_Score.tsv`,
verycode-upstream, the loop must never edit). Where DocuHarnessX relies on convention ("never a
hardcoded literal"), OUS relies on a **validator** (`EvolveValidator._policy` forbids editing the
0–5 severity) because a loop, not just a human, edits it.
3. **Firewall-as-reward vs reward/feasibility split.** DocuHarnessX's firewall *is* the signal
(binary pass/fail). OUS must feed `MetaAgent.evolve`, so it keeps a **scalar reward**
(`project_score`) **separate from** a **hard feasibility constraint** (≥90% recall / <10% FP),
un-folded so the loop can't game the number. Reuse DocuHarnessX's fail-closed firewall verbatim
at the *per-finding* `VerifyProcessor` granularity; layer the reward/feasibility split on top.
## Phase 3 blueprint for OpenUltraSAST (mirror DocuHarnessX)
- **`build_sast()` factory** = the single HarnessX composition import site (DocuHarnessX's "blast
radius is this file" rule on `bundle.py`); `control | hunter_stages_builder()`; raised loop
thresholds (large-repo scanning revisits shapes, like large-repo docs); model bound later in
`HxScanOrchestrator`. OUS delta: the `_has_harnessx()` guard + raise-then-fallback to
`run_hunter_pool()`.
- **Ontology seam** = CWE policy + ruleset as OUS's "ontology": `load_ruleset(path, ledger)` +
`load_policy(tsv)` loaded into `sast.*` slots at run start; `resolve_severity()` is the single
severity authority (mirrors DocuHarnessX's "every transform reads the loaded store, never a
literal"); absent ledger → byte-identical baseline; unmapped enabled-rule CWE → fail-loud
(stricter than DocuHarnessX, because severity is authoritative).
- **Processor set**: register HarnessX classes directly **only inside the hunter sub-harness**
(LoopDetection, CostGuard/TokenBudget, ToolFailureGuard, ToolFilter, SelfVerify, EpisodeMetrics/
OTel/Checkpoint); everything deterministic is a `skip_model=True` `MultiHookProcessor` over slots
(SarifIngest/Preprocess/EntryPoint/Rank/Calibrate/QuickFindings/PolicyScoring/Verify/Score) with
stable `make_<stage>_processor()` factories so a spec replaces exactly one stub in place.
- **`MetaAgent.evolve` loop** (Phase 4, on the same substrate): two bounded levers (`rule`,
`policy`); reward = `project_score`; feasibility = recall/FP gate (un-folded); signal path
benchmark per-rule precision/recall + `rule_policy_delta` → `rule_signals.json` →
`MetaAgent.evolve`; gate = validity (canonicalize → contract → `_rules`/`_policy` → replay) →
novelty/evidence → re-benchmark → attribution → accept or **byte-for-byte revert** (the
structural analog of DocuHarnessX's `DEFAULT_UNAVAILABLE_VERDICT="fail"`); ledger =
`rule_policy.json` (OUS's resume-safe analog of DocuHarnessX's markdown round journal).
- **Steering surface**: reuse DocuHarnessX's `kiro-*` skills verbatim (maintainer-only); add
SAST-domain `.kiro/steering/` rules pinning the detection gate as a hard invariant, the zero-dep
guard, and the `pip --dry-run` audit gate; traceable per-module docstrings citing spec Req/Decision
numbers.
## Net
The two projects already share the spine — one composition factory, model bound in the CLI via
`ModelConfig.agentic`, content-neutral processors over typed slots, a project-loaded domain seam,
a fail-closed acceptance firewall, and the Kiro fresh-subagent steering loop. OpenUltraSAST diverges
in exactly three principled places (hybrid runtime confining HarnessX to the hunter pool; it *builds*
the `MetaAgent.evolve` loop with a reward/feasibility split + `rule_policy.json` ledger; zero-dep core
with HarnessX opt-in). Adopting DocuHarnessX's load-bearing patterns at the points above gives the
two projects one architecture that opencode/claude-code steers identically.
@@ -0,0 +1,116 @@
# HarnessX Dependency Dry-Run Audit (Task 5.1)
Recorded realized transitive dependency graph and the accept-vs-vendor decision for
adopting HarnessX (`git+https://github.com/Darwin-Agent/HarnessX.git`) in Phase 3.
## Method
```bash
uv venv /tmp/hx-audit --python 3.11
uv pip install --dry-run --python /tmp/hx-audit/bin/python <HarnessX checkout>
```
- **118 transitive packages** resolved from HarnessX's **30 direct** `[project.dependencies]`
(all mandatory upstream; the only extra is `dev`).
- Pinned versions observed: `harnessx 0.1.0`, and engine image `registry.dagger.io`/SDK
unrelated; LLM SDKs `anthropic 0.111.0`, `openai 2.43.0`, `litellm 1.89.3`.
### Realized transitive graph (118)
```
aiofiles aiohappyeyeballs aiohttp aiosignal annotated-doc annotated-types anthropic
antlr4-python3-runtime anyio attrs bracex certifi cffi charset-normalizer click cryptography
distro docker dockerfile-parse docstring-parser e2b et-xmlfile fastapi fastuuid filelock
frozenlist fsspec googleapis-common-protos greenlet grpcio h11 h2 harnessx hf-xet hpack
html2text httpcore httptools httpx httpx-sse huggingface-hub hydra-core hyperframe idna
importlib-metadata jinja2 jiter jsonschema jsonschema-specifications litellm loguru lxml
markdown-it-py markupsafe mcp mdurl multidict omegaconf openai openpyxl opentelemetry-api
opentelemetry-exporter-otlp opentelemetry-exporter-otlp-proto-common
opentelemetry-exporter-otlp-proto-grpc opentelemetry-exporter-otlp-proto-http opentelemetry-proto
opentelemetry-sdk opentelemetry-semantic-conventions packaging pdfminer-six pdfplumber pillow
playwright prompt-toolkit propcache protobuf pycparser pydantic pydantic-core pydantic-settings
pyee pygments pyjwt pypdfium2 python-dateutil python-docx python-dotenv python-multipart
python-pptx pyyaml referencing regex reportlab requests rich rpds-py shellingham six sniffio
sse-starlette starlette structlog tiktoken tokenizers tqdm typer typing-extensions
typing-inspection urllib3 uvicorn uvloop watchfiles wcmatch wcwidth websockets xlsxwriter yarl zipp
```
## Key finding: heavy backends are lazy; ~half the tree is dead weight
Every heavy backend is **lazily imported** — `docker`/`e2b` are `TYPE_CHECKING`-guarded and
imported inside provider factories; `playwright` is imported inside `_get_local_page()`;
office/PDF libs inside `tools/builtin/read.py` parse functions; `sandbox/__init__.py` registers
docker/e2b via `try/except`. The `gateway/` is a **separate distribution**. So the 118-package
tree is an **install-time** cost; at **runtime only what a given agent path touches is loaded**.
This is the answer to "I don't need most of these": neither OpenUltraSAST (SAST) nor the
author's DocuHarnessX (docs) loads the bulk of it — HarnessX simply declares every capability as
a *mandatory* dependency.
| Category | Packages | SAST verdict |
|---|---|---|
| LLM SDKs | anthropic, openai, litellm, tiktoken, tokenizers, huggingface-hub, hf-xet, jiter, regex, tqdm, fsspec | **needed** |
| HTTP / async / pydantic core | httpx(+core/h11/h2/hpack/hyperframe), anyio, aiohttp(+glue), pydantic(+core/settings), certifi, urllib3, requests, sniffio | **needed** |
| Config / CLI / logging | hydra-core, omegaconf, antlr4-runtime, typer, click, rich, prompt-toolkit, structlog, loguru, jsonschema(+glue) | **needed** |
| Browser automation | **playwright, pyee, greenlet, html2text** | **dead weight** (+ out-of-band Chromium binary download via `playwright install`) |
| Sandbox backends | docker, dockerfile-parse, **e2b** | docker = useful (isolate hunter); **e2b = dead weight** |
| Web gateway / Lab UI | **fastapi, uvicorn, starlette, sse-starlette, httptools, websockets, uvloop, watchfiles, python-multipart, aiofiles** | **dead weight** (server surface; never started by a headless embed) |
| gRPC + OpenTelemetry | grpcio, protobuf, googleapis-common-protos, opentelemetry-* (8) | **optional** (tracing only) |
| Office / PDF | **python-pptx, python-docx, openpyxl, reportlab, pdfplumber, pdfminer-six, pypdfium2, pillow, xlsxwriter, lxml, et-xmlfile** | **dead weight** (SAST reads source, not documents) |
| MCP | mcp, httpx-sse | optional (only if the agent calls MCP tools) |
Roughly **55–60 packages are opt-in capability weight** pulled in mandatorily, including a
browser binary and a Docker SDK — an awkward footprint for a supply-chain auditing tool.
## Decision
**ACCEPT HarnessX as the optional, SHA-pinned extra `openultrasast[harnessx]`, lazy-imported
behind `_has_harnessx()`. Keep a pre-specified vendored lean subset as a documented, triggered
fallback. Pursue upstream dependency-extras as the preferred long-term slimming path.**
Rationale:
1. **Footprint is fully opt-in.** Core `dependencies = []` stays zero-dep; the governance /
scoring / benchmark / detection planes never pull HarnessX. Only `openultrasast[harnessx]`
installs the tree, and lazy imports keep heavy backends cold unless the path runs.
2. **Author precedent.** DocuHarnessX already ships `harnessx` as a direct git dependency — the
`ModelConfig.agentic` integration is proven and maintained. OpenUltraSAST reuses the
mechanism but tightens the posture (opt-in, not direct).
3. **Lower maintenance than vendoring.** Accept + SHA-pin gives reproducibility without a fork;
vendoring is paid only if the *installed* graph is itself judged unacceptable.
### Preferred long-term: upstream dependency-extras (helps both projects)
The clean fix for "I don't need most of these" is to modularize HarnessX's own dependencies into
extras — e.g. `harnessx[browser]`, `harnessx[sandbox-e2b]`, `harnessx[gateway]`, `harnessx[office]`,
`harnessx[otel]` — leaving a lean agent-runtime core (`anthropic`/`openai`/`litellm`/`pydantic`/
`httpx`/`hydra`/`structlog`/`mcp`). Since the author owns the `Darwin-Agent/HarnessX` line, this is
actionable and benefits DocuHarnessX equally. OpenUltraSAST would then depend on
`harnessx` (lean core) under its `[harnessx]` extra and add only the subsets it needs.
### Vendored lean subset (fallback, fully specified)
If triggered, vendor `harnessx/` only:
- **KEEP**: `core/`, `processors/{control,evaluation,observability,tools,context,memory,multi_model}`,
`meta_harness/`, `rl/`, `providers/`, `tools/builtin/` (minus `browser.py`/`web_*`),
`sandbox/{base,local,docker}.py`, plus `config`/`tracing`/`plugins`/`bundles`/`workspace` as needed.
- **REMOVE**: `frontend/`, `gateway/`, `harnessx/api/` (drops the web-server stack), `examples/`,
`docs/`, `benchmarks/`, `container/`, `scripts/`, `recipe/` (incl. the `recipe/verl_harnessX/verl`
submodule — clone non-recursive regardless), `extensions/skills/{docx,pdf,pptx,xlsx}` (drops
office/PDF), `harnessx/sandbox/e2b.py` (drops e2b), `harnessx/tools/builtin/browser.py`
(drops playwright + browser binary). Net ≈ 40–60% fewer packages, no browser binary.
## Residual risks (security-tool-specific) and mitigations
| Risk | Mitigation |
|---|---|
| `playwright install` fetches a Chromium binary out-of-band (unaudited, breaks hermetic builds) | Browser path is lazy and never invoked; document that `[harnessx]` does not trigger a browser download; lean subset deletes `browser.py` |
| `docker` SDK can reach the Docker daemon socket (privilege boundary) | Docker sandbox **default-off**, `LocalSandbox` default; run untrusted target code only inside the sandbox; SHA-pin docker/cryptography/cffi |
| FastAPI/uvicorn listening-socket surface in a security tool | `api/` server only starts on the explicit `serve` path; add a guard test asserting no server starts on import; lean subset removes `api/` |
| 118-package CVE surface in a supply-chain tool | SHA-pin the entire extra; keep core **zero-dep**; add a **zero-dep CI guard test** (importing `openultrasast` without the extra pulls nothing outside stdlib) |
| `recipe/verl` git submodule recursion | Init non-recursively; never in the accept-as-extra install path |
> Decision date 2026-06-22. The `pip install --dry-run` audit is itself the Phase-3 gate: it is
> re-run before the packaging task (5.2), and if the realized graph pulls a browser binary /
> Docker SDK / FastAPI stack as *mandatory* (non-lazy) deps judged unacceptable, the spec switches
> to the vendored lean subset under the same extra name.
@@ -0,0 +1,611 @@
# Design Document
## Overview
**Purpose.** This feature splits OpenUltraSAST's single overloaded regex layer — which today is detector, severity authority, and volume knob at once — into three governed owners: **rules detect**, a **central CWE policy decides severity and scope**, and a **single project score** becomes the optimization target. On top of that governance plane it adopts the HarnessX runtime exactly where it pays off — the LLM hunter loop, the LLM-judge verifier, and a meta-harness self-improvement loop that adapts rules and policy gates under hard bounds.
**Users.** OpenUltraSAST maintainers gain a staged, reversible HarnessX adoption and an automatic, gated rule/policy tuning loop. Security policy owners gain a versioned, central CWE-to-severity policy. Security reviewers and CI consumers gain a bounded 0-100 AppSec project score with a two-condition gate.
**Impact.** Today `findings.PatternRule` carries a string `severity`, `quick_scan_findings` reads a module-level `PATTERN_RULES`, `calibration.py` only demotes ranking (floored at `MIN_CALIBRATED_PRIORITY = 0.1`, keyed on `tags[0]`), `benchmark.py:368` writes an inert `next_improvement_candidate="rules_or_language_hunter"`, and `harness.HarnessRuntime.run_stage` is a sync dict-state driver. This design turns rules into TOML data, removes `PatternRule.severity`, sources severity from a vendored verycode `CWE_Score.tsv`, computes a project score, makes the benchmark actionable, and confines HarnessX behind an optional, SHA-pinned, lazy-imported extra — while `dependencies = []` and `requires-python = ">=3.11"` stay intact and the **≥90% recall / <10% FP** detection gate holds at every phase.
### Goals
- Rules become versioned data (`ruleset/<lang>/*.toml`) with a loop-controlled `status` and no rule-local severity.
- One CWE = one governed severity, sourced from `policy/CWE_Score.tsv` via `resolve_severity()`.
- A single 0-100 `project_score` from `policy.severity × reachability`, with a two-condition CI gate.
- HarnessX adopted at the hunter loop, the verifier, and the meta-harness, behind `openultrasast[harnessx]`.
- A bounded self-improvement loop (`rule`, `policy` levers) that auto-reverts byte-for-byte under the gate.
- The governance + scoring + benchmark planes stay **zero-dependency** and **byte-identical by default** through Phases 0-2.
### Non-Goals
- The loop authoring or rewriting detection pattern text (human PR only).
- The loop editing the authoritative 0-5 CWE severity (verycode-upstream authority).
- A full single-step HarnessX rewrite of all deterministic stages and weight-level RL (deferred to optional Phase 5+).
- Changing the reachability vocabulary, which stays exactly `reachable | inferred-file-surface | unknown`.
- Dynamic/DAST scoring — report-only for a static tool.
## Boundary Commitments
### This Spec Owns
- The **RulesetStore** (`ruleset/store.py` + `ruleset/<lang>/*.toml`): `load_ruleset()`, the `PatternRule` schema (detection-only), and the `enabled|shadow|disabled` status vocabulary.
- The **PolicyStore** (`policy/verycode.py` + `policy/CWE_Score.tsv`): `load_policy()`, `CwePolicy`, `resolve_severity()`, and the CWE-120→CWE-121 remap.
- The **ScoreModel** (`scoring/project_score.py`): `SEV_WEIGHT`, `REACH_MULT`, `finding_penalty`, `project_score`, the score artifact, and the two-condition CI gate.
- The **loop ledger** (`.openultrasast/calibration/rule_policy.json`): the loop-owned overlay of `status`/evidence-floor/threshold/precision.
- The **HarnessX seam**: `harness/build_sast.py`, `HxScanOrchestrator`, the `MultiHookProcessor` SAST processors, the `SlotContractMixin`, and the `improve/` meta-harness driver.
- The **benchmark→signal** conversion: `rule_id` attribution, per-rule precision/recall, `rule_policy_delta`, and `rule_signals.json`.
### Out of Boundary
- The detection-pattern **content** of rules (authored by humans via PR; the loop may only toggle status / raise floors).
- The authoritative **0-5 severity** numbers in `CWE_Score.tsv` (verycode-upstream; PR gaps there).
- Upstream **HarnessX internals** beyond the minimal meta-harness patch (`_VALID_LEVERS`, `_render_task_brief`, `EvolveValidator`, changeset buckets, `contract_autocheck`).
- The DAST/dynamic execution engine; dynamic-only CWEs are report-only here.
- Retiring `HarnessRuntime` and adopting `rl/` — explicitly Phase 5+, optional.
### Allowed Dependencies
- **Stdlib only** for the governance/scoring/benchmark planes: `tomllib`, `csv`, `json`, `math`, `dataclasses`.
- **HarnessX** only behind the `[harnessx]` extra, pinned to a commit SHA, imported lazily behind `_has_harnessx()`.
- The existing `HarnessRuntime` as the top-level sync driver through Phases 0-4 (not removed by this spec until Phase 5).
- The existing reachability vocabulary emitted by `findings.py` (consumed, never redefined).
### Revalidation Triggers
- **Contract shape changes** to `PatternRule` (removing `severity`), `CwePolicy`, `VerificationResult`, `BenchmarkMiss`/`BenchmarkFalsePositive` (adding `rule_id`), or the `project_score` artifact JSON.
- **Data ownership change**: severity moving from rule → policy; calibration re-keying from `tags[0]` → `rule_id`.
- **Dependency direction change**: any new import of HarnessX outside a lazy `_has_harnessx()` guard; any addition to core `dependencies`.
- **Runtime prerequisite change**: the startup CWE-resolution invariant (fail-loud if an enabled rule's CWE is unmapped); the `pip install --dry-run` audit gate before Phase 3.
- **Lever surface change**: any addition to `journal._VALID_LEVERS` or the `EvolveValidator` bounds.
## Architecture
### Existing Architecture Analysis
| Existing element | File | Constraint to respect / debt to fix |
|---|---|---|
| `HarnessRuntime.run_stage(name, callback)` | `harness.py:74` | Sync, dict `state` (`{"degradations": []}`), single `HarnessEvent` (`scan_id`), `ProcessorSpec.reads/writes` allow-list. Incompatible with HarnessX async-generator + message-window contract — **bridge, do not map**. Stays as the top-level driver through Phase 4. |
| `PatternRule.severity: str` + module-level `PATTERN_RULES` | `findings.py:31,34` | The fused defect: regex carries its own severity string ("high"/"medium"/"low") and is the only volume control. **Remove `severity`; externalize patterns to TOML.** |
| `quick_scan_findings(root, targets, rankings)` | `findings.py` / `cli.py:126` | Reads `PATTERN_RULES` implicitly. Becomes `quick_scan_findings(..., ruleset)` wired like `static_hints`. |
| `calibrate_rankings` demotion | `calibration.py:146` | `max(MIN_CALIBRATED_PRIORITY, calibrated - 0.5)`, keyed on `finding.tags[0]` (`calibration.py:262`). Demotes ranking, never the rule. **Re-key to `rule_id`; write status flips to ledger.** |
| `verify_findings` rejects on `reachability_status == "unknown"` | `verification.py:91` | Structural verifier. Keep as **zero-dep fallback**; add LLM-judge path. |
| `benchmark.py:368` `next_improvement_candidate="rules_or_language_hunter"` | `benchmark.py` | Inert stub written to `calibration_records.json`. **Make actionable** via `rule_id` attribution + `rule_policy_delta`. |
| `cli.py` `run_stage(...)` call chain | `cli.py:104-145` | The 13-stage sync pipeline. Stages migrate to HarnessX hooks incrementally; the hunter pool first. |
### Architecture Pattern & Boundary Map
**Selected pattern.** Three planes with one-directional authority: **governance data drives execution; execution emits signals; the improvement loop edits governance under a gate.** Nothing in execution mutates governance directly. The governance + scoring planes are **pure stdlib, zero HarnessX** (non-negotiable, so CI gating runs in the default install). HarnessX lives only in the execution and improvement planes, behind the extra.
**Boundaries.** RulesetStore detects, PolicyStore decides severity/scope, ScoreModel optimizes — three owners, three change channels (human PR for patterns/severity; loop-via-`EvolveValidator` for status/floors/score-constants).
```mermaid
flowchart TB
subgraph GOV["GOVERNANCE PLANE — stdlib, versioned, audited, ZERO-DEP"]
direction LR
POL["PolicyStore<br/>policy/verycode.py<br/>CWE_Score.tsv -> CwePolicy<br/>severity 0-5, static/dynamic"]
RUL["RulesetStore<br/>ruleset/store.py<br/>*.toml: pattern, cwe, status"]
LED["rule_policy.json<br/>loop-owned: status/floor/precision"]
SCO["ScoreModel<br/>scoring/project_score.py<br/>policy-weighted -> 0-100 + gate"]
FPL["FP ledger<br/>false_positive_learnings.json"]
end
subgraph EXE["EXECUTION PLANE — HarnessX runtime, OPTIONAL EXTRA"]
direction LR
DET["Deterministic stages<br/>skip_model MultiHookProcessors"]
HUNT["hunter_pool<br/>HxScanOrchestrator (real LLM loop)"]
VER["VerifyProcessor<br/>llm_judge helpers (+ structural fallback)"]
PSP["PolicyScoringProcessor + ScoreProcessor"]
end
subgraph IMP["IMPROVEMENT PLANE — meta_harness, OPTIONAL EXTRA"]
EVO["improve/evolve.py -> MetaAgent.evolve()<br/>levers: rule | policy"]
BEN["BenchmarkHarness<br/>recall/FP + per-rule attribution"]
end
POL --> DET & PSP & SCO
RUL --> DET & HUNT
LED --> RUL
DET --> HUNT --> VER --> PSP --> SCO
VER --> FPL
EXE -->|trajectories + verdicts| BEN
BEN -->|rule_signals.json + deltas| EVO
EVO -->|gated edits| RUL & POL & LED
SCO -->|gate verdict| CI[("CI gate")]
BEN -->|>=90% recall / <10% FP| CI
```
**New components rationale.** PolicyStore/ScoreModel/RulesetStore exist to break the fused regex defect; `HxScanOrchestrator`/`VerifyProcessor` exist to host the agent loop and judge; `improve/evolve.py`+`EvolveValidator` exist to make adaptation safe (bounded, gated, reversible). **Steering compliance:** zero-dep default install preserved; detection gate kept as a hard constraint separate from the score reward.
### Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|-------|------------------|-----------------|-------|
| CLI | `cli.py` (`_run_scan`) | Wires ruleset/policy; Phase 3 routes hunter pool to `HxScanOrchestrator` | Unchanged entrypoints when extra absent |
| Governance | Python 3.11 stdlib (`tomllib`, `csv`, `dataclasses`) | RulesetStore + PolicyStore load | Zero-dep, fail-loud on unmapped CWE |
| Scoring | Python stdlib `math` | `project_score` exponential decay | Always on, zero-dep |
| Execution runtime | HarnessX `@ git+...@<SHA>` (optional extra) | Hunter loop, judge verifier, processors | Lazy import behind `_has_harnessx()` |
| Self-improvement | HarnessX `meta_harness` (extra/vendored) | `MetaAgent.evolve` + `EvolveValidator` + journal | 5/6-hook minimal patch |
| Data / Storage | TOML rulesets, TSV policy, JSON ledger/artifacts | Versioned governance + loop overlay | All `_target_`/JSON-serializable for resume |
| Events | HarnessX lifecycle hooks (`on_task_start`…`on_task_end`) | Stage→hook mapping | `HarnessRuntime` sync driver retained Phases 0-4 |
| Packaging | `pyproject.toml` `[project.optional-dependencies]` | `harnessx` extra, pinned SHA | Core `dependencies = []` unchanged |
## File Structure Plan
### Directory Structure
```
src/openultrasast/
├── ruleset/
│ ├── store.py # load_ruleset()/write_ruleset(); PatternRule (NO severity); status/min_evidence_level/precision_estimate/version
│ ├── python/*.toml # rules-as-data, one [[rule]] per detector
│ ├── c/*.toml # incl. CWE-120 -> CWE-121 remapped buffer rules
│ └── <lang>/*.toml # same pattern per language
├── policy/
│ ├── verycode.py # load_policy() -> dict[str, CwePolicy]; resolve_severity(); CwePolicy dataclass
│ └── CWE_Score.tsv # vendored verycode data (167 CWEs; header "Flaw Severity " has trailing space)
├── scoring/
│ └── project_score.py # SEV_WEIGHT, REACH_MULT, finding_penalty(), project_score(), gate()
├── harness/ # Phase 3 (extra)
│ ├── build_sast.py # build_sast() factory mirrors examples/coding/build_coding
│ ├── task.py # ScanTask(BaseTask)
│ ├── run.py # HxScanOrchestrator: asyncio.run around model_config.agentic(cfg).run(...)
│ ├── slot_contract.py # SlotContractMixin: reads_slots/writes_slots on MultiHookProcessor
│ └── processors.py # SarifIngest/Preprocess/EntryPoint/Rank/Calibrate/QuickFindings/PolicyScoring/Verify/Score processors
└── improve/ # Phase 4 (extra)
├── evolve.py # orchestrator: benchmark -> delta -> rule_signals.json -> MetaAgent.evolve() -> re-benchmark -> accept/revert
├── rule_validator.py # EvolveValidator._rules / ._policy bound checks
└── rule_autocheck.py # ContractAutoCheckProcessor _check_rule/_check_policy branches
.openultrasast/calibration/
└── rule_policy.json # loop-owned ledger: status/threshold/evidence-floor/precision per rule_id
```
### Modified Files
- `findings.py` — extract `PATTERN_RULES` to TOML; **delete `PatternRule.severity`**; `quick_scan_findings(root, targets, rankings, ruleset)` takes ruleset arg.
- `calibration.py` — re-key from `tags[0]` (line 262) to `rule_id`; write `rule_policy.json` status flips, not only ranking demotion (line 146).
- `benchmark.py` — add `rule_id` to `BenchmarkMiss`/`BenchmarkFalsePositive`; per-rule precision/recall in `BenchmarkMetrics`; emit `rule_policy_delta`; replace the inert stub at line 368; add `BenchEvaluator` subclass.
- `verification.py` — add LLM-judge path (`VerifyProcessor` over `llm_judge` helpers); keep structural verifier (`reachability_status == "unknown"` reject, line 91) as zero-dep fallback.
- `config.py` — ruleset/policy/score paths + gate thresholds (`K`, `MIN_SCORE`, `min_emit_priority`, `min_emit_precision`).
- `cli.py` — `_run_scan` wires ruleset/policy; Phase 3 routes the `hunter_pool` `run_stage` call (line 122) to `HxScanOrchestrator`.
- `harness.py` — retired in Phase 5 only.
- `pyproject.toml` — add `[project.optional-dependencies] harnessx = ["harnessx @ git+https://github.com/Darwin-Agent/HarnessX.git@<SHA>"]`; core `dependencies = []` unchanged.
- `harnessx/meta_harness/{journal.py, agent.py, validate_workflow.py, processors/contract_autocheck.py}` — vendored/patched seam (Phase 4).
## System Flows
### Scan pipeline on HarnessX (Phase 3 seam)
The top-level driver stays sync (`HarnessRuntime`); only the `hunter_pool` stage opens an async surface via `asyncio.run`. Deterministic stages run as `skip_model=True` processors carrying work in typed slots; the `SlotContractMixin` validates `reads_slots`/`writes_slots` on every hook (preserving the `ProcessorSpec.reads/writes` discipline). If a HarnessX capability is unavailable at runtime, the stage falls back to its existing equivalent and records a degradation in the manifest.
```mermaid
flowchart TD
A["on_task_start: SarifIngestProcessor<br/>-> slot sast.static_hints"] --> B["Preprocess -> sast.targets"]
B --> C["EntryPoint -> sast.entry_points"]
C --> D["on_step_start: Rank -> sast.rankings"]
D --> E["Calibrate (MemoryRetrieval pattern over FP ledger)<br/>demotes sast.rankings"]
E --> F{"HarnessX extra<br/>present & capable?"}
F -->|yes| G["hunter_pool: HxScanOrchestrator<br/>asyncio.run(model_config.agentic(cfg).run(ScanTask))<br/>control/tools/obs processors -> StatefulTrajectory"]
F -->|no / capability missing| G2["fallback run_hunter_pool()<br/>record degradation in manifest"]
G --> H["QuickFindings: regex over load_ruleset()<br/>-> sast.findings (NO severity yet)"]
G2 --> H
H --> I["PolicyScoringProcessor: resolve_severity(policy, rule.cwe)<br/>+ emission decision; drop shadow findings"]
I --> J{"reachability_status<br/>== unknown?"}
J -->|yes| K["VerifyProcessor: REJECTED (structural fallback)<br/>or llm_judge verdict"]
J -->|no| K2["VerifyProcessor: llm_judge build/parse/call<br/>(structural fallback if extra absent)"]
K --> L["record_calibration: lesson/missing -> FalsePositiveLearning<br/>+ per-rule outcome -> rule_policy.json"]
K2 --> L
L --> M["on_task_end: ScoreProcessor -> project_score + gate"]
M --> N["report / sarif / manifest (incl. score block)"]
```
### Self-improvement loop
The benchmark is the **regression suite governing the data stores**. The objective is `maximize project_score subject to recall ≥ 90% AND FP < 10%` as a hard feasibility constraint — kept separate so the loop cannot "improve" the number by shadowing rules that hurt real security. Every change is `shadow`-staged first, novelty/evidence/replay-gated, and reverted byte-for-byte on any failure.
```mermaid
sequenceDiagram
participant B as BenchmarkHarness
participant O as improve/evolve.py
participant M as MetaAgent.evolve()
participant V as EvolveValidator (_rules/_policy)
participant J as journal (compute_attribution/fill_gating)
participant L as rule_policy.json
B->>O: misses(recall) + FPs/verifier-rejects(precision) + per-rule metrics
O->>O: write rule_signals.json [{rule_id, signal, cwe, evidence, suggested_action}]
O->>M: evolve(brief + rule_signals.json)
M->>M: write ruleset/*.toml + policy/overrides.toml + candidates.md + journal Round N
M->>V: validity(canon -> contract -> _rules/_policy -> replay) + policy(novelty + evidence)
alt validation fails
V-->>M: StrictValidationError(kind="rule_change") -> round rejected
else passes
V->>B: re-run benchmark on candidate
B->>J: passed_now vs passed_before
J->>J: compute_attribution(predicted): flipped/still_F/regressed/still_T/absent
alt recall<90% OR FP>=10% OR score regressed OR unpredicted regression
J->>L: fill_gating(reverted) -> restore config byte-for-byte
else
J->>L: fill_gating(accepted) -> persist status/floor/precision
end
end
```
## Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|-------------|---------|------------|------------|-------|
| 1.1, 1.4 | Hunter pool on real `Harness.run()`; async confined to one stage | `HxScanOrchestrator` | `run.py:HxScanOrchestrator.run()` | Scan pipeline |
| 1.2 | Verify via llm-judge + structural fallback | `VerifyProcessor` | `processors.py:VerifyProcessor` | Scan pipeline |
| 1.3 | Extra absent → unchanged quick/standard | `cli._run_scan`, `_has_harnessx()` | capability guard | Scan pipeline (`F` branch) |
| 1.5 | Slot allow-list validated per hook | `SlotContractMixin` | `slot_contract.py` | Scan pipeline |
| 1.6 | Deterministic stages `skip_model` | SAST processors | `MultiHookProcessor(skip_model=True)` | Scan pipeline |
| 1.7 | Event/`scan_id` rename to HarnessX hooks/`run_id` | `build_sast`, journal | hook map table | Scan pipeline |
| 1.8 | Capability-missing fallback + manifest degradation | `HxScanOrchestrator` | manifest `degradations` | Scan pipeline (`G2`) |
| 2.1, 2.4 | Rules-as-data; 3 statuses; no rule severity | RulesetStore | `load_ruleset()`, `PatternRule` | — |
| 2.2, 2.3 | Severity from policy; legacy strings discarded | PolicyStore, `PolicyScoringProcessor` | `resolve_severity()` | Scan pipeline (`I`) |
| 2.5 | Shadow excluded from report+score | `PolicyScoringProcessor`, ScoreModel | emission filter | Scan pipeline (`I`,`M`) |
| 2.6 | CwePolicy 0-5 + static/dynamic; dynamic report-only | PolicyStore | `CwePolicy` | — |
| 2.7 | Fail-loud on unmapped enabled-rule CWE | PolicyStore startup invariant | `load_policy()` check | — |
| 2.8 | CWE-120 → CWE-121 remap | RulesetStore | TOML data | — |
| 2.9, 5.9, 7.5 | Governance/score/benchmark zero-dep | all GOV components | stdlib-only | — |
| 3.1 | Two levers `rule`/`policy` | `improve/evolve.py` | `_VALID_LEVERS` | Self-improvement |
| 3.2, 3.3 | No pattern text / no 0-5 severity edits | `EvolveValidator` | `_rules`/`_policy` | Self-improvement |
| 3.4 | No enabled→disabled jump (shadow mandatory) | `EvolveValidator._rules` | bound check | Self-improvement |
| 3.5 | Parse/status/bounds/dup/CWE-resolve checks | `EvolveValidator._rules` | validity phase | Self-improvement |
| 3.6 | Novelty gate on reverted re-proposals | `journal.check_novelty` | novelty gate | Self-improvement |
| 3.7 | Replay smoke gate (ruleset boots) | `run_synthetic_task_smoke_gate` | replay phase | Self-improvement |
| 3.8, 3.9 | `StrictValidationError`; journal each edit | `EvolveValidator`, journal | `fill_gating` | Self-improvement |
| 4.1, 4.2 | `rule_id` attribution; per-rule metrics; delta artifact | BenchmarkHarness | `rule_policy_delta` | Self-improvement |
| 4.3 | Delta → `rule_signals.json` (miss/fp) | `improve/evolve.py` | signal writer | Self-improvement |
| 4.4, 4.5 | Hard accept gate; byte-for-byte revert | `improve/evolve.py`, journal | `fill_gating(reverted)` | Self-improvement |
| 4.6 | Auto-shadow precision-draggers | `improve/evolve.py` | status flip | Self-improvement |
| 4.7 | Coverage-gap nominates rule (human PR) | `improve/evolve.py` | `rule_signals.json` | Self-improvement |
| 4.8, 6.5 | Score=reward, gate=feasibility (separate) | `improve/evolve.py` | objective split | Self-improvement |
| 5.1, 5.2 | 0-100 score; reachability multipliers | ScoreModel | `project_score()` | Scan pipeline (`M`) |
| 5.3 | Dynamic-only/unmapped → 0 penalty, report-only | ScoreModel | `finding_penalty()` | Scan pipeline (`M`) |
| 5.4 | Shadow excluded from score input | ScoreModel | pre-call filter | Scan pipeline (`M`) |
| 5.5 | Score artifact + manifest block | ScoreModel | score JSON | Scan pipeline (`N`) |
| 5.6 | FP lowers effective reachability, not delete | `calibration.py` | reachability knob | Self-improvement |
| 5.7 | Hard gate: sev5 + reachable → fail | CI Score Gate | `gate()` | — |
| 5.8 | Score gate advisory-first, then blocking | CI Score Gate | `gate()` | — |
| 6.1, 6.4, 6.6 | Per-merge recall/FP gate + HX tolerance | CI Pipeline | benchmark gate | — |
| 6.2 | Byte-identical baseline Phases 0-2 | BenchmarkHarness | golden diff | Migration |
| 6.3 | Gate must pass on re-benchmarked candidate | `improve/evolve.py` | accept gate | Self-improvement |
| 7.1, 7.6 | Empty core deps; pinned extra; non-recursive submodule | Packaging Config | `pyproject.toml` | Migration |
| 7.2 | Lazy import behind capability check | `_has_harnessx()` | guard | — |
| 7.3, 7.4, 7.7 | Dry-run audit; vendored fallback trigger; pre-specified | Packaging Config | audit gate | Migration (Phase 3 gate) |
## Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|-----------|--------------|--------|--------------|--------------------------|-----------|
| RulesetStore | Governance | Rules-as-data + status | 2.1,2.4,2.8 | tomllib (P0), rule_policy.json (P1) | State |
| PolicyStore | Governance | CWE→severity/scope authority | 2.2,2.6,2.7 | CWE_Score.tsv (P0) | State |
| ScoreModel | Scoring | 0-100 score + gate | 5.1-5.9 | PolicyStore (P0) | Batch, State |
| HxScanOrchestrator | Execution | Hunter loop on HarnessX | 1.1,1.4,1.8 | HarnessX (P0), RulesetStore (P1) | Service, Event |
| PolicyScoringProcessor | Execution | Resolve severity + emission | 2.2,2.3,2.5 | PolicyStore (P0) | Event |
| VerifyProcessor | Execution | LLM-judge + structural fallback | 1.2 | llm_judge (P1) | Event |
| EvolveValidator (`_rules`/`_policy`) | Improvement | Bound-check loop edits | 3.2-3.8 | journal (P0), PolicyStore (P1) | Service |
| improve/evolve.py | Improvement | Benchmark→signal→evolve→gate | 4.1-4.8,6.3 | BenchmarkHarness (P0), MetaAgent (P0) | Batch |
### Governance Layer
#### RulesetStore
| Field | Detail |
|-------|--------|
| Intent | Load detection rules from versioned TOML; carry loop-controlled status; carry no severity |
| Requirements | 2.1, 2.4, 2.8, 2.9 |
**Responsibilities & Constraints**
- `load_ruleset(path) -> tuple[PatternRule, ...]` replaces module-level `PATTERN_RULES`; loaded with `tomllib` (zero-dep).
- `PatternRule` carries `rule_id`, `languages`, `cwe`, `tags`, `pattern`, `status`, `min_evidence_level`, `precision_estimate`, `version` — **no `severity` field**.
- `status ∈ {enabled, shadow, disabled}` is the volume control; the loop overlays `status`/`min_evidence_level`/`precision_estimate` from `rule_policy.json`.
- C/C++ buffer rules (`c-unsafe-copy`, `c-unsafe-memory-copy`, `c-unsafe-scanf`) declare `cwe = "CWE-121"` (remapped from unmapped CWE-120).
**Dependencies** — Inbound: `quick_scan_findings` (P0). Outbound: `rule_policy.json` overlay (P1). External: `tomllib` (P0).
**Contracts**: State [x]
##### Service Interface
```python
def load_ruleset(path: Path, ledger: Path | None = None) -> tuple[PatternRule, ...]: ...
def write_ruleset(path: Path, rules: Iterable[PatternRule]) -> None: ...
@dataclass(frozen=True)
class PatternRule:
rule_id: str
languages: tuple[str, ...]
cwe: str # the ONLY policy join key
tags: tuple[str, ...]
pattern: str # human-PR-only; loop must never edit
status: str = "enabled" # enabled | shadow | disabled (loop-controlled)
min_evidence_level: str = "static_corroboration"
precision_estimate: float = 0.0
version: str = "1"
```
- **Preconditions:** every loaded rule has a non-empty `pattern` and a `CWE-NNN` `cwe`.
- **Postconditions:** ledger overlay applied; `status`/floors reflect `rule_policy.json`.
- **Invariants:** no `severity` attribute exists; `status` is one of the three literals.
#### PolicyStore
| Field | Detail |
|-------|--------|
| Intent | Authoritative CWE→severity/scope; resolve finding severity |
| Requirements | 2.2, 2.3, 2.6, 2.7, 2.8 |
**Responsibilities & Constraints**
- `load_policy() -> dict[str, CwePolicy]` keyed `"CWE-NNN"` from `CWE_Score.tsv` (167 CWEs; severity column header is literally `"Flaw Severity "` with a trailing space — parse defensively).
- `resolve_severity(policy, rule.cwe, target, ranking)` replaces every literal `rule.severity` read. **Policy always wins**; any legacy rule severity string is discarded.
- **Startup invariant (fail-loud):** every `enabled` rule's `cwe` must resolve in PolicyStore, else the scan aborts. `dynamic`-only CWEs are report-only.
**Dependencies** — Inbound: `PolicyScoringProcessor`, ScoreModel (P0). External: `csv`, `dataclasses` (P0).
**Contracts**: State [x]
##### Service Interface
```python
@dataclass(frozen=True)
class CwePolicy:
flaw_category: str
severity: int # 0..5 — AUTHORITATIVE; overrides any rule string; loop must never edit
static: bool # SAST scope gate
dynamic: bool # DAST scope — report out-of-scope, never scored by static tool
def load_policy(tsv: Path = CWE_SCORE_TSV) -> dict[str, CwePolicy]: ...
def resolve_severity(policy: Mapping[str, CwePolicy], cwe: str, target, ranking) -> int: ...
def assert_rules_resolve(rules, policy) -> None: # raises at startup if an enabled rule's CWE is unmapped
...
```
- **Invariants:** one CWE ⇒ one severity; two rules for the same CWE always receive identical severity.
### Scoring Layer
#### ScoreModel
| Field | Detail |
|-------|--------|
| Intent | Compute a 0-100 project score and the two-condition gate verdict |
| Requirements | 5.1-5.9 |
**Responsibilities & Constraints**
- Pure stdlib (`math`), always on, zero-dep — runs in the default install with no HarnessX.
- Excludes `shadow`-status findings before computing; assigns 0 penalty to dynamic-only/unmapped CWEs (reports them instead).
- The `REACH_MULT` keys are exactly the three values `findings.py` emits; the reachability multiplier is the **FP-calibration knob** (a confirmed FP lowers effective reachability, never deletes a rule).
- Emits the scan-level artifact and the manifest score block; computes the hard gate (sev5 + reachable) and the score gate (advisory-first).
**Contracts**: Batch [x] / State [x]
##### Service Interface
```python
SEV_WEIGHT = {5: 50, 4: 25, 3: 10, 2: 2, 1: 1, 0: 0}
REACH_MULT = {"reachable": 1.0, "inferred-file-surface": 0.6, "unknown": 0.4}
def finding_penalty(f, rule_cwe: str, policy) -> float:
pol = policy.get(rule_cwe)
if pol is None or not pol.static: # dynamic-only / unmapped -> report, don't score
return 0.0
return SEV_WEIGHT[pol.severity] * REACH_MULT.get(f.reachability_status, 0.4)
def project_score(findings, rule_cwe_by_id, policy, K: float = 60.0) -> int:
# shadow-status findings excluded before this call
P = sum(finding_penalty(f, rule_cwe_by_id[f.finding_id], policy) for f in findings)
return round(100 * math.exp(-P / K)) # P=0 -> 100; one critical-reachable -> ~43
def gate(findings, score, *, min_score: int, block_severity_reachable: int = 5,
blocking: bool) -> dict: ...
```
- **Postconditions:** artifact JSON (see Data Models) written next to per-finding output and merged into the manifest.
### Execution Layer
#### HxScanOrchestrator
| Field | Detail |
|-------|--------|
| Intent | Run the hunter pool on a real HarnessX agent loop, confining async to one stage |
| Requirements | 1.1, 1.4, 1.7, 1.8 |
**Responsibilities & Constraints**
- Invoked from the existing `hunter_pool` `run_stage` callback (`cli.py:122`); wraps `asyncio.run(model_config.agentic(harness_config).run(ScanTask(...)))`.
- `build_sast()` mirrors `examples/coding/build_coding`; `ModelConfig(main=hunter, judge=…, ranker=…, patcher=…).agentic(cfg)` keeps model info out of `HarnessConfig`.
- Each hunter sub-harness registers `LoopDetectionProcessor`, `CostGuardProcessor`/`TokenBudgetProcessor`, `ToolFailureGuard`, `ToolFilterProcessor`, `SelfVerifyProcessor`, `EpisodeMetricsProcessor`/`OTelProcessor`/`CheckpointProcessor`.
- Produces a `StatefulTrajectory` per target; rewards backfilled from verifier verdicts. On capability-missing, falls back to `run_hunter_pool()` and appends to manifest `degradations`.
**Dependencies** — External: HarnessX core + processors (P0, lazy). Inbound: `cli._run_scan` (P0).
**Contracts**: Service [x] / Event [x]
##### Event Contract
- Hook mapping: `stage_start`→`on_step_start`, `stage_end`→`on_step_end`, `scan_id`→`session_id`/`run_id`; `before/after_model|tool` direct. `HARNESSX_CONTRACT_MODE` (message-window) + `OUS_SLOT_CONTRACT` (key-level via `SlotContractMixin`) split the old single `contract_mode`.
#### PolicyScoringProcessor
| Field | Detail |
|-------|--------|
| Intent | Resolve policy severity per finding and make the emission decision |
| Requirements | 2.2, 2.3, 2.5 |
**Responsibilities & Constraints**
- Runs `on_after_tool`/`on_step_end`; reads `sast.rankings` + PolicyStore; writes resolved severity + emit-decision.
- Drops `shadow` findings from the reported set and the score input while recording their outcome for precision tracking.
- Applies `min_emit_priority`/`min_emit_precision` thresholds — the new volume controls that replace "every regex match becomes a finding".
**Contracts**: Event [x]
#### VerifyProcessor
| Field | Detail |
|-------|--------|
| Intent | LLM-judge verification with a zero-dep structural fallback |
| Requirements | 1.2 |
**Responsibilities & Constraints**
- Wraps `build_judge_prompt`/`parse_judge_response`/`_call_judge` (`llm_judge.py`) as a `judge`-role sub-harness; maps the strict-JSON verdict (`plausible|unsupported|hedging|…` + `lesson`/`missing`) to `VerificationResult`.
- **Fallback:** the existing structural verifier (`verification.py:91`, rejects on `reachability_status == "unknown"`) is used verbatim when the extra is absent or the judge is unavailable.
**Contracts**: Event [x]
### Improvement Layer
#### EvolveValidator (`_rules` / `_policy`)
| Field | Detail |
|-------|--------|
| Intent | Enforce the bounded-lever safety contract on every loop edit |
| Requirements | 3.2-3.8 |
**Responsibilities & Constraints** (the gate — runs in the *validity* phase beside `_contract`/`_replay`)
- `_rules`: edit parses; `status ∈ {enabled,shadow,disabled}`; `tighten`/`loosen` adjusts only threshold/evidence-floor **within configured bounds**; `add` doesn't duplicate a `rule_id`; **every `cwe` resolves in PolicyStore**; **pattern text unchanged** (reject otherwise); **no `enabled→disabled` jump** (require `shadow`).
- `_policy`: touches only evidence floor, scope, `K`/`MIN_SCORE` within bounds; **never the 0-5 severity**.
- On any failure: `raise StrictValidationError(kind="rule_change")` → the whole round is rejected.
**Contracts**: Service [x]
##### Service Interface
```python
_VALID_LEVERS = frozenset({
"configuration", "control", "action", "instruction",
"rule", # status/evidence-floor/threshold in ruleset/ — NEVER pattern text
"policy", # evidence floor + scope + K/MIN_SCORE — NEVER the 0-5 CWE severity
})
class EvolveValidator:
def _rules(self, edit) -> None: ... # raises StrictValidationError(kind="rule_change")
def _policy(self, edit) -> None: ...
```
#### improve/evolve.py (orchestrator)
| Field | Detail |
|-------|--------|
| Intent | Drive benchmark→signal→evolve→re-benchmark→accept/revert under the gate |
| Requirements | 4.1-4.8, 6.3 |
**Responsibilities & Constraints**
- Converts `rule_policy_delta` + FP learnings → `rule_signals.json` (recall side `signal="miss"`; precision side `signal="verifier_reject"|"fp"`); calls `MetaAgent.evolve()`.
- **Acceptance gate (hard):** round accepted **only if** aggregate recall ≥ 90% **AND** FP < 10% **AND** `project_score` did not regress on the held set **AND** no unpredicted regression; otherwise `fill_gating(outcome="reverted")` restores config byte-for-byte.
- Precision-dragging rules are **auto-shadowed**, never deleted; coverage gaps nominate a rule via `rule_signals.json` (pattern authoring stays human-PR).
- Objective: `maximize project_score s.t. recall/FP gate` — score is the reward, the gate is the feasibility region; never folded together.
**Contracts**: Batch [x]
## Data Models
### Ruleset policy schema (detection only — TOML)
```toml
[[rule]]
rule_id = "python-sql-injection"
languages = ["python"]
cwe = "CWE-89" # the ONLY policy join key
tags = ["injection"]
pattern = '(\.execute)\s*\(\s*[^,)]*%' # human-PR-only
status = "enabled" # enabled | shadow | disabled (loop-controlled)
min_evidence_level = "static_corroboration" # loop may raise within bounds
precision_estimate = 0.62 # backfilled from benchmarks
version = "3"
```
Status semantics: `enabled` fires/scores/reports; `shadow` fires and records outcomes but is excluded from report and score (mandatory staging state for any loop-proposed change); `disabled` does not fire.
Loop ledger (`rule_policy.json`) — loop-owned overlay, re-keyed on `rule_id`:
```json
{ "python-sql-injection": { "status": "shadow", "min_evidence_level": "dynamic_corroboration",
"precision_estimate": 0.41, "outcome": "accepted", "round": 7 } }
```
### Project-score schema (artifact + manifest block)
```json
{ "project_score": 73, "max_severity": 5, "penalty_total": 30.0,
"by_category": {"Command or Argument Injection": 1, "Cryptographic Issues": 2},
"out_of_scope_dynamic_only": 0,
"unmapped_cwe": [],
"gate": {"min_score": 80, "block_severity_reachable": 5, "blocking": false, "passed": false} }
```
### Consistency & Integrity
- **Authority direction:** governance data is the source of truth; execution reads it, emits findings/signals; only the loop (via `EvolveValidator`) writes `status`/floors/score-constants back, never the pattern or the 0-5 severity.
- **Temporal/versioning:** `PatternRule.version` and `rule_policy.json.round` give per-rule audit; the journal records each lever edit; `fill_gating(reverted)` restores byte-for-byte.
- **Serialization (resume-safe):** all scan slots (`sast.targets`, `sast.findings`, `sast.rankings`, verifications) use plain dicts/dataclasses with `_target_` registration so they survive `wake()`.
## Error Handling
### Error Strategy
| Error type | Condition | Response |
|---|---|---|
| **Startup config error** | An `enabled` rule's `cwe` is unmapped in PolicyStore | `assert_rules_resolve` raises; **fail loud**, abort scan before any work (Req 2.7) |
| **Malformed ruleset/policy TOML** | `tomllib` parse failure | Fail loud at load; CI catches before merge |
| **Loop edit violates bounds** | Pattern edit / severity edit / enabled→disabled / duplicate `rule_id` / unmapped CWE | `EvolveValidator` raises `StrictValidationError(kind="rule_change")`; entire round rejected (Req 3.8) |
| **Reverted re-proposal w/o rationale** | Novelty gate hit | `journal.check_novelty` blocks; loop continues with other candidates (Req 3.6) |
| **Replay smoke gate failure** | Edited ruleset/policy won't boot | Round rejected before benchmark re-run (Req 3.7) |
| **Acceptance-gate failure** | recall<90% OR FP≥10% OR score regressed OR unpredicted regression | `fill_gating(reverted)` restores config byte-for-byte (Req 4.5) |
| **HarnessX capability missing at runtime** | Extra absent or sub-harness unavailable | Stage falls back to existing equivalent; record degradation in manifest (Req 1.8) |
| **Slot contract violation** | Stage reads/writes an undeclared slot | `SlotContractMixin` raises under `OUS_SLOT_CONTRACT=strict`; warns under `warn` (Req 1.5) |
| **Slot serialization loss on resume** | Non-`_target_` slot restores as `None` | Per-slot-type round-trip test guards; `_target_` registration required |
### Monitoring
- `EpisodeMetricsProcessor.episode_summary` → manifest metrics block; `OTelProcessor` OTLP export when configured.
- `HarnessJournal` writes per-run `{run_id}.jsonl` / `_trace.jsonl` / `_state.json`, rotated on `SegmentBoundaryEvent`.
- The score artifact `unmapped_cwe` list surfaces policy-completeness drift; the journal changeset table surfaces every rule/policy lever edit per round.
## Testing Strategy
### Unit Tests
- `load_policy()` parses the `"Flaw Severity "` trailing-space header and yields 167 `CwePolicy` entries with correct `static`/`dynamic` flags.
- `project_score()` boundary cases: P=0 → 100; one severity-5 reachable finding → ~43; one low finding → ~97; shadow findings excluded.
- `resolve_severity()` returns policy severity (not the legacy rule string) and is identical for two rules sharing a CWE.
- `EvolveValidator._rules` rejects: pattern edit, severity edit, `enabled→disabled` jump, duplicate `rule_id`, unmapped CWE.
- `finding_penalty()` returns 0.0 for dynamic-only and unmapped CWEs.
### Integration Tests
- **Byte-identical baseline (Req 6.2):** with the default ruleset and an empty `rule_policy.json`, `benchmark.py` output matches the pre-feature golden corpus exactly.
- **Startup invariant (Req 2.7):** an enabled rule with an unmapped CWE aborts the scan with a loud error.
- **Self-improvement round (Req 4.4/4.5):** a deliberately noisy rule is auto-shadowed when it drags precision; a round that regresses recall is reverted byte-for-byte.
- **Capability fallback (Req 1.8):** extra absent → `quick`/`standard` run through the sync driver unchanged; degradation recorded when a HX stage is requested but unavailable.
- **Slot round-trip (Req on resume):** each scan slot type serializes and restores without loss after `wake()`.
### E2E / Gate Tests
- **CI detection gate (Req 6.1):** the pipeline fails the merge if recall<90% or FP≥10% on the corpus.
- **Two-condition score gate (Req 5.7/5.8):** hard gate fails on a sev5+reachable finding; score gate is advisory until `K`/`MIN_SCORE` are calibrated, then blocking.
- **Zero-dep guard (Req 7.5):** with HarnessX absent, governance/scoring/benchmark planes operate and CI gating runs in the default install.
### Performance / Tolerance
- **HX tolerance (Req 6.4):** an enabled HarnessX-backed stage keeps recall/FP within the configured tolerance of the zero-dep baseline.
- Hunter-loop cost/token budgets enforced by `CostGuardProcessor`/`TokenBudgetProcessor`.
## Security Considerations
- **Supply-chain posture (Req 7):** core `dependencies = []` is preserved; HarnessX ships only as a SHA-pinned optional extra, lazy-imported behind `_has_harnessx()`. A `pip install --dry-run` audit runs before Phase 3; if the realized transitive graph pulls browser binaries / Docker SDK / a FastAPI web stack as *mandatory* deps and maintainers judge it unacceptable for a supply-chain auditing tool, the pre-specified **vendored lean subset** (Option C) is switched in under the same extra name — a packaging change, not a redesign. Submodule init is **non-recursive** so the large `verl` RL fork is never pulled.
- **Loop containment:** the meta-agent can never invent or rewrite detectors (no pattern edits) or rewrite the severity authority (no 0-5 edits); `shadow`-first staging, novelty/evidence/replay gates, and byte-for-byte revert bound its blast radius — directly preventing the "too influential" defect from re-entering via the loop.
- **Gate integrity:** the recall/FP gate is a hard constraint that is never folded into the score reward, so the loop cannot "improve" the number by shadowing rules that hurt real security.
## Migration Strategy
Every phase is independently shippable, test-gated, and reversible. Phases 0-2 deliver governance value at **zero dependency cost** and stay byte-identical by default. Phases 3-4 are the HarnessX adoption behind the extra. Phase 5 is the optional native end-state.
```mermaid
flowchart LR
P0["Phase 0<br/>Policy + Score<br/>zero-dep, gate advisory"] --> P1["Phase 1<br/>Rules-as-data<br/>zero-dep, byte-identical"]
P1 --> P2["Phase 2<br/>Benchmark->signal + ledger<br/>zero-dep"]
P2 --> GATE{"pip --dry-run audit<br/>(D2 decision)"}
GATE -->|graph acceptable| P3B["Phase 3<br/>[harnessx] extra (Option B)<br/>hunter loop + verify"]
GATE -->|graph unacceptable| P3C["Phase 3<br/>vendored lean subset (Option C)<br/>same extra name"]
P3B --> P4["Phase 4<br/>Self-improvement loop live"]
P3C --> P4
P4 --> P5["Phase 5 (optional)<br/>full single-step harness + RL<br/>retire HarnessRuntime"]
```
| Phase | Scope | Dep cost | Merge gate | Reversibility |
|---|---|---|---|---|
| **0 — Policy & score** | Vendor `CWE_Score.tsv`; `policy/verycode.py` + `scoring/project_score.py`; CWE-120→121 remap; startup CWE-resolution invariant; emit score artifact + advisory gate. `HarnessRuntime`/`PATTERN_RULES` unchanged. | **zero** | benchmark byte-identical; score is additive output | gate disable flag |
| **1 — Rules as data** | Extract `PATTERN_RULES`→`ruleset/<lang>/*.toml`; delete `PatternRule.severity`; `load_ruleset()`; `resolve_severity`; add `status` + emit thresholds. | **zero** | benchmark **byte-identical** to Phase 0 | keep `PATTERN_RULES` fallback |
| **2 — Benchmark→signal + ledger** | `rule_id` on misses/FPs; per-rule precision/recall; emit `rule_policy_delta`; `rule_policy.json` ledger; calibration writes status flips re-keyed on `rule_id`. | **zero** | empty ledger == current behavior | empty ledger = no-op |
| **3 — HX execution adoption** | Run `pip --dry-run` audit (D2 gate). Add `[harnessx]` extra (pinned SHA, lazy, zero-dep CI guard); `build_sast()` + `HxScanOrchestrator`; hunter pool on real `Harness.run()`; `VerifyProcessor`; `PolicyScoring`/`ScoreProcessor`. `HarnessRuntime` stays. | **opt-in heavy** | extra-absent: unchanged; extra-present: recall/FP within tolerance | feature flag off |
| **4 — Self-improvement live** | Vendor/patch the meta-harness seam; orchestrator benchmark→delta→signal→`evolve()`→re-benchmark→accept/revert. | **opt-in** | ≥90% recall / <10% FP per-round; auto-revert | don't run `evolve` → ledger-only changes |
| **5 — Full harness + RL (optional)** | Migrate deterministic stages to single-step harness + `SlotContractMixin`; retire `HarnessRuntime`; promote score gate to blocking; optional `rl/`. | **opt-in** | golden-diff vs Phase 3 empty; gate held | per-stage revert |
**Rollback triggers.** Any phase that drops recall<90% or raises FP≥10% on the corpus blocks the merge; any self-improvement round failing the acceptance gate reverts byte-for-byte; an unacceptable `pip --dry-run` graph switches Phase 3 to the vendored subset.
@@ -0,0 +1,115 @@
# Requirements Document
## Introduction
OpenUltraSAST today fuses three responsibilities into one regex layer: detection patterns also carry their own severity strings and act as the sole volume control, so a noisy rule can dominate scan output, set its own criticality, and fire forever with no central governance. This feature splits those responsibilities into three governed owners — rules detect, a central CWE policy decides severity and scope, and a single project score becomes the optimization target — and adopts the HarnessX runtime exactly where it pays off: the LLM hunter loop, the LLM-judge verifier, and a meta-harness self-improvement loop that adapts rules and policy gates under hard bounds.
The work spans five user asks. First, OpenUltraSAST shall adopt HarnessX directly at the hunter loop and migrate the existing scan flow stage-by-stage onto HarnessX processors. Second, detection rules shall become versioned data governed centrally, with severity sourced from the verycode CWE policy rather than from the rule itself. Third, the HarnessX meta-harness shall adapt rules and policy gates within bounded levers. Fourth, benchmark results shall drive that adaptation as actionable signals with a hard acceptance gate. Fifth, a Veracode-style project score shall be computed from policy-weighted, reachability-adjusted findings and gated in CI.
Two cross-cutting constraints govern every part of this feature. The existing detection gate of at least 90% recall and under 10% false-positive rate on the benchmark corpus must never regress, at any phase, for any change. And the HarnessX runtime must be adopted in a way that preserves the project's zero-dependency posture for the governance and scoring planes, so an explicit dependency-weight decision controls whether HarnessX ships as an optional extra or as a vendored lean subset.
## Boundary Context
- **In scope**: detection rules as versioned data; a central CWE-to-severity policy with static/dynamic scope; a single project score (0-100) with a two-condition CI gate; HarnessX adoption at the hunter loop, the verifier, and the self-improvement meta-harness; benchmark-to-signal conversion with per-rule attribution; bounded loop authority over rule status, evidence floors, thresholds, and score constants; a dependency-weight audit and decision; preservation of the recall/FP detection gate.
- **Out of scope**: authoring new detection pattern text by the loop (human pull-request only); editing the authoritative 0-5 CWE severity by the loop (verycode-upstream authority); a full single-step HarnessX rewrite of all deterministic stages and weight-level reinforcement learning (deferred, optional, later phase); changing the reachability vocabulary, which remains exactly `reachable | inferred-file-surface | unknown`; dynamic/DAST scoring, which is report-only for a static tool.
- **Adjacent expectations**: the governance and scoring planes shall run with no HarnessX present and `dependencies = []`; CI gating (score plus benchmark) shall run in the zero-dependency default install; HarnessX, when present, shall be confined behind a capability check and lazy imports so heavy code paths stay cold in the default install.
## Requirements
### Requirement 1: HarnessX Direct Adoption and Scan-Flow Migration
**Objective:** As an OpenUltraSAST maintainer, I want the scan flow to adopt the HarnessX runtime directly at the LLM hunter loop and migrate the existing stages onto HarnessX processors in a staged way, so that the project gains the HarnessX agent loop, journal, and trajectory machinery without a high-risk big-bang rewrite.
#### Acceptance Criteria
1. Where the HarnessX extra is installed, the Scan Orchestrator shall run the hunter pool stage on a real HarnessX `Harness.run()` loop with the HarnessX control, tool, and observability processors registered in the hunter sub-harness.
2. Where the HarnessX extra is installed, the Verify Processor shall produce verification verdicts using the HarnessX llm-judge helpers and shall retain the structural verifier as a zero-dependency fallback.
3. While the HarnessX extra is absent, the Scan Runtime shall execute `quick` and `standard` scans through the existing sync driver with behavior unchanged from the pre-adoption baseline.
4. When the hunter pool stage runs under HarnessX, the Scan Orchestrator shall confine all asynchronous execution to that single stage so that the deterministic stages remain synchronous.
5. The Scan Runtime shall preserve each stage's declared read and write slot allow-list and shall validate slot access on every lifecycle hook, so that the existing provenance and audit discipline is retained under the HarnessX runtime.
6. Where a deterministic stage runs under HarnessX, the Scan Runtime shall execute it with model invocation disabled so that no deterministic stage incurs a model call.
7. When migrating event names, the Scan Runtime shall map the existing `stage_start`/`stage_end` to the HarnessX step-level hooks and `scan_id` to the HarnessX run identifier without losing trace continuity.
8. If a HarnessX-backed stage is enabled and the corresponding HarnessX capability is unavailable at runtime, then the Scan Runtime shall fall back to the existing equivalent stage and shall record the degradation in the scan manifest.
### Requirement 2: Central Security-Affine Ruleset Governance
**Objective:** As a security policy owner, I want detection rules to be versioned data whose severity and scope come from a central CWE policy rather than from the rule itself, so that one CWE maps to one governed severity and no rule can be too influential by setting its own criticality or volume.
#### Acceptance Criteria
1. The Ruleset Store shall load detection rules from versioned data files in which each rule declares a pattern, a CWE, tags, and a loop-controlled `status`, and shall not carry any rule-local severity field.
2. When resolving the severity of a finding, the Policy Scoring Processor shall read severity exclusively from the Policy Store keyed on the rule's CWE and shall discard any severity string carried in legacy rule data.
3. While two enabled rules map to the same CWE, the Policy Scoring Processor shall assign both findings the identical policy-governed severity so that two rules for one CWE can no longer disagree.
4. The Ruleset Store shall support exactly three rule statuses — `enabled`, `shadow`, and `disabled` — where `enabled` fires and scores and reports, `shadow` fires and records outcomes but is excluded from the report and the score, and `disabled` does not fire.
5. When a finding's rule status is `shadow`, the Policy Scoring Processor shall exclude that finding from the reported findings and from the project-score input while still recording its outcome for precision tracking.
6. The Policy Store shall expose, per CWE, an authoritative 0-5 severity plus a static scope flag and a dynamic scope flag, and shall mark dynamic-only CWEs as report-only and never scored by the static tool.
7. If an enabled rule's CWE does not resolve in the Policy Store at startup, then the Scan Runtime shall fail loud and shall not proceed with the scan.
8. The Ruleset Store shall remap the C/C++ buffer-handling rules that reference the unmapped CWE-120 to CWE-121 (severity 5) so that those rules contribute a nonzero penalty to the project score.
9. The Ruleset Store and the Policy Store shall be loaded with the standard library only and shall require no third-party dependency.
### Requirement 3: Bounded Rule and Policy Adaptation in the Self-Improvement Loop
**Objective:** As an OpenUltraSAST maintainer, I want the HarnessX meta-harness to adapt rules and policy gates only through two bounded levers, so that the loop can suppress, stage, or raise the bar on noisy rules without ever inventing detectors or rewriting the severity authority.
#### Acceptance Criteria
1. The Self-Improvement Loop shall expose exactly two new edit levers: a `rule` lever covering rule status, evidence floor, and threshold, and a `policy` lever covering evidence floor, scope, and the score constants `K` and `MIN_SCORE`.
2. The Rule Edit Validator shall reject any loop edit that modifies detection pattern text, so that pattern authoring remains human-pull-request only.
3. The Policy Edit Validator shall reject any loop edit that modifies the authoritative 0-5 CWE severity, so that the severity authority remains upstream-governed.
4. If the loop proposes moving a rule directly from `enabled` to `disabled`, then the Rule Edit Validator shall reject the edit and require `shadow` as a mandatory intermediate staging state.
5. When validating a proposed edit, the Rule Edit Validator shall assert that the edit parses, that `status` is one of `enabled`/`shadow`/`disabled`, that any threshold or evidence-floor tightening or loosening stays within configured bounds, that an added rule does not duplicate an existing rule identifier, and that every referenced CWE resolves in the Policy Store.
6. If the loop re-proposes an edit that was previously reverted without supplying a retry rationale, then the Self-Improvement Loop shall block the re-proposal via the novelty gate.
7. Before accepting any round, the Self-Improvement Loop shall run a replay smoke gate that proves the edited ruleset and policy still load and boot.
8. When any loop edit is rejected during validation, the Self-Improvement Loop shall raise a rule-change validation error and reject the entire round.
9. The Self-Improvement Loop shall record each rule and policy edit, its lever, and its outcome in the journal so that every change is attributable and reversible.
### Requirement 4: Benchmark-Driven Iteration with a Hard Acceptance Gate
**Objective:** As an OpenUltraSAST maintainer, I want benchmark results to become actionable per-rule signals that drive the self-improvement loop under a hard acceptance gate, so that detection quality improves automatically while regressions are reverted byte-for-byte.
#### Acceptance Criteria
1. The Benchmark Harness shall attribute every recorded miss and every recorded false positive to a `rule_id` and shall aggregate per-rule precision and recall in its metrics.
2. The Benchmark Harness shall emit a per-rule recommendation artifact alongside its records, recommending enable, disable, shadow, threshold, or evidence-floor changes per rule.
3. When the orchestrator converts benchmark output to loop input, it shall write recall-side items as miss signals and precision-side items as verifier-reject or false-positive signals into the loop's signal file.
4. After the meta-agent edits rules or policy, the Self-Improvement Loop shall re-run the benchmark and shall accept the round only if aggregate recall is at least 90% AND aggregate false-positive rate is under 10% AND the project score did not regress on the held set AND no unpredicted regression occurred.
5. If a re-run round fails any acceptance condition, then the Self-Improvement Loop shall revert the round and restore the rule and policy configuration byte-for-byte.
6. If a rule drags aggregate precision below the gate, then the Self-Improvement Loop shall auto-shadow that rule rather than delete it, so that recall is preserved while the false-positive cost is removed.
7. When a coverage gap surfaces as a persistent miss, the Self-Improvement Loop shall nominate a new or loosened rule through the signal file while leaving pattern authoring to a human pull request.
8. The Self-Improvement Loop shall treat maximizing the project score as the optimization objective and the recall/FP gate as a separate hard feasibility constraint, so that the loop cannot improve the score by shadowing rules in a way that harms real security.
### Requirement 5: Project Scores via verycode Policies
**Objective:** As a security reviewer, I want a single 0-100 project score computed from policy-weighted, reachability-adjusted findings and gated in CI, so that a scan produces a bounded AppSec quality signal alongside its findings.
#### Acceptance Criteria
1. The Score Model shall compute a single 0-100 project score from policy-governed severity weights multiplied by a reachability multiplier over the reported findings, using the standard library only.
2. The Score Model shall use reachability multipliers keyed exactly on the three emitted reachability values `reachable`, `inferred-file-surface`, and `unknown`.
3. When a finding maps to a CWE that is dynamic-only or unmapped in the Policy Store, the Score Model shall assign that finding a zero score penalty and shall report it as out-of-scope or unmapped rather than scoring it.
4. The Score Model shall exclude findings whose rule status is `shadow` from the score input before computing the project score.
5. The Score Model shall emit a scan-level score artifact containing the project score, the maximum severity, the total penalty, a by-category breakdown, the out-of-scope dynamic-only count, the unmapped CWE list, and the gate verdict, and shall include the score block in the scan manifest.
6. When confirming a false positive, the Calibration subsystem shall lower the finding's effective reachability multiplier rather than delete the rule, so that recall stays high while the score stops over-penalizing.
7. If any reported finding maps to policy severity 5 AND has `reachability_status` equal to `reachable`, then the CI Score Gate shall fail the build.
8. Where the score gate is configured as blocking, the CI Score Gate shall fail the build when the project score is below `MIN_SCORE`; until the score constants are calibrated against the corpus, the CI Score Gate shall compute and report the score advisory-first without blocking.
9. The Score Model and the CI Score Gate shall run in the zero-dependency default install with no HarnessX present.
### Requirement 6: Detection Gate Non-Regression
**Objective:** As an OpenUltraSAST maintainer, I want the existing detection gate of at least 90% recall and under 10% false positives on the benchmark corpus to hold across every phase and every change, so that no governance, scoring, runtime, or self-improvement change can silently degrade detection quality.
#### Acceptance Criteria
1. The CI Pipeline shall evaluate aggregate recall and aggregate false-positive rate on the benchmark corpus on every merge and shall fail the merge if recall is below 90% or the false-positive rate is at or above 10%.
2. While the default ruleset and an empty loop ledger are in effect, the Benchmark Harness shall produce results byte-identical to the pre-feature baseline for the governance and rules-as-data phases.
3. When the self-improvement loop proposes any rule or policy change, the Self-Improvement Loop shall require the recall/FP gate to pass on the re-benchmarked candidate before the change can be accepted.
4. If a HarnessX-backed stage is enabled, then the CI Pipeline shall confirm recall and false-positive rate stay within the configured tolerance of the equivalent zero-dependency baseline.
5. The CI Pipeline shall treat the recall/FP gate as a hard constraint that is never folded into the project-score reward, so that improving the score can never substitute for meeting the detection gate.
6. If any change would reduce recall below 90% or raise the false-positive rate to 10% or above, then the CI Pipeline shall block the change regardless of any project-score improvement.
### Requirement 7: HarnessX Dependency-Weight Decision
**Objective:** As an OpenUltraSAST maintainer, I want an explicit dependency-weight decision that keeps the core install zero-dependency and chooses between an optional HarnessX extra and a vendored lean subset based on an audited dependency graph, so that a supply-chain auditing tool never silently inherits an unacceptable transitive dependency footprint.
#### Acceptance Criteria
1. The Packaging Configuration shall keep the core `dependencies` list empty and shall expose HarnessX only as an optional extra pinned to a specific commit revision.
2. The Runtime shall guard every HarnessX import behind a capability check and shall import HarnessX lazily so that heavy code paths stay cold in the default install.
3. Before adopting the HarnessX execution stages, the maintainer shall run a dependency dry-run audit and shall record the realized transitive dependency graph.
4. If the audited transitive graph pulls a browser-binary, container-SDK, or web-server stack as a mandatory dependency and the maintainers judge it unacceptable for a supply-chain auditing tool, then the Packaging Configuration shall switch to a vendored lean subset of HarnessX under the same extra name with the heavy subtrees removed.
5. While HarnessX is absent, the CI Pipeline shall run a zero-dependency guard test confirming the governance, scoring, and benchmark planes operate with no HarnessX present.
6. Where the project is developed via a submodule, the Runtime setup shall initialize HarnessX non-recursively so that the large reinforcement-learning fork subtree is not pulled.
7. The Packaging Configuration shall keep the vendored lean subset pre-specified so that switching from the optional extra to the vendored subset is a packaging change and not a redesign.
@@ -0,0 +1,191 @@
# Research & Design Decisions
---
**Purpose**: Capture the discovery findings and architectural investigation behind the `harnessx-self-improving-rulesets` spec. This is the background-notes companion to `design.md` — it records *what was read*, *what was found in the source*, and *why* the three resolved decisions came out the way they did. `design.md` carries the committed architecture; this file carries the evidence and the rejected alternatives.
---
## Summary
- **Feature**: `harnessx-self-improving-rulesets`
- **Discovery Scope**: Complex Integration (adopt an external agent-runtime library + re-architect the in-house rule/policy/scoring planes)
- **Key Findings**:
- HarnessX and OpenUltraSAST (OUS) already both have a thing called a "harness" with "processors" and "events", but they are **incompatible at every load-bearing point**: HarnessX is async-generator / message-window-contract / transcript-state; OUS is sync / declared-`reads`/`writes`-contract / dict-state. There is no drop-in.
- HarnessX's real, reusable value for OUS is concentrated in three places: the **LLM hunter agent loop** (control/tools/observability processors), the **llm-judge verifier** (`build_judge_prompt`/`parse_judge_response`/`_call_judge`), and the **meta-harness self-improvement engine** (journal / novelty / evidence / replay / attribution). Everything else (lab web UI, browser tools, sandboxes, office skills) is unused weight.
- OUS today fuses **detector + severity authority + volume control** into one regex layer (`PATTERN_RULES` in `findings.py`); its self-improvement loop only demotes *ranking* (floored at `0.1`, keyed on `tags[0]`), never the rules; and its benchmark already names the ruleset as the thing to fix (`next_improvement_candidate="rules_or_language_hunter"`) but writes it to an inert JSON. The signals exist; the loop back onto the rules does not.
- The `verycode` policy data (`CWE_Score.tsv`, 167 CWEs, severity 0-5 + static/dynamic scope) is **complete and authoritative**, but the `verycode-score` scoring *algorithm was never implemented* (`// scorer code here` placeholder). The score formula in the design is a labeled, defensible reconstruction.
- OUS today has **zero runtime dependencies** (`dependencies = []`); HarnessX's `dependencies` list is ~30 heavyweight packages (Playwright, Docker SDK, FastAPI/uvicorn, multi-LLM SDKs, office-doc suite) with **no lean extra** to pip-install a subset — a direct hard dependency would invert the project's supply-chain posture for a security tool.
## Research Log
### HarnessX core composition & runtime model
- **Context**: We need to know whether OUS's `harness.HarnessRuntime` can be replaced by, or interoperate with, HarnessX's `Harness`, and how an external project builds and runs a HarnessX harness.
- **Sources Consulted**: `/tmp/HarnessX/harnessx/core/{harness,builder,processor,events,runloop,state,contract_check,config_schema,config_store,model_config,trajectory}.py` (read in full); `/home/mc/Source/OpenUltraSAST/src/openultrasast/harness.py`.
- **Findings**:
- Public composition is three objects + one method: `HarnessBuilder.build() -> HarnessConfig`; `ModelConfig.agentic(HarnessConfig) -> Harness`; `await Harness.run(task) -> HarnessResult`. **`HarnessConfig` carries NO model info** — model is always supplied separately via `ModelConfig` (key→provider map, `main` required, non-`main` keys auto-get sub-harnesses).
- `HarnessBuilder` is an immutable fluent factory: `add(proc)`, `slot(**kw)`, `add_tool`, `plugin(source)`, `__or__`/`merge` (collects conflicts → `HarnessConflictError`), `build()` (topo-sorts processors per hook by `_order` then Kahn within a bucket by `_after`). Declarative alt: `build_from_config(dict)` from a flat `processors:` list of `_target_` dicts.
- The run loop (`run_loop`) is a fixed async agent loop: `task_start` → `while True:` (step: `step_start` → `before_model` → call provider → `after_model` → per-tool `before_tool`/exec/`after_tool` → `step_end`) → `task_end`. `get_procs(key)` runs `"*"` (MultiHook) processors on every hook, then hook-specific lists.
- State is **two message tracks** (`raw_messages` append-only, `messages` effective context) + typed `slots: dict[str, StateSlot]` (the nearest analogue to OUS's flat `state` dict, but typed and `_target_`-serializable). `StatefulTrajectory` is a first-class product (`s_t = (z_t, Δz_t, a_t, o_t, e_t, r_t)`), built inline during `run_loop`, with `backfill_rewards` and `to_training_records`/`to_rl_records`.
- Hook-name alignment vs OUS: 5/8 match exactly (`task_start`, `before_model`, `after_model`, `before_tool`, `after_tool`, `task_end`); the gap is `stage_start`/`stage_end` ↔ `step_start`/`step_end`. Semantically an OUS "stage" is a coarse pipeline phase wrapping a callback (`run_stage(name, callback)`), whereas a HarnessX "step" is one model-call iteration — not the same concept.
- **Implications**: A whole-pipeline swap is high risk. The cheapest correct adoption confines HarnessX's async loop to the one OUS stage that *is* an agent loop (the hunter pool) and keeps the sync driver for deterministic stages. This directly motivates Decision D1 (phased seam, not big-bang).
### The two incompatible processor contracts
- **Context**: OUS's defining feature is a declared `reads`/`writes` per-key state contract. We must know if HarnessX preserves it.
- **Sources Consulted**: `/tmp/HarnessX/harnessx/core/{processor,contract_check}.py`; `/home/mc/Source/OpenUltraSAST/src/openultrasast/harness.py:49-118`.
- **Findings**:
- HarnessX `Processor` is `async def process(event) -> AsyncIterator[Event]` — an async generator that yields transformed events. The recommended base is `MultiHookProcessor` with `on_*` hook methods, an `@on(EventClass)` decorator, and class-attr ordering hints (`_order`, `_after`, `_singleton_group`, `_hook`). There is **no `spec`, no `handles`, no `reads`/`writes`.**
- HarnessX's contract governs **message-window mutation**, not state keys: enforced in `ProcessorChain.process` via `_validate_messages_contract` (system stays at index 0 on `step_start`; `before_model` net length ∈ {0,+1}; appended message must be role `user`; no out-of-window edits) and per-chain `check_post_hook_invariants`. Mode is the env var `HARNESSX_CONTRACT_MODE ∈ {warn(default), strict}` — not a constructor arg.
- OUS `Processor` is **synchronous**, `def handle(event, state: dict) -> dict | None`, with `spec: ProcessorSpec(name, version, handles, reads, writes)`; `_run_processor` enforces *state-key* discipline (reads must pre-exist; writes must be declared; return must be a dict). `ContractMode` is a `"strict"|"warn"` constructor arg.
- **Implications**: The two contracts do not map onto each other. OUS's per-key allow-list (its provenance/audit discipline) has **no home in HarnessX**. To keep that discipline we must reimplement it as a `SlotContractMixin` on our HarnessX processors (`reads_slots`/`writes_slots` class attrs validated in a base `on_*` wrapper), gated by our own `OUS_SLOT_CONTRACT` env. This is Decision D7.
### HarnessX built-in processors — reuse map
- **Context**: Which HarnessX processors can OUS register directly, which are reusable as patterns/helpers, and which are irrelevant?
- **Sources Consulted**: `/tmp/HarnessX/harnessx/processors/` (control, tools, multi_model, observability, evaluation, memory, context); `/home/mc/Source/OpenUltraSAST/src/openultrasast/cli.py` (`_run_scan`).
- **Findings**:
- **Register-direct only inside the hunter loop** (the one real agent loop): `LoopDetectionProcessor`, `CostGuardProcessor`/`TokenBudgetProcessor`, `ToolFailureGuard`, `SelfVerifyProcessor`, `ToolFilterProcessor`/`ToolWhitelistProcessor`, `ModelRouterProcessor`, `EpisodeMetricsProcessor`/`OTelProcessor`/`CheckpointProcessor`.
- **Reuse as helper functions** for `verify`: the `llm_judge` code (`build_judge_prompt`, `parse_judge_response` with the verdict whitelist, the two-attempt repair loop `_call_judge`) plus the strict-JSON verdict schema (`verdict/confidence/cause/missing_capability`/`lesson`/`missing`). The judge is explicitly told "ground truth is intentionally withheld" — matching OUS's no-oracle scanning.
- **Reuse as pattern** for `calibrate`/`record_calibration`: `MemoryRetrievalProcessor` + `MemoryPolicy` (retrieve/add, should_retrieve/should_store) is the clean interface to refactor the FP ledger toward; `MemoryExtractionProcessor` mirrors distilling verifier outcomes into durable learnings. Storage stays a custom JSON ledger.
- **Keep custom** for every SAST-domain stage: `static_mapping`, `preprocess`, `entry_point_mapping`, `quick_findings`, `report`, `sarif`, `manifest`, and the benchmark recall/FP scoring (HarnessX `EvalResult.score` is a single float — too coarse for the recall/FP gate).
- **Implications**: Adoption is bimodal — register HarnessX classes directly only in the hunter sub-harness; everywhere else port logic as helpers or patterns. This is the backbone of the §3 flow-migration table in `design.md`.
### HarnessX self-improvement (meta_harness) and the rule-adaptation seam
- **Context**: Can an external signal (a verifier rejection or a benchmark miss) drive a *detection-rule* change through HarnessX's self-improvement loop, and how does it compare to OUS's current calibration?
- **Sources Consulted**: `/tmp/HarnessX/harnessx/meta_harness/{agent,journal,replay,validate_workflow}.py`, `processors/contract_autocheck.py`, `workers/trajectory_digester.py`, `rl/{builder,config,task}.py`; `/home/mc/Source/OpenUltraSAST/src/openultrasast/{calibration,benchmark,findings,rank,cli}.py`.
- **Findings**:
- HarnessX self-improves a *harness* round by round. The unit of change is a `HarnessConfig` (the meta-agent literally rewrites the inner agent's config/processors/templates as files); the unit of memory is a markdown **journal** with per-`## Round N` YAML frontmatter; the unit of safety is a multi-phase **validation gate**.
- The journal classifies every change by a **lever** from `_VALID_LEVERS = {configuration, control, action, instruction}` and tracks `predicted_affected` task_ids plus a `gating_attribution` (`flipped/still_F/regressed/still_T/absent`). `build_context` renders a per-lever **time-decayed Beta posterior** scoreboard (`_DECAY=0.9`) and an explicit "Reverted hypotheses — do not re-propose" list.
- `EvolveValidator.run` enforces validity (canonicalize → contract → diff → replay smoke gate), policy (novelty + evidence, only when the diff is non-empty), advisory. `check_novelty` hard-blocks re-proposing a reverted hypothesis or reverted `(levers, predicted_affected)` signature unless a `retry_rationale` is present. `_evidence` requires a `candidates.md` `## Candidate C-N` cross-referenced by the journal. `run_synthetic_task_smoke_gate` proves the evolved config boots end-to-end. `ContractAutoCheckProcessor` gives in-session `[AUTO-CHECK]` feedback on every authored `processors/*.py`/`tools/*.py` edit.
- **The concrete seam to drive a rule change**: add a lever value to `journal._VALID_LEVERS`; carry the external signal in via a `rule_signals.json` read-context artifact + `_render_task_brief` deliverable; add a validity-phase check (`EvolveValidator._rules`) that raises `StrictValidationError(kind="rule_change")`; add changeset buckets (`compute_changeset` + `journal._CHANGESET_KEYS_ORDER`); extend `contract_autocheck` to classify rule files. The novelty/evidence/replay/attribution machinery then applies for free.
- **OUS by comparison**: detection rules in `findings.py` are static and never modified. Calibration (`calibration.py`) only does `calibrated = max(0.1, calibrated - 0.5)` on a *file's* ranking priority (keyed on `tags[0]`, not `rule_id`) plus a prompt-constraint string — it never disables/tightens/adds a rule, has no novelty gate, no attribution. Benchmark misses produce a `BenchmarkCalibrationRecord` whose `next_improvement_candidate` is hard-coded to `"rules_or_language_hunter"` and written to an **inert** `calibration_records.json` that nothing consumes. The recall side is a fully open loop.
- **Implications**: OUS has exactly the *signals* HarnessX needs (verifier rejections = precision side; benchmark misses = recall side) but no loop closing back onto the rules. The meta_harness is the engine that closes it. This motivates Decision D3 (which levers, and their hard bounds) and the §5/§6 design.
### The verycode policy & scoring model
- **Context**: The design needs a central CWE→severity authority and a project score. What does `verycode` actually provide?
- **Sources Consulted**: `/tmp/verycode-policies/{README.md,CWE_Score.tsv}`; `gh repo view norandom/verycode-score` (incl. `action.yaml`, `main.go`); `gh repo view norandom/verycode-lib`; `/home/mc/Source/OpenUltraSAST/src/openultrasast/findings.py`.
- **Findings**:
- `CWE_Score.tsv` is the real artifact: 167 data rows, one per CWE, columns `Flaw Category | CWE ID | CWE Name | Flaw Severity (0-5) | Static (X/empty) | Dynamic (X/empty)`. Severity distribution: 12×5, 15×4, 97×3, 31×2, 0×1, 12×0. Scope: 154 Static, 45 Dynamic, 13 Dynamic-only, none neither. (Header column 4 is literally `"Flaw Severity "` with a trailing space.)
- **The scoring algorithm does not exist.** `norandom/verycode-score` is the unmodified `the-gophers/go-action` "Tweeter" template with `// scorer code here` and a `--policy policies/standard.json` flag; `norandom/verycode-lib` is a one-line stub README. The data shape + the "GitHub Advanced Security findings, weighted by CWE severity" descriptions imply a Veracode-style 0-100 pipeline; the design reconstructs the formula and labels it as such.
- **Real coverage gap**: CWE-120 (Classic Buffer Overflow) is **absent from the TSV**, but 3 high-value C/C++ rules use it (`c-unsafe-copy`, `c-unsafe-memory-copy`, `c-unsafe-scanf`). Unmapped → those rules would contribute 0 to the score. CWE-121 (Stack Buffer Overflow) is present at severity 5 and is the precise classic-overflow CWE.
- **Severity mismatch**: OUS rule strings disagree with policy (rules label CWE-78/95/134 "high"; policy rates them 5/critical; rules label CWE-94/502 "high"; policy rates them 3/medium). The policy is the authoritative source.
- The OUS reachability vocabulary is exactly `reachable | inferred-file-surface | unknown` (verified in `findings.py`), which maps cleanly onto a `REACH_MULT = {reachable:1.0, inferred-file-surface:0.6, unknown:0.4}` exploitability multiplier — and that multiplier is the natural FP-calibration knob (lower effective reachability instead of deleting a rule).
- **Implications**: Severity must flow from PolicyStore (`CWE_Score.tsv`), not from the rule. Day-one fixes: remap the 3 CWE-120 rules to CWE-121 (Decision D5); discard rule string-severities (Decision D4). The score is a stdlib-only reconstruction (Decision D8 ships it advisory-first until `K`/`MIN_SCORE` are calibrated).
### Installability & dependency weight
- **Context**: How does an external project depend on HarnessX, and what does it cost a zero-dependency security tool?
- **Sources Consulted**: `/tmp/HarnessX/pyproject.toml`, `harnessx/__init__.py`, `core/builder.py`, `plugins/{base,loader,discovery,convert}.py`, `examples/{coding,research,assistant}/harness.py`, `extensions/plugins/workflow/plugin.py`, `.gitmodules`; `/home/mc/Source/OpenUltraSAST/pyproject.toml`.
- **Findings**:
- HarnessX is **library-first** and **effectively not on PyPI** (`name="harnessx"`, `version="0.1.0"`, Beta; consumed from Git). `requires-python = ">=3.11"` — exact match with OUS, no floor conflict. Depend via a SHA-pinned Git URL (the version string won't move).
- Custom stages map cleanly onto HarnessX's plugin model: each SAST stage is a `MultiHookProcessor`; group stages + tools + skills into a `HarnessPlugin` mounted via `builder.plugin(...)`; load in-process (instance/class) rather than via directory/`plugin.json`.
- **Dependency-weight is the headline risk.** HarnessX's `dependencies` (always-installed, not extras) is ~30 heavyweight packages: `anthropic`/`openai`/`litellm`/`tiktoken`, `fastapi`/`uvicorn`/`websockets`, `playwright` (ships browser binaries), `docker`/`e2b`, `hydra-core`/`omegaconf`/`structlog`/`loguru`, `opentelemetry-*`, `mcp`, `python-pptx`/`python-docx`/`openpyxl`/`reportlab`/`pdfplumber`. **There is no lean extra** — `pip install harnessx` pulls everything. OUS currently has `dependencies = []`.
- **Mitigating fact**: HarnessX uses **pervasive lazy imports** (heavy modules imported inside functions). So the heavy deps are *installed* but only *imported on demand* — which makes both an optional-extra opt-in and a vendored subset genuinely viable.
- **Submodule caveat**: `.gitmodules` declares a nested `recipe/verl_harnessX/verl` → a large verl RL fork OUS does not need; never `--recursive` init.
- **Implications**: A hard dependency is off the table (it inverts the zero-dep stance and pins to an unmovable Beta). Options narrow to (B) optional extra and (C) vendored lean subset. The lazy-import architecture is what makes B keep heavy code cold by default and makes C a surgical packaging switch. This is Decision D2.
### Current OUS rule-system problem analysis
- **Context**: Establish, in code terms, why the user's complaint ("rules are too influential and not centrally managed") is true.
- **Sources Consulted**: `/home/mc/Source/OpenUltraSAST/src/openultrasast/{findings,rank,calibration,benchmark,verification,harness,config,cli}.py`.
- **Findings** (six code-level defects):
1. **Pattern matches dominate output.** In quick mode, every non-comment regex match becomes a finding (`quick_scan_findings`); the only gate is language. Ranking only sorts; verification rejects only on `reachability_status == "unknown"`. A noisy regex sets output volume and FP rate directly — the rule *is* the result.
2. **Severity is rule-local.** `PatternRule.severity` is a string literal copied straight onto the finding (`_finding_from_match`). Two rules for the same CWE can disagree; nothing reconciles them.
3. **No central store.** The ruleset is a Python tuple compiled into the package; `config.py` has no rule/policy section. You cannot enable/disable/tune a rule without editing source and cutting a release.
4. **Calibration demotes ranking, not rules.** `calibrate_ranking` only subtracts `learning.demotion` (default 0.5) from a *file's* `priority`, floored at `MIN_CALIBRATED_PRIORITY = 0.1`, keyed on `tags[0]`. A chronically wrong rule fires at full severity forever; learning targets the wrong object.
5. **No CWE-policy linkage.** `cwe` is free-text used only for rationale text and loose benchmark matching. No table maps CWE → governed severity/enablement/evidence floor.
6. **No project-score weighting.** Severity and emission are global constants; the ranking signal (`composite_priority`) only re-sorts. A low-severity crypto finding in a fuzzable network parser is never promoted; a high-severity finding in dead code is never demoted.
- **Implications**: The regex layer is simultaneously **detector, severity authority, and volume knob**, while ranking/verification/calibration/benchmark can only act *around* the rules, never *on* them. The fix is to split those three roles into three owners (rules detect / policy decides severity & scope / score is the optimization target) with a one-directional authority model (governance drives execution; execution emits signals; the loop edits governance under a gate). This is the organizing principle of `design.md`.
## Architecture Pattern Evaluation
These are the three "architect angles" that were considered for the overall shape. Each is internally coherent; the synthesis in `design.md` borrows from all three and resolves their three disagreements (D1/D2/D3).
| Option | Description | Strengths | Risks / Limitations | Notes |
|--------|-------------|-----------|---------------------|-------|
| **Incremental seam** | Keep the OUS sync driver; run HarnessX only inside the `hunter_pool` callback via `asyncio.run`. Rules→data + policy + score added around the existing pipeline. Dependency = optional extra (B) with vendored (C) as escape hatch. Loop edits a `"rule"` lever (status/threshold). | Smallest blast radius; confines the async surface to one stage; keeps the recall/FP gate green per-merge; preserves zero-dep default. | Two harness contracts coexist for a while; `HarnessRuntime` lingers; deterministic stages don't gain HarnessX trajectories yet. | Chosen as the base trajectory (Phases 0-4). |
| **Native rewrite** | Delete `HarnessRuntime` entirely; the *whole* scan becomes a single-step HarnessX harness with `skip_model=True` + slot dataflow; vendored lean subset (C) as the committed default. Loop edits a `"rule"` lever; patterns stay human-PR'd. | Cleanest end-state; one runtime, one contract; full trajectory/journal coverage of every stage; smallest auditable dep graph. | Touches every stage at once — too much blast radius to keep the gate green per-merge; vendoring is permanent fork-maintenance burden. | Demoted to optional Phase 5+; its end-state (single-step harness, retire `HarnessRuntime`) is the long-term target. |
| **Policy-first** | The whole scan runs as processors on a fixed loop; central CWE policy is the centerpiece; loop edits `"rule"` + `"policy"` levers (policy = evidence floors/scope, *not* the 0-5 CWE severity). Dependency = optional extra (B) default, vendored (C) fallback. | Strongest governance story; two levers give the loop the right surface to "stop rules being too influential" without inventing detectors or rewriting severity. | Same whole-pipeline-at-once risk as native if taken literally; needs careful bounds so the loop can't corrupt the ruleset. | Contributed the two-lever model (D3) and the bounded-authority safety rules. |
## Design Decisions
The three architect angles agreed on the destination (rules→governed data; severity from a central CWE policy; a gated project score; HarnessX hosting the hunter loop and a rule-adapting self-improvement loop). They disagreed on three load-bearing choices. Each is resolved below with the recall/FP gate as the deciding tie-breaker. (D4-D9 are smaller decisions that follow.)
### Decision D1: Scope of the HarnessX migration
- **Context**: How much of the scan moves onto HarnessX, and how fast?
- **Alternatives Considered**:
1. Incremental seam — sync driver stays; HarnessX only in the hunter callback.
2. Native rewrite — delete `HarnessRuntime`; whole scan becomes a single-step harness immediately.
3. Policy-first whole-loop — whole scan as processors on a fixed loop.
- **Selected Approach**: **Phased.** Adopt HarnessX directly *at the hunter loop* first (Phase 3); migrate deterministic stages onto a single-step harness only later and optionally (Phase 5+), retiring `HarnessRuntime` only after the seam is proven and the gate has held.
- **Rationale**: The sync→async + contract mismatch is the single highest-risk item in the whole effort. The native rewrite touches every stage at once — too much blast radius to keep ≥90% recall / <10% FP green per-merge. The incremental seam ships the *actual* HarnessX value (agent loop, journal, `StatefulTrajectory`) at exactly the one stage that is already an agent loop.
- **Trade-offs**: Two harness contracts coexist through Phases 3-4 and `HarnessRuntime` lingers; in exchange every phase is independently shippable, test-gated, and reversible.
- **Follow-up**: Golden-output diff gate at each phase; confine `asyncio.run` to the hunter callback; `SlotContractMixin` to preserve key-level discipline.
### Decision D2: Dependency strategy
- **Context**: A zero-dependency security tool considering a ~30-package agent runtime with no lean extra.
- **Alternatives Considered**:
1. (A) Hard dependency in main deps.
2. (B) Optional extra `openultrasast[harnessx]`, SHA-pinned, lazy-imported.
3. (C) Vendored lean subset under `src/openultrasast/_vendor/harnessx/`.
- **Selected Approach**: **(B) is the shipping default; (C) is a pre-committed, triggered fallback** gated by an explicit `pip install --dry-run` audit before Phase 3. Core `dependencies = []` stays; all HarnessX imports are lazy behind a `_has_harnessx()` check.
- **Rationale**: HarnessX's pervasive lazy imports mean the heavy deps are *installed* but only *imported on demand*, so a clean opt-in extra already keeps heavy code paths cold in the default install — with the least maintenance burden, while tracking upstream. (A) permanently inverts the project's stance and pins to an unmovable Beta `0.1.0`. (C) adds permanent fork-maintenance and should only be paid for when the *installed* graph (not the *imported* graph) is itself unacceptable.
- **Trade-offs**: With B, enabling the extra still inherits the full HarnessX dep set (no lean upstream extra exists); every HarnessX import must be guarded.
- **Follow-up**: Run the dry-run audit and record the realized transitive graph. **Decision rule**: if it pulls Playwright browser binaries / Docker SDK / a FastAPI web stack as *mandatory* transitive deps and maintainers judge that unacceptable, switch to (C) — vendor `harnessx/{core,plugins,processors,bundles,meta_harness,workspace,tools/base}` + the `llm_judge`/`journal`/`validate_workflow`/`agent`/`contract_autocheck` modules, delete `lab/`/browser/sandbox/office subtrees, re-pin a minimal set. The lazy-import design makes the deletion surgical. Never use the recursive submodule path (`recipe/verl_harnessX/verl`).
### Decision D3: What the self-improvement loop is allowed to edit
- **Context**: The user's core complaint is that rules are "too influential." The loop must be able to *govern* rules without becoming a new way for them to run wild.
- **Alternatives Considered**:
1. Single `"rule"` lever (status/threshold).
2. `"rule"` lever; patterns stay human-PR'd (native angle).
3. `"rule"` + `"policy"` levers, where policy = evidence floors/scope, *not* CWE severity (policy-first angle).
- **Selected Approach**: **Two bounded levers.** `"rule"` = `status` (enabled/shadow/disabled) + evidence-floor + threshold, **never pattern text**. `"policy"` = evidence floor + scope + score constants `K`/`MIN_SCORE`, **never the authoritative 0-5 CWE severity**. Patterns and CWE severities are human-PR-only.
- **Rationale**: This is the precise answer to "stop rules being too influential" — the loop may *suppress / stage / raise-the-bar*, never *invent detectors* or *rewrite the severity authority*. Bounds + mandatory `shadow` staging + auto-revert keep loop authority safe; the meta_harness novelty/evidence/replay/attribution gates apply for free.
- **Trade-offs**: The loop cannot fix a bad pattern itself (it can only `shadow` it and nominate a human PR), and cannot re-weight a CWE — both are deliberate guardrails.
- **Follow-up**: Implement `EvolveValidator._rules`/`._policy` raising `StrictValidationError(kind="rule_change")`; enforce the four "may NOT" bounds (no pattern edits, no 0-5 severity edits, no `enabled→disabled` without `shadow`, no reverted re-proposal without `retry_rationale`).
### Decision D4: Severity authority
- **Context**: Two rules for the same CWE can disagree on severity today.
- **Selected Approach**: PolicyStore (`CWE_Score.tsv`) is authoritative; `resolve_severity(policy, rule.cwe, target, ranking)` replaces the literal `rule.severity` read; `PatternRule.severity` is removed. Policy always wins; rule string-severities are discarded.
- **Rationale**: One CWE = one governed severity, reconciled centrally; ends rule disagreement.
- **Trade-offs**: Loses the per-rule severity hint (acceptable — it was an authoring accident, not a governed decision).
### Decision D5: The CWE-120 coverage gap
- **Context**: 3 C/C++ buffer rules use CWE-120, which is absent from `CWE_Score.tsv`.
- **Selected Approach**: Remap `c-unsafe-copy`/`c-unsafe-memory-copy`/`c-unsafe-scanf` to **CWE-121** (Stack Buffer Overflow, severity 5) in the ruleset TOML.
- **Rationale**: CWE-121 is the precise classic-overflow CWE and is already at severity 5; this avoids forking the upstream policy file. Otherwise those high-value rules contribute 0 to the score.
- **Follow-up**: Add a hard CI invariant — every `enabled` rule's `cwe` must resolve in PolicyStore (fail loud at startup); log `unmapped_cwe` in the score artifact; PR genuine gaps upstream to verycode-policies.
### Decision D6: Response to a confirmed false positive
- **Context**: How does the system react to an FP without hurting recall?
- **Selected Approach**: Lower the finding's effective **reachability multiplier** and/or `shadow` the rule — **never delete it**.
- **Rationale**: The score stops over-penalizing while the rule keeps firing (tracked, just not scored/reported), preserving ≥90% recall and pushing FP <10%. This is exactly the recall/FP balance the project gates on.
### Decision D7: Where OUS's `reads`/`writes` contract lives
- **Context**: HarnessX has no per-key state contract; OUS's defining discipline must survive.
- **Selected Approach**: Reimplement as a `SlotContractMixin` on our `MultiHookProcessor`s (`reads_slots`/`writes_slots` class attrs validated in a base `on_*` wrapper), gated by `OUS_SLOT_CONTRACT=strict|warn`.
- **Rationale**: The two contracts are incompatible; the mixin preserves provenance/audit discipline inside HarnessX's runtime without trying to bend the message-window contract.
### Decision D8: Project-score gate rollout
- **Context**: The verycode score formula is a reconstruction (no upstream implementation).
- **Selected Approach**: Ship the score gate **advisory-first** (computed and reported, not blocking); promote to blocking only after `K`/`MIN_SCORE` are calibrated against the benchmark corpus. Score computation is stdlib-only and always on (zero-dep), so CI gating runs in the default install with no HarnessX present.
- **Rationale**: The formula needs corpus calibration before it can safely block a merge; keeping it zero-dep keeps the CI gate independent of the optional harness.
### Decision D9: RL adoption
- **Context**: HarnessX has an `rl/` subsystem (weight-level improvement) alongside the config-level meta_harness.
- **Selected Approach**: Optional, Phase 5+.
- **Rationale**: Config-level adaptation via the meta_harness is sufficient to close the rule/policy loop; weight-level RL over hunter trajectories is purely additive and not on the critical path.
## Risks & Mitigations
- **Sync↔async + contract incompatibility (High)** — confine the async surface to the hunter stage via `asyncio.run` in its callback (Phase 3); `SlotContractMixin` for key-level discipline; deterministic stages run `skip_model`; golden-output diff gate at each phase.
- **Dependency posture inversion (High)** — governance/scoring planes stay zero-dep; HarnessX strictly behind the extra + lazy imports + pinned SHA; CI zero-dep guard test; `pip --dry-run` audit before Phase 3; vendored-subset fallback pre-specified.
- **Benchmark-gate regression from rule auto-tuning (High)** — the recall/FP gate is the per-round acceptance test (aggregate + per-rule, hard constraint); `shadow` before `disabled`; novelty + attribution block thrash and reverted re-proposals; auto-revert byte-for-byte.
- **Score gaming the gate (High)** — keep the gate as a *hard constraint*, never part of the reward; track unpredicted regressions; add a score-regression check to acceptance.
- **Meta-agent corrupts the ruleset, re-introducing "too influential" (Medium)** — bounded levers (D3): no pattern rewrites, no severity edits, mandatory `shadow` staging, no `enabled→disabled` jump; recall gate auto-reverts; every change journaled and novelty-checked.
- **Policy completeness / CWE drift (Medium)** — day-one CWE-120→CWE-121 remap; hard CI invariant that every enabled rule's CWE resolves; `unmapped_cwe` logged; treat verycode-policies as authoritative-upstream and PR gaps there.
- **Frozen Beta upstream (Medium)** — pin SHA; document in `VENDOR.md`; keep the meta_harness patch minimal/isolated; rule lever degrades gracefully (absent patch = no rule lever, loop still does ranking demotion); vendored subset removes upstream-instability exposure for depended modules.
- **Slot serialization loss on resume (Medium)** — all scan slots use plain dicts/dataclasses with `_target_` registration; per-slot-type round-trip serialization test (non-serializable content restores as `None` on `wake()`).
- **Two-store coordination drift (Low-Med)** — a single `resolve_severity` owns the rule-status + policy-gate combination; the changeset records both lever edits per round; the benchmark regression suite catches interaction regressions.
## References
- HarnessX core — `/tmp/HarnessX/harnessx/core/{harness,builder,processor,events,runloop,state,contract_check,model_config,trajectory}.py` — composition/run-loop/contract/state model.
- HarnessX processors — `/tmp/HarnessX/harnessx/processors/{evaluation/llm_judge.py, evaluation/strategies/evaluators/prm.py, observability/{otel_proc,episode_metrics,checkpoint}.py, memory/memory_retrieval.py, control/loop_detection.py}` — reuse map (verify/calibrate/observability).
- HarnessX meta_harness — `/tmp/HarnessX/harnessx/meta_harness/{agent,journal,replay,validate_workflow}.py`, `processors/contract_autocheck.py`, `workers/trajectory_digester.py`; `rl/{builder,config,task}.py` — self-improvement engine + rule-adaptation seam.
- HarnessX packaging — `/tmp/HarnessX/pyproject.toml`, `harnessx/__init__.py`, `plugins/{base,loader,discovery,convert}.py`, `examples/{coding,research,assistant}/harness.py`, `extensions/plugins/workflow/plugin.py`, `.gitmodules` — installability + dependency weight.
- verycode policy — `/tmp/verycode-policies/{CWE_Score.tsv,README.md}`; `gh repo view norandom/verycode-score` (`action.yaml`, `main.go` placeholder); `gh repo view norandom/verycode-lib` (stub) — policy data (complete) vs scoring algorithm (never implemented).
- OUS current system — `/home/mc/Source/OpenUltraSAST/src/openultrasast/{findings,rank,calibration,benchmark,verification,harness,config,cli}.py` — `PATTERN_RULES`, ranking-only calibration (`MIN_CALIBRATED_PRIORITY=0.1`), inert `next_improvement_candidate`, sync `HarnessRuntime`, no rule/policy config section.
- Companion design — `.kiro/specs/harnessx-self-improving-rulesets/design.md` (committed architecture); detection gate per `MEMORY.md`: ≥90% recall / <10% FP on the benchmark corpus.
@@ -0,0 +1,22 @@
{
"feature_name": "harnessx-self-improving-rulesets",
"created_at": "2026-06-22T10:07:18Z",
"updated_at": "2026-06-22T16:45:00Z",
"language": "en",
"phase": "implementation",
"approvals": {
"requirements": {
"generated": true,
"approved": true
},
"design": {
"generated": true,
"approved": true
},
"tasks": {
"generated": true,
"approved": true
}
},
"ready_for_implementation": true
}
@@ -0,0 +1,277 @@
# Implementation Plan
This plan splits the fused regex layer into three governed owners (rules detect, CWE policy decides severity/scope, project score is the optimization target) and adopts HarnessX behind an optional extra. Tasks are grouped into four phases. Each phase is independently shippable and keeps the ≥90% recall / <10% FP detection gate green. Phases 1-2 (Foundation) and the early Core work stay zero-dependency and byte-identical by default; the HarnessX execution and self-improvement work (Integration) lives behind a lazy, SHA-pinned extra; Validation locks the cross-cutting gates and the dependency-weight decision.
## Phase 1 — Foundation: Governance & Scoring Planes (zero-dep)
- [x] 1. Stand up the central CWE policy authority
- [x] 1.1 Vendor the verycode CWE severity data and build the Policy Store loader
- Vendor the verycode CWE-to-severity dataset into the governance plane and parse it with the standard library only, tolerating the trailing-space severity column header.
- Expose, per CWE, an authoritative 0-5 severity, a static scope flag, and a dynamic scope flag; mark dynamic-only CWEs as report-only.
- Observable completion: loading the policy yields the full set of CWE policy entries with correct static/dynamic flags and zero third-party imports, verified by a unit test.
- _Requirements: 2.6, 2.9_
- _Boundary: PolicyStore_
- [x] 1.2 Expose policy-governed severity resolution
- Resolve a finding's severity exclusively from the Policy Store keyed on the rule's CWE, discarding any severity string carried in legacy rule data.
- Guarantee that two findings whose rules map to the same CWE receive identical severity.
- Observable completion: a resolution call returns the policy severity (never a legacy string) and is identical for two rules sharing a CWE, proven by a unit test.
- _Requirements: 2.2, 2.3_
- _Boundary: PolicyStore_
- [x] 1.3 Enforce the fail-loud startup CWE-resolution invariant
- Assert at startup that every enabled rule's CWE resolves in the Policy Store; abort the scan loudly before any work when an enabled rule's CWE is unmapped.
- Observable completion: an enabled rule with an unmapped CWE aborts the scan with a loud error and no findings are produced, proven by an integration test.
- _Requirements: 2.7_
- _Boundary: PolicyStore_
- _Depends: 1.1_
- [x] 2. Build the project Score Model and CI gates (zero-dep)
- [x] 2.1 Compute the 0-100 policy-weighted, reachability-adjusted project score
- Compute a single 0-100 project score from policy-governed severity weights multiplied by a reachability multiplier over reported findings, using the standard library only.
- Key the reachability multipliers exactly on the three emitted reachability values (`reachable`, `inferred-file-surface`, `unknown`) without redefining the vocabulary.
- Assign a zero score penalty to findings whose CWE is dynamic-only or unmapped, reporting them as out-of-scope or unmapped instead of scoring them.
- Observable completion: a no-penalty corpus scores 100, a single severity-5 reachable finding scores roughly 43, and dynamic-only/unmapped findings contribute zero penalty, proven by boundary unit tests.
- _Requirements: 5.1, 5.2, 5.3, 5.9_
- _Boundary: ScoreModel_
- _Depends: 1.1_
- [x] 2.2 Emit the scan-level score artifact and manifest score block
- Emit a score artifact containing the project score, the maximum severity, the total penalty, a by-category breakdown, the out-of-scope dynamic-only count, the unmapped-CWE list, and the gate verdict; include the score block in the scan manifest.
- Observable completion: a scan writes a score artifact with every required field and merges the score block into the manifest, verified against the documented artifact schema.
- _Requirements: 5.5_
- _Boundary: ScoreModel_
- _Depends: 2.1_
- [x] 2.3 Implement the two-condition CI score gate (advisory-first)
- Fail the build when any reported finding maps to policy severity 5 and has reachability `reachable`.
- When configured as blocking, fail the build when the project score is below the minimum; until the score constants are calibrated against the corpus, compute and report the score advisory-first without blocking.
- Observable completion: the hard gate fails on a severity-5 reachable finding while the score gate reports advisory-only by default, proven by gate tests.
- _Requirements: 5.7, 5.8, 5.9_
- _Boundary: CI Score Gate_
- _Depends: 2.1_
- [x] 2.4 Centralize ruleset/policy/score configuration
- Add configuration entries for the ruleset, policy, and score data locations plus the gate thresholds and emission floors used by later phases.
- Observable completion: the scan reads the score constants and emission thresholds from configuration rather than hardcoded literals, confirmed by a config-driven test.
- _Requirements: 5.8_
- _Boundary: Packaging Configuration, ScoreModel_
## Phase 2 — Core: Rules-as-Data and Benchmark Signals (zero-dep, byte-identical)
- [x] 3. Convert detection rules into versioned, governed data
- [x] 3.1 Externalize detection patterns into a versioned Ruleset Store
- Load detection rules from versioned data files in which each rule declares a pattern, a CWE, tags, and a loop-controlled status, carrying no rule-local severity field.
- Remove the rule-local severity field from the in-memory rule model so severity can only come from policy.
- Observable completion: the existing pattern rules load from versioned data files with no severity attribute present, proven by a unit test asserting the absence of the field.
- _Requirements: 2.1, 2.9_
- _Boundary: RulesetStore_
- [x] 3.2 Implement the three-status volume control
- Support exactly three rule statuses — `enabled`, `shadow`, `disabled` — where enabled fires/scores/reports, shadow fires and records outcomes but is excluded from report and score, and disabled does not fire.
- Exclude shadow-status findings from the reported set and from the score input while still recording their outcome for precision tracking.
- Observable completion: an enabled rule appears in report and score, a shadow rule is excluded from both yet records an outcome, and a disabled rule never fires, proven by a unit test across all three statuses.
- _Requirements: 2.4, 2.5, 5.4_
- _Boundary: RulesetStore, ScoreModel_
- _Depends: 3.1, 2.1_
- [x] 3.3 Remap the unmapped C/C++ buffer rules to a scored CWE
- Remap the C/C++ buffer-handling rules that reference the unmapped CWE-120 to CWE-121 so they resolve in policy and contribute a nonzero penalty.
- Observable completion: the remapped buffer rules resolve to a severity-5 policy entry and produce a nonzero score penalty, verified by a unit test.
- _Requirements: 2.8_
- _Boundary: RulesetStore_
- _Depends: 3.1, 1.1_
- [x] 3.4 Wire findings and severity resolution through the governance plane
- Pass the loaded ruleset into the finding-detection stage instead of reading an implicit module-level rule list, and resolve every finding's severity through the Policy Store.
- Apply emission thresholds so not every regex match becomes a reported finding, replacing the old "rule is its own volume control" behavior.
- Observable completion: a scan produces findings with policy-resolved severity and shadow findings filtered out, while the default ruleset keeps benchmark output byte-identical to the pre-feature baseline.
- _Requirements: 2.2, 2.3, 2.5, 6.2_
- _Boundary: PolicyScoringProcessor, RulesetStore, PolicyStore_
- _Depends: 3.1, 3.2, 1.2_
- [x] 4. Make benchmark output an actionable per-rule signal
- [x] 4.1 Attribute misses and false positives to rules
- Attribute every recorded miss and every recorded false positive to a rule identifier and aggregate per-rule precision and recall in the benchmark metrics.
- Observable completion: a benchmark run reports per-rule precision/recall and every miss and false positive carries a rule identifier, verified by a metrics test.
- _Requirements: 4.1_
- _Boundary: Benchmark Harness_
- _Depends: 3.1_
- [x] 4.2 Emit a per-rule recommendation delta artifact
- Replace the inert improvement-candidate stub with a per-rule recommendation artifact recommending enable, disable, shadow, threshold, or evidence-floor changes per rule.
- Observable completion: a benchmark run writes a recommendation artifact with per-rule suggested actions alongside its records, verified against the documented delta schema.
- _Requirements: 4.2_
- _Boundary: Benchmark Harness_
- _Depends: 4.1_
- [x] 4.3 Introduce the loop-owned ledger and re-key calibration to rules
- Add the loop-owned overlay ledger that records per-rule status, evidence floor, threshold, and precision, applied over the ruleset at load time.
- Re-key calibration from the first-tag key to the rule identifier and write status flips into the ledger rather than only demoting ranking.
- Observable completion: an empty ledger leaves behavior byte-identical to the prior phase while a ledger entry overlays a rule's status at load, proven by an integration test.
- _Requirements: 2.4, 5.6, 6.2_
- _Boundary: RulesetStore, Calibration subsystem_
- _Depends: 3.1, 3.2_
- [x] 4.4 Convert false-positive confirmation into a reachability knob
- When confirming a false positive, lower the finding's effective reachability multiplier rather than delete the rule, so recall stays high while the score stops over-penalizing.
- Observable completion: confirming a false positive lowers the finding's effective reachability multiplier and the rule still fires, proven by a calibration test.
- _Requirements: 5.6_
- _Boundary: Calibration subsystem, ScoreModel_
- _Depends: 4.3, 2.1_
## Phase 3 — Integration: HarnessX Execution & Self-Improvement (optional extra)
- [x] 5. Gate and package the HarnessX dependency-weight decision
- [x] 5.1 Run the dependency dry-run audit and record the transitive graph
- Before adopting the HarnessX execution stages, run a dependency dry-run audit and record the realized transitive dependency graph.
- If the audited graph pulls a browser-binary, container-SDK, or web-server stack as a mandatory dependency and it is judged unacceptable, select the pre-specified vendored lean subset under the same extra name with the heavy subtrees removed.
- Observable completion: the audit produces a recorded transitive-dependency graph and a documented accept-extra-or-vendor-subset decision before any HarnessX execution code is wired in.
- _Requirements: 7.3, 7.4, 7.7_
- _Boundary: Packaging Configuration_
- [x] 5.2 Configure HarnessX as a SHA-pinned optional extra with a capability guard
- Keep the core dependency list empty and expose HarnessX only as an optional extra pinned to a specific commit revision; initialize any submodule non-recursively so the large RL fork subtree is not pulled.
- Guard every HarnessX import behind a capability check and import it lazily so heavy code paths stay cold in the default install.
- Observable completion: the default install resolves with an empty core dependency list and HarnessX symbols are only imported after the capability check passes, verified by an import-cold test.
- _Requirements: 7.1, 7.2, 7.6_
- _Boundary: Packaging Configuration_
- _Depends: 5.1_
- [x] 6. Adopt HarnessX at the hunter loop and verifier
- [x] 6.1 Run the hunter pool stage on a real HarnessX agent loop
- Run the hunter pool stage on a real HarnessX run loop with the control, tool, and observability processors registered in the hunter sub-harness, invoked from the existing hunter-pool stage callback.
- Confine all asynchronous execution to this single stage so the deterministic stages remain synchronous.
- Observable completion: with the extra installed, the hunter pool produces per-target trajectories from a real agent loop while all other stages stay synchronous, verified by an integration test.
- _Requirements: 1.1, 1.4_
- _Boundary: HxScanOrchestrator_
- _Depends: 5.2_
- [x] 6.2 Map stage events and identifiers onto HarnessX hooks
- Map the existing stage-start/stage-end events to the HarnessX step-level hooks and the scan identifier to the HarnessX run identifier without losing trace continuity.
- Observable completion: a HarnessX-backed run preserves trace continuity with stage events mapped to step hooks and the scan id mapped to the run id, verified by inspecting the emitted trace.
- _Requirements: 1.7_
- _Boundary: HxScanOrchestrator_
- _Depends: 6.1_
- [x] 6.3 (P) Run deterministic stages as model-disabled HarnessX processors with slot contracts
- Run each deterministic stage under HarnessX with model invocation disabled so no deterministic stage incurs a model call.
- Preserve each stage's declared read/write slot allow-list and validate slot access on every lifecycle hook.
- Observable completion: deterministic stages execute under HarnessX with no model calls and a slot-contract violation is raised on undeclared slot access, proven by a slot-contract test.
- Implemented zero-dep in `slot_contract.py` (`SlotContractMixin`/`SlotPipeline`, validates reads/writes per lifecycle hook, enforces `skip_model`) + `stage_processors.py` (the deterministic stages as `skip_model` slot processors; `run_quick_pipeline` is byte-identical to the quick path). Per "bridge, do not map", the sync `HarnessRuntime` stays the top-level driver and these slot processors are the model-disabled representation; `host_under_harnessx` is the lazy, capability-gated bridge that disables the model on HarnessX's `BeforeModelEvent.skip_model`. Module names differ from the design's `harness/{slot_contract,processors}.py` because `harness` is a module, not a package. Tests: `tests/test_slot_contract.py`.
- _Requirements: 1.5, 1.6_
- _Boundary: HxScanOrchestrator_
- _Depends: 6.1_
- [x] 6.4 (P) Add the LLM-judge verifier with a structural fallback
- Produce verification verdicts using the HarnessX llm-judge helpers when the extra is present, mapping the strict-JSON verdict to a verification result.
- Retain the existing structural verifier (which rejects on unknown reachability) verbatim as the zero-dependency fallback.
- Observable completion: with the extra present the judge produces verdicts and with it absent the structural verifier produces equivalent verdicts, proven by a paired verifier test.
- _Requirements: 1.2_
- _Boundary: VerifyProcessor_
- _Depends: 5.2_
- [x] 6.5 Fall back gracefully when a HarnessX capability is unavailable
- When a HarnessX-backed stage is enabled but its capability is unavailable at runtime, fall back to the existing equivalent stage and record the degradation in the scan manifest.
- Observable completion: requesting a HarnessX stage whose capability is missing runs the existing equivalent and records a degradation entry in the manifest, proven by a fallback test.
- _Requirements: 1.8_
- _Boundary: HxScanOrchestrator_
- _Depends: 6.1, 6.4_
- [x] 7. Stand up the bounded self-improvement loop
- [x] 7.1 Add the two bounded edit levers
- Expose exactly two new edit levers: a rule lever covering rule status, evidence floor, and threshold, and a policy lever covering evidence floor, scope, and the score constants.
- Observable completion: the loop's valid-lever set includes exactly the rule and policy levers and rejects any edit outside their declared fields, verified by a lever test.
- _Requirements: 3.1_
- _Boundary: EvolveValidator, improve/evolve.py_
- _Depends: 5.2_
- [x] 7.2 Enforce the rule/policy edit safety bounds in the validator
- Reject any loop edit that modifies detection pattern text and any edit that modifies the authoritative 0-5 CWE severity.
- Reject moving a rule directly from enabled to disabled, requiring shadow as a mandatory intermediate staging state.
- Assert that each edit parses, that status is one of the three literals, that threshold/evidence-floor changes stay within configured bounds, that an added rule does not duplicate an existing identifier, and that every referenced CWE resolves in policy; raise a rule-change validation error and reject the whole round on any failure.
- Observable completion: each prohibited edit (pattern change, severity change, enabled-to-disabled jump, duplicate identifier, unmapped CWE, out-of-bounds threshold) is rejected and rejects the round, proven by validator unit tests.
- _Requirements: 3.2, 3.3, 3.4, 3.5, 3.8_
- _Boundary: EvolveValidator_
- _Depends: 7.1, 1.1_
- [x] 7.3 Add the novelty, replay, and journal gates
- Block re-proposing a previously reverted edit that lacks a retry rationale via the novelty gate.
- Run a replay smoke gate before any benchmark re-run proving the edited ruleset and policy still load and boot.
- Record each rule and policy edit, its lever, and its outcome in the journal so every change is attributable and reversible.
- Observable completion: a reverted re-proposal without rationale is blocked, a non-booting edited ruleset is rejected before re-benchmark, and every edit appears in the journal with its lever and outcome, proven by gate tests.
- _Requirements: 3.6, 3.7, 3.9_
- _Boundary: EvolveValidator, improve/evolve.py_
- _Depends: 7.2_
- [x] 7.4 Convert benchmark deltas into loop signals
- Convert the per-rule recommendation delta and false-positive learnings into a loop signal file, writing recall-side items as miss signals and precision-side items as verifier-reject or false-positive signals.
- Observable completion: a benchmark run produces a signal file whose recall-side entries are miss signals and precision-side entries are verifier-reject/false-positive signals, verified against the documented signal schema.
- _Requirements: 4.3_
- _Boundary: improve/evolve.py_
- _Depends: 4.2, 7.1_
- [x] 7.5 Implement the hard acceptance gate with byte-for-byte revert
- After the meta-agent edits rules or policy, re-run the benchmark and accept the round only if aggregate recall is at least 90% AND false-positive rate is under 10% AND the project score did not regress on the held set AND no unpredicted regression occurred.
- On any failed acceptance condition, revert the round and restore the rule and policy configuration byte-for-byte.
- Observable completion: a round that regresses recall is reverted and the configuration is restored byte-for-byte, while a compliant round is accepted and persisted, proven by a self-improvement integration test.
- _Requirements: 4.4, 4.5, 6.3_
- _Boundary: improve/evolve.py_
- _Depends: 7.3, 7.4, 4.1_
- [x] 7.6 Auto-shadow precision-draggers, nominate coverage gaps, and separate the objective
- Auto-shadow a rule that drags aggregate precision below the gate rather than delete it, preserving recall while removing the false-positive cost.
- Nominate a new or loosened rule through the signal file when a coverage gap surfaces as a persistent miss, leaving pattern authoring to a human pull request.
- Treat maximizing the project score as the optimization objective and the recall/FP gate as a separate hard feasibility constraint that is never folded into the score reward.
- Observable completion: a deliberately noisy rule is auto-shadowed (not deleted) when it drags precision, a persistent miss emits a rule nomination, and the loop cannot raise the score by shadowing rules that breach the gate, proven by integration tests.
- _Requirements: 4.6, 4.7, 4.8_
- _Boundary: improve/evolve.py_
- _Depends: 7.5_
## Phase 4 — Validation: Cross-Cutting Gates & Non-Regression
- [x] 8. Lock the detection-gate non-regression checks into CI
- [x] 8.1 Enforce the per-merge recall/FP detection gate
- Evaluate aggregate recall and aggregate false-positive rate on the benchmark corpus on every merge and fail the merge if recall is below 90% or the false-positive rate is at or above 10%.
- Treat the recall/FP gate as a hard constraint that is never folded into the project-score reward, blocking any change that breaches the gate regardless of score improvement.
- Observable completion: a CI run fails the merge when recall drops below 90% or false-positive rate reaches 10%, and a score improvement cannot unblock a gate-breaching change, proven by pipeline tests.
- _Requirements: 6.1, 6.5, 6.6_
- _Boundary: CI Pipeline_
- _Depends: 4.1_
- [x] 8.2 (P) Assert byte-identical benchmark baseline for the zero-dep phases
- With the default ruleset and an empty loop ledger, assert the benchmark output is byte-identical to the pre-feature baseline for the governance and rules-as-data phases.
- Observable completion: a golden-diff test confirms byte-identical benchmark output with the default ruleset and empty ledger.
- _Requirements: 6.2_
- _Boundary: Benchmark Harness, CI Pipeline_
- _Depends: 3.4, 4.3_
- [x] 8.3 (P) Confirm HarnessX-backed stages stay within detection tolerance
- When a HarnessX-backed stage is enabled, confirm recall and false-positive rate stay within the configured tolerance of the equivalent zero-dependency baseline.
- Observable completion: a CI run with a HarnessX-backed stage enabled keeps recall/FP within the configured tolerance of the baseline, proven by a tolerance test.
- _Requirements: 6.4_
- _Boundary: CI Pipeline, HxScanOrchestrator_
- _Depends: 6.1, 8.1_
- [x] 8.4 (P) Add the zero-dependency guard test
- Run a guard test confirming that with HarnessX absent the governance, scoring, and benchmark planes operate and CI gating runs in the default install.
- Observable completion: the guard test passes with HarnessX uninstalled and fails if any governance/scoring/benchmark path imports HarnessX.
- _Requirements: 7.5_
- _Boundary: CI Pipeline_
- _Depends: 8.1_
- [x] 9. Validate scan-pipeline integrity and serialization
- [x] 9.1 (P) Verify capability fallback and unchanged sync-driver behavior
- Confirm that with the extra absent, quick and standard scans execute through the existing sync driver with behavior unchanged from the pre-adoption baseline, and that a requested-but-unavailable HarnessX stage records a degradation.
- Observable completion: an extra-absent scan matches the pre-adoption baseline and a missing-capability request records a manifest degradation, proven by an end-to-end fallback test.
- _Requirements: 1.3, 1.8_
- _Boundary: HxScanOrchestrator, CI Pipeline_
- _Depends: 6.5_
- [x] 9.2 (P) Verify slot serialization round-trips on resume
- Confirm each scan slot type serializes and restores without loss after a resume, guarding against non-serializable slots restoring as empty.
- Observable completion: a per-slot-type round-trip test passes for every scan slot after a resume cycle.
- Done for the current artifact-persistence model (findings/ranking/file-target/verification/score round-trip + the real findings.json reload path in `tests/test_scan_pipeline_integrity.py`). The HarnessX processor slot-contract serialization variant remains deferred with task 6.3.
- _Requirements: 1.5_
- _Boundary: HxScanOrchestrator_
- _Depends: 6.3_
+42 -24
View File
@@ -249,37 +249,55 @@ Do not report OpenUltraSAST as a working security harness until `usable_harness_
## Phase 12: MCP And OpenCode Commands (`opencode_product` prerequisite) ## Phase 12: MCP And OpenCode Commands (`opencode_product` prerequisite)
Implemented in `mcp.py` (zero-dep stdio JSON-RPC server) + the `ousast mcp` CLI
subcommand; tests in `tests/test_mcp.py`; README "MCP server" section documents it.
- [x] Add project-local opencode skills for scan, triage, fix audit, and implementation steering. - [x] Add project-local opencode skills for scan, triage, fix audit, and implementation steering.
- [ ] Implement narrow MCP server. - [x] Implement narrow MCP server.
- [ ] Add `openultrasast.scan`, `status`, `findings`, `get_finding`, `evidence`, `artifacts`, `benchmark`, `explain`, `propose_patch`, and `export_report` tools. - [x] Add `openultrasast.scan`, `status`, `findings`, `get_finding`, `evidence`, `artifacts`, `benchmark`, `explain`, `propose_patch`, and `export_report` tools.
- [ ] Add opencode command docs for quick, standard, deep, verify, and patch workflows. - [x] Add opencode command docs for quick, standard, deep, verify, and patch workflows. (README; deep/patch noted as roadmap.)
- [ ] Ensure MCP tools never expose arbitrary shell execution. - [x] Ensure MCP tools never expose arbitrary shell execution. (Surface-safety test asserts no shell/exec/command tool.)
- [ ] Add integration test for MCP tool listing and scan status. - [x] Add integration test for MCP tool listing and scan status.
## Phase 13: Fusion (`standard_security_harness` and `opencode_product` prerequisite) ## Phase 13: Fusion (`standard_security_harness` and `opencode_product` prerequisite)
- [ ] Implement fusion trigger policy. Implemented in `fusion.py` (zero-dep deterministic engine + lazy LLM panels), wired
- [ ] Trigger fusion whenever a task requires deeper reasoning, including difficult findings, contradictory evidence, high-impact decisions, and risky fixes. into the standard-mode CLI (`fusion.json` + manifest `fusion` block), config `[fusion]`.
- [ ] Implement panel A, panel B, and decider role config. Tests in `tests/test_fusion.py`.
- [ ] Implement cross-critique and revised answer flow.
- [ ] Implement vote/rank output schema. - [x] Implement fusion trigger policy.
- [ ] Implement reconciliation dispositions. - [x] Trigger fusion whenever a task requires deeper reasoning, including difficult findings, contradictory evidence, high-impact decisions, and risky fixes.
- [ ] Record model IDs, degradations, warnings, and final decision source. - [x] Implement panel A, panel B, and decider role config.
- [ ] Add tests for fusion output validation. - [x] Implement cross-critique and revised answer flow.
- [x] Implement vote/rank output schema.
- [x] Implement reconciliation dispositions.
- [x] Record model IDs, degradations, warnings, and final decision source.
- [x] Add tests for fusion output validation.
## Phase 14: Skill Index (`standard_security_harness` prerequisite) ## Phase 14: Skill Index (`standard_security_harness` prerequisite)
- [ ] Build Trail of Bits skill descriptor inventory. Implemented in `skills.py` (descriptor inventory + scoring router + retrieval-namespace
- [ ] Chunk selected skill operating guidance into retrieval namespace. chunking + budget-capped context); the HarnessX hunter prompt consumes the routed
- [ ] Implement skill router by language, tags, vulnerability class, and stage. skill context; tests in `tests/test_skills.py`.
- [ ] Add prompt budget caps for skill snippets.
- [ ] Add tests for routing C parser targets to memory/fuzzing skills and crypto targets to constant-time/vector skills. - [x] Build Trail of Bits skill descriptor inventory.
- [x] Chunk selected skill operating guidance into retrieval namespace.
- [x] Implement skill router by language, tags, vulnerability class, and stage.
- [x] Add prompt budget caps for skill snippets.
- [x] Add tests for routing C parser targets to memory/fuzzing skills and crypto targets to constant-time/vector skills.
## Phase 15: Hardening And Release Readiness (`production` prerequisite) ## Phase 15: Hardening And Release Readiness (`production` prerequisite)
- [ ] Add cost budget enforcement. Implemented: `redaction.py` (traces/reports), provider retry/backoff in
- [ ] Add model call retry and backoff policies. `provider/openrouter.py`, `[hardening]` config (redact_secrets + max_findings budget;
- [ ] Add redaction for secrets in traces and reports. agentic spend bounded by `[harnessx]`), `docs/threat-model.md`, `docs/examples.md`.
- [ ] Add docs for threat model and sandbox limits. Tests in `tests/test_redaction.py`, `tests/test_provider_retry.py`,
- [ ] Add end-to-end examples. `tests/test_hardening_pipeline.py`. CI gates pre-existed (ruff/mypy/pytest/detection
- [ ] Add lint, typecheck, test, and CI gates. gate/dagger).
- [x] Add cost budget enforcement.
- [x] Add model call retry and backoff policies.
- [x] Add redaction for secrets in traces and reports.
- [x] Add docs for threat model and sandbox limits.
- [x] Add end-to-end examples.
- [x] Add lint, typecheck, test, and CI gates.
+74
View File
@@ -0,0 +1,74 @@
# Brief: three-stage-scan
## Problem
AppSec engineers and DevOps teams running OpenUltraSAST in CI get a regex static scan scored against cheat-sheet fixtures. They cannot tell what actually exists in a real repository, where complexity concentrates, how to aim tests, or whether a finding is worth a CI failure. The previous HarnessX spec added governance around those regexes (CWE policy, score, shadow status) without making the hunter evolutionary or the evaluation realistic.
## Current State
- `ousast scan --mode quick` is a deterministic regex inventory. It works.
- `--mode standard` is labeled a hunter pool but reuses the same regex path unless the optional HarnessX extra *and* a hunter model are both set. That hunter dumps a file into a prompt and scrapes JSON; it has no tools.
- `--mode deep` exits with "not implemented." Sandbox config exists on paper (`network=false`, read-only workspace, memory/pids/timeout). No container is ever started.
- Benchmarks are one-line sinks written to match the regexes. The fixtures themselves comment the misses (`regex blind spot`). The ≥90% recall gate is circular.
- CWE policy, project score, shadow status, traces, and SARIF already exist and should not be redesigned.
## Desired Outcome
A scan answers three operator questions, in order:
1. **What exists?** A static inventory of files, surfaces, and pattern/SARIF hits — honest about being inventory, not verified bugs.
2. **Where are problems most likely, and how do I tune tests?** A complexity map of hotspots plus test-gap hints a DevOps team can act on.
3. **Is this worth fixing?** Isolated regression against the top hotspots. Triggerable and reachable → worth a CI fail. Not triggerable → do not fail the build.
Stages 2–3 use a tool-calling hunter (read / grep / callers) and a Docker-isolated runner. Evaluation uses split-sink and mutation corpora, not cheat sheets. Missing Docker or a model degrades visibly; stage 1 still runs.
## Approach
**Map-then-prove on the existing modes.** Keep `--mode quick|standard|deep`:
- `quick` → stage 1 only
- `standard` → stages 1 + 2
- `deep` → stages 1 + 2 + 3
Do not require the HarnessX extra for the LLM path. Use the existing stdlib OpenRouter client when a hunter model is configured. HarnessX remains an optional alternative runtime.
Do not add a Python Docker SDK. Drive the Docker CLI via subprocess with the threat-model flags (no network, read-only source, tmpfs scratch, memory/pids/timeout, cap-drop). Core `dependencies = []` stays.
Complexity map is a new artifact built from existing ranking, entry points, finding density, nesting/size, and test-file presence — not a rewrite of `RankingScore`. Stage 3 writes a worth-fixing verdict that feeds back into a complexity ledger (evolutionary), without editing detection pattern text.
## Scope
- **In**: three-stage scan contract; complexity map + test-tuning hints; Docker-isolated regression and worth-fixing verdicts; tool-calling hunter on hotspots; split-sink/mutation evaluation; CI mapping (PR = stage 1, nightly = stage 2/3); visible degradation; evolutionary feedback from regress → map.
- **Out**: rewriting CWE policy or project-score formula; auto-authoring regex patterns; full fuzzing campaigns; patch generation/application; network pentest; importing Clearwing.
## Boundary Candidates
- Stage orchestrator (which stages a mode runs, artifacts, degradation)
- Complexity map (hotspots, signals, test-gap hints)
- Sandbox runner (Docker CLI, capability probe)
- Regression / worth-fixing (candidate tests, verdicts, ledger feedback)
- Tool-calling hunter (repo-bound tools + model loop for stages 2–3)
## Out of Boundary
- PolicyStore / ScoreModel / RulesetStore internals (consume, do not own)
- MetaAgent.evolve regex-status loop (adjacent, do not replace)
- Deep fuzzing, exploit development, auto-patching
- HarnessX packaging / optional extra (may be used, not required)
## Upstream / Downstream
- **Upstream**: existing preprocess, rank, mapping, findings, verification, config, CLI modes, OpenRouter client, threat-model sandbox limits.
- **Downstream**: CI fail-on `worth-fixing`; later fuzz/patch specs; MCP scan status.
## Existing Spec Touchpoints
- **Extends**: `openrouter-sast-harness` gates `standard_security_harness` and `sandboxed_dynamic_harness` (still unsatisfied).
- **Adjacent**: `harnessx-self-improving-rulesets` owns CWE policy, score, shadow, optional HarnessX extra. This spec must not reopen those.
## Constraints
- Core install stays zero-dependency. Docker CLI and an LLM key are operator-provided runtimes, not Python dependencies.
- Scanned code is untrusted. Stage 3 is the first mode allowed to execute target-derived code, and only inside the sandbox.
- LLM output starts at evidence `suspicion`. It must not be stamped `static_corroboration`.
- Cheat-sheet one-liners may remain as unit fixtures; they must not be the merge gate for stages 2–3.
+526
View File
@@ -0,0 +1,526 @@
# Design Document
## Overview
**Purpose.** This feature turns an OpenUltraSAST scan into three operator questions: what exists (static inventory), where problems are most likely and how to aim tests (complexity map), and whether a finding is worth fixing (isolated regression). It is a follow-up to the HarnessX governance spec, not a replacement of CWE policy, scoring, or rules-as-data.
**Users.** DevOps engineers get a cheap pull-request inventory and a nightly prove-worth-fixing job. AppSec reviewers get a hotspot map and test-gap hints instead of a regex dump. Maintainers get an evaluation gate that split-sink cases can fail.
**Impact.** Today `--mode standard` reuses regex findings and `--mode deep` exits. After this spec, `quick` remains stage 1, `standard` builds a complexity map and may run a tool-using hunter, and `deep` runs a Docker-isolated regression that is the only path allowed to execute target-derived code.
### Goals
- Map existing `--mode quick|standard|deep` onto stages 1 / 1+2 / 1+2+3 with explicit manifest accounting.
- Keep stage 1 zero-dependency, no model, no target execution.
- Emit a complexity map and test-tuning hints that do not collapse to pattern-hit count.
- Prove worth-fixing inside an isolated sandbox; fail CI on `worth_fixing` only when asked.
- Hunter tools follow dataflow; hunter output is `suspicion`.
- Stage 2/3 quality is gated on split-sink and mutation corpora.
### Non-Goals
- Rewriting PolicyStore, ScoreModel, or detection pattern text.
- Requiring the HarnessX extra for any stage.
- Full fuzzing, exploit development, or auto-patching.
- Importing Clearwing.
- A second CLI family (`--stage`).
## Boundary Commitments
### This Spec Owns
- **Stage plan**: which stages a mode runs, skip/degrade accounting, `stages_requested` / `stages_completed` in the manifest.
- **Complexity map**: `Hotspot` records, signals, test-gap hints, `complexity_map.json`.
- **Complexity ledger**: overlay of hotspot scores from regression verdicts.
- **Sandbox**: capability probe and Docker CLI runner that never mounts the host Docker socket.
- **Regression / worth-fixing**: candidate selection, language recipes, verdicts, `--fail-on worth-fixing`.
- **Tool hunter**: repo-bound read / grep / callers loop for stages 2–3, evidence honesty.
- **Split-sink evaluation**: corpora and the "beats pattern-hit ranking" gate.
### Out of Boundary
- CWE TSV, `resolve_severity`, project-score formula, rule `enabled|shadow|disabled`.
- `MetaAgent.evolve` pattern-status loop.
- Semgrep/CodeQL execution (ingest of operator-supplied SARIF remains as today).
- Patch generation and repository mutation.
- Sanitizer-image matrix and campaign fuzzing.
- MCP docker/shell tools, and exposing `deep` on MCP (CLI/CI only).
- Trail of Bits `skills.STAGES` (`map`/`hunt`/`verify`/`fix`). That `map` is mapping-discipline skill routing, not the complexity-map scan stage.
### Allowed Dependencies
- Existing preprocess, rank, mapping, findings, verification, reports, config, CLI, OpenRouter client, HarnessRuntime events.
- Optional HarnessX extra as an alternate hunter host, not a requirement.
- Operator-provided Docker CLI and LLM credential.
- Stdlib only for new core modules (`subprocess` for Docker, `urllib` already used by OpenRouter).
### Revalidation Triggers
- Changing the mode→stage mapping or `--fail-on` vocabulary.
- Changing hotspot identity keys (breaks the complexity ledger).
- Changing sandbox flags or mounting the Docker socket.
- Stamping hunter output above `suspicion`.
- Using cheat-sheet recall as the stage 2/3 merge gate.
## Architecture
### Existing Architecture Analysis
| Existing element | Constraint |
|---|---|
| `cli._run_scan` modes | Reuse names; stop exiting on `deep` |
| `quick_scan_findings` | Stage 1 detector; not the stage 2 hunter |
| `rank.heuristic_rank` | File budget; not the complexity map |
| `mapping.analyze_entry_points` | Input to map signals and caller hints |
| `hunter._run_hunter_task` | Must stop being a regex alias when a model is set |
| `OpenRouterChatClient` | Default hunter transport |
| `SandboxConfig` | Limits already match the threat model; need a runner |
| `verify_finding` | Keep as structural review gate; do not treat it as worth-fixing |
| `gate.py` 90% recall | Stage 1 only after this spec |
### Architecture Pattern & Boundary Map
**Selected pattern.** Ordered pipeline: **inventory drives the map; the map drives regression; regression overlays the map.** Execution never mutates governance data. Target code runs only in the sandbox.
```mermaid
flowchart TB
subgraph S1["STAGE 1 inventory"]
PRE[preprocess and mapping]
RANK[file rank]
INV[pattern and SARIF inventory]
end
subgraph S2["STAGE 2 map"]
SIG[complexity signals]
MAP[hotspot map]
HINT[test-gap hints]
HUNT[tool hunter optional]
end
subgraph S3["STAGE 3 prove"]
CAND[regress candidates]
BOX[Docker CLI sandbox]
VERD[worth-fixing verdicts]
end
LED[complexity ledger]
CI[CI fail-on]
PRE --> RANK --> INV
INV --> SIG
RANK --> SIG
LED --> SIG
SIG --> MAP --> HINT
MAP --> HUNT
HUNT --> MAP
MAP --> CAND
INV --> CAND
CAND --> BOX --> VERD
VERD --> LED
VERD --> CI
INV --> CI
```
**Dependency direction:** config/types → preprocess/rank/mapping/findings → complexity map → hunter tools → sandbox → regress → CLI/report/gate.
### Technology Stack
| Layer | Choice | Role | Notes |
|-------|--------|------|-------|
| CLI | existing `ousast scan --mode` | Stage selection | Add `--fail-on worth-fixing` |
| Hunter transport | `OpenRouterChatClient` | Tool loop | Stdlib; HarnessX optional |
| Sandbox | Docker CLI via `subprocess` | Isolated execution | No Python docker SDK |
| Map / ledger | JSON artifacts | Attention + evolution | Same overlay style as `rule_policy.json` |
| Evaluation | split-sink fixtures | Stage 2/3 gate | Smoke fixtures stay stage 1 |
## File Structure Plan
### Directory Structure
```
src/openultrasast/
├── stages.py # Stage enum, mode map, StagePlan, degradation helpers
├── complexity/
│ ├── __init__.py
│ ├── signals.py # per-file/function signals from targets, findings, tests
│ ├── map.py # build_complexity_map, Hotspot, ComplexityMap
│ ├── hints.py # test-gap hints, closed test-kind set
│ └── ledger.py # load/apply/write complexity_ledger.json
├── hunter_tools.py # read_file, grep_repo, find_refs; path clamp
├── tool_hunter.py # bounded tool loop over a chat client
├── sandbox/
│ ├── __init__.py
│ ├── probe.py # docker CLI present and usable
│ └── runner.py # DockerCliRunner, SandboxRunner protocol, FakeSandboxRunner
└── regress/
├── __init__.py
├── candidate.py # select and render candidates
├── recipes.py # language command recipes
└── verdict.py # run candidates, worth-fixing gate
benchmarks/fixtures/
├── split-sink-python/
├── split-sink-javascript/
├── split-sink-java/
└── split-sink-c/
benchmarks/manifests/split-sink-*.toml
```
### Modified Files
- `cli.py` — run stage 2/3; stop exiting on `deep`; `--fail-on worth-fixing`; write new artifacts.
- `reports.py` — `scan_exit_code` grows `worth-fixing`; report sections for inventory, map, and worth-fixing.
- `config.py` — `ComplexityConfig` (top_k, max_hunter_hotspots) and `RegressConfig` (max_candidates, image pins); **consume** existing `SandboxConfig` (do not add a second sandbox TOML).
- `hunter.py` — schedule remains; execution delegates to `tool_hunter` when a model is set, else inventory-only.
- `verification.py` / reports — inventory vs worth-fixing labeling; hunter findings stay `suspicion`.
- `gate.py` — stage-1 smoke gate unchanged; add split-sink map gate.
- `reports.py` — map and verdict sections in markdown/manifest.
- `.github/workflows/ci.yml` — PR stays quick; optional nightly deep when Docker is available.
## System Flows
### Mode to stages
```mermaid
flowchart LR
Q[quick] --> A[stage 1]
S[standard] --> A
S --> B[stage 2]
D[deep] --> A
D --> B
D --> C[stage 3]
```
If stage 2 has no model, the heuristic map still writes. If stage 3 has no Docker, stages 1–2 remain and `sandbox_unavailable` is recorded.
### Stage 3 worth-fixing
```mermaid
sequenceDiagram
participant M as ComplexityMap
participant R as RegressionRunner
participant S as DockerCliRunner
participant G as WorthFixingGate
M->>R: top hotspots plus severe inventory
R->>R: render snippet and safety check
alt safety check fails
R->>G: inconclusive
else sandbox missing
R->>G: inconclusive sandbox_unavailable
else recipe missing
R->>G: inconclusive
else runs
R->>S: docker run read-only source
S->>R: exit code stdout stderr
R->>G: triggerable or not_triggerable or already_covered
end
G->>M: ledger overlay
```
## Requirements Traceability
| Requirement | Summary | Components | Flows |
|-------------|---------|------------|-------|
| 1.1–1.6 | Mode maps to stages; manifest accounting; degrade forward | `stages.py`, `cli.py` | Mode to stages |
| 2.1–2.5 | Stage 1 inventory, no execute, no model | existing preprocess/rank/findings | Stage 1 |
| 3.1–3.5 | Complexity map independent of regex count | `complexity/map.py`, `signals.py` | Stage 2 |
| 4.1–4.4 | Test-gap hints, no repo writes | `complexity/hints.py` | Stage 2 |
| 5.1–5.8 | Sandbox regression, worth-fixing, fail-on | `sandbox/*`, `regress/*` | Stage 3 |
| 6.1–6.6 | Tool hunter, path clamp, suspicion | `hunter_tools.py`, `tool_hunter.py` | Stage 2/3 |
| 7.1–7.5 | Ledger overlay, cannot hide sev-5 reachable | `complexity/ledger.py` | Stage 3 → map |
| 8.1–8.6 | Split-sink / mutation gate | fixtures, `gate.py` | Evaluation |
| 9.1–9.6 | PR cheap, nightly bounded, visible skip | `cli.py`, CI workflow | Degradation |
## Components and Interfaces
| Component | Domain | Intent | Req Coverage | Dependencies | Contracts |
|-----------|--------|--------|--------------|--------------|-----------|
| StagePlan | Orchestration | Mode → stages, skip reasons | 1.1–1.6, 9.3–9.6 | config | State |
| ComplexityMapBuilder | Map | Hotspots from signals + ledger | 3.1–3.5, 7.1–7.3 | preprocess, rank, findings | Batch, State |
| TestTuningAdvisor | Map | Test-gap hints | 4.1–4.4 | ComplexityMap | Batch |
| HunterTools | Hunter | Read/grep/refs inside repo | 6.1–6.2 | mapping | Service |
| ToolHunter | Hunter | Bounded tool loop | 6.1–6.5 | OpenRouter, HunterTools | Service |
| SandboxProbe | Sandbox | Docker usable? | 5.2, 9.3 | docker CLI | Service |
| DockerCliRunner | Sandbox | Isolated `docker run` | 5.2, 5.8 | docker CLI | Service |
| RegressionRunner | Regress | Candidates, recipes, verdicts | 5.1–5.6 | Sandbox, map | Batch |
| WorthFixingGate | Regress | CI fail-on | 5.4–5.7, 7.4 | RegressionRunner, policy | Batch |
### Orchestration
#### StagePlan
| Field | Detail |
|-------|--------|
| Intent | Declare which stages run and why a stage was skipped |
| Requirements | 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 9.3, 9.4, 9.6 |
```python
from enum import StrEnum
class Stage(StrEnum):
STATIC = "static"
MAP = "map"
REGRESS = "regress"
MODE_STAGES = {
"quick": (Stage.STATIC,),
"standard": (Stage.STATIC, Stage.MAP),
"deep": (Stage.STATIC, Stage.MAP, Stage.REGRESS),
}
@dataclass(frozen=True)
class StagePlan:
mode: str
requested: tuple[Stage, ...]
completed: tuple[Stage, ...]
skipped: tuple[dict[str, str], ...] # stage, reason
```
- **Invariants:** `quick` never includes `MAP` or `REGRESS`. Skipped stages appear in `runtime.state["degradations"]` with the same shape as existing capability fallbacks.
- **Naming:** `Stage.MAP` is the complexity-map scan stage. It is unrelated to `skills.STAGES` value `"map"` (Semgrep/CodeQL skill routing). Do not merge the two enumerations.
### Complexity map
#### ComplexityMapBuilder
| Field | Detail |
|-------|--------|
| Intent | Rank likely problem areas without using pattern-hit count as the score |
| Requirements | 3.1, 3.2, 3.3, 3.4, 3.5 |
**Signals** (stdlib, no extra libs):
- size: loc
- nesting: max indent or brace depth
- surface tags from `FileTarget`
- reachability from entry-point hints
- inventory density as a *feature*, not the score (used in rationale, down-weighted in the formula)
- test presence: sibling `test_*` / `*_test` / `*Test.java` / `*_test.go` under common test roots
- ledger overlay last
**Hotspot identity:** `path` + `function_name or ""`. Function name from reachability hints or a language-specific def/func regex; empty if unknown. Java/Groovy mapping is tag-only today, so identity is usually the file; that is acceptable for ranking and test-gap hints.
```python
@dataclass(frozen=True)
class Hotspot:
path: str
function_name: str | None
score: float
band: str # high | medium | low
signals: dict[str, float | int | bool | str]
rationale: str
test_hint: TestHint | None
inventory_finding_ids: tuple[str, ...]
@dataclass(frozen=True)
class ComplexityMap:
hotspots: tuple[Hotspot, ...]
heuristic_only: bool
```
- **Invariant:** sorting files by `len(inventory_finding_ids)` descending must not be identical to map order on the split-sink corpus (enforced by the gate).
- **Postcondition:** write `.openultrasast/runs/<id>/complexity_map.json`.
#### TestTuningAdvisor
Closed `test_kind` set: `unit | property | sanitizer | http-contract | fuzz-harness`.
```python
@dataclass(frozen=True)
class TestHint:
path: str
function_name: str | None
gap: str # no_adjacent_test | no_function_reference | no_fuzz_entry | covered
test_kind: str | None
reason: str
```
- **Invariant:** `gap == "covered"` ⇒ `test_kind is None`. Never writes into the scanned tree.
#### Complexity ledger
Path: `<repo>/.openultrasast/calibration/complexity_ledger.json`.
```json
{
"parser.c:parse_header": {
"score_delta": 1.5,
"last_verdict": "triggerable",
"round": 3
}
}
```
Apply at map build. `score_delta` cannot drop a reachable policy-severity-5 inventory finding out of the stage-3 candidate set.
### Hunter
#### HunterTools
```python
def clamp_repo_path(root: Path, user_path: str) -> Path: ...
def read_file(root: Path, path: str, *, max_chars: int) -> str: ...
def grep_repo(root: Path, pattern: str, *, max_matches: int) -> list[dict[str, object]]: ...
def find_refs(root: Path, symbol: str, mapping_index: object) -> list[dict[str, object]]: ...
```
- **Invariant:** any resolved path whose common prefix is not `root` raises `PathEscapesRepo`.
- Grep is Python `re` over enumerated source files (zero-dep). Ripgrep is an optional fast path if present, still clamped.
#### ToolHunter
Bounded loop: max steps from the hotspot's hunter budget (reuse tier A/B/C caps). Each model turn may call the three tools or stop with a JSON list of suspicions.
```python
def run_tool_hunter(
root: Path,
hotspots: list[Hotspot],
*,
client: ChatClient,
model: str,
max_steps: int,
) -> list[StaticFinding]: ...
```
- **Postconditions:** each finding `evidence_level == "suspicion"`; `finding_id` prefix `tool-hunter:`.
- **Fallback:** no model → empty hunter findings, `heuristic_only=True` on the map, degradation `hunter_model_unavailable`.
### Sandbox and regression
#### SandboxRunner
```python
class SandboxRunner(Protocol):
def run(self, spec: SandboxJob) -> SandboxResult: ...
@dataclass(frozen=True)
class SandboxJob:
image: str
command: tuple[str, ...]
repo_root: Path
scratch_files: dict[str, str] # relative to /scratch
timeout_seconds: int
memory_mb: int
pids_limit: int
```
`DockerCliRunner` argv (conceptual): `docker run --rm --init --network none --read-only --tmpfs /scratch:rw,exec --memory --pids-limit --cap-drop ALL --security-opt no-new-privileges --user` non-root `--mount type=bind,src=repo,dst=/workspace,ro` image command.
- **Forbidden:** `-v /var/run/docker.sock`, `--network host`, `--privileged`, writable bind of the source tree.
- `SandboxProbe.available()` runs `docker info` with a short timeout.
#### RegressionRunner
Select `min(max_candidates, top_k_hotspots ∪ sev5_reachable_inventory)`. Render a recipe snippet into `scratch_files`. Safety check rejects snippets that mention `socket`, `curl`, `docker.sock`, or writes under `/workspace`.
Verdicts:
| Observation | Verdict |
|---|---|
| recipe or sandbox missing, timeout, safety reject | `inconclusive` |
| existing project test covering the hotspot already passes in sandbox | `already_covered` |
| candidate exits non-zero with a crash, assertion, or sanitizer abort | `triggerable` |
| candidate exits 0 | `not_triggerable` |
`worth_fixing` ⇔ `triggerable` and related finding reachability is `reachable` or `inferred-file-surface`.
`--fail-on` gains `worth-fixing`. Existing `findings` / `verified` unchanged.
Recipes MVP: Python (`python /scratch/case.py` with `PYTHONPATH=/workspace`), JavaScript (`node /scratch/case.js`), C (`cc` + run) when the image contains a compiler. Java recipe may return `inconclusive` until added.
## Data Models
### Manifest additions
```json
{
"stages": {
"requested": ["static", "map", "regress"],
"completed": ["static", "map"],
"skipped": [{"stage": "regress", "reason": "sandbox_unavailable"}]
},
"complexity": {"hotspot_count": 12, "heuristic_only": true},
"worth_fixing": {"count": 0, "verdicts": []}
}
```
Degradations reuse the existing list: `{ "stage": "regress", "reason": "sandbox_unavailable" }`.
### Consistency
- Map artifact and ledger are JSON, resume-safe.
- Hunter suspicions never jump evidence levels in this spec.
- Stage 1 artifacts remain byte-compatible with current `findings.json` schema; new fields are additive.
## Error Handling
| Error | Response |
|---|---|
| `deep` requested, Docker missing | Complete 1–2, skip 3, degradation, exit 0 unless other fail-on hits |
| Model missing on `standard` | Heuristic map, no hunter findings, degradation |
| Path escape in a tool | Tool error to the model; do not throw the scan |
| Snippet fails safety check | `inconclusive`, do not run |
| Docker run timeout | kill container (`--rm`), `inconclusive` |
| Ledger would hide sev-5 reachable | Candidate still selected |
## Testing Strategy
### Unit
- Mode→stage mapping and skip accounting.
- Path clamp rejects `../`.
- Map order on a fixture where regex hits a trivial helper and the real bug is a split-sink parser — parser ranks higher.
- Covered hotspot yields `gap=covered` and no test kind.
- Safety check rejects docker.sock and network snippets.
- Ledger boost/demote; sev-5 reachable remains a candidate.
### Integration
- Fake sandbox: `triggerable` / `not_triggerable` / timeout mapped to verdicts.
- Tool hunter with a scripted chat client that must call `grep_repo` before concluding on a split-sink file; fail if it only returns JSON with no tool call.
- `--fail-on worth-fixing` exits non-zero only when a `worth_fixing` verdict exists.
### Evaluation / CI
- Stage 1: existing `python -m openultrasast.gate` on smoke corpora.
- Stage 2: split-sink manifests; true vulnerable function in the high band; map order ≠ pattern-hit order.
- Stage 3: `@pytest.mark.docker`; skip if probe fails; at least one planted split-sink that stage 1 missed is `triggerable`.
- PR workflow stays `quick`. Nightly/deep is a separate job with Docker.
### Anti-patterns this spec forbids in tests
- One-line `eval(request.args)` as the only stage-2 proof.
- Asserting hunter findings equal regex findings.
- Calling the real Docker daemon in the default `pytest` job.
## Security Considerations
- Scanned code is untrusted. Stage 1–2 read it as text. Stage 3 executes only in Docker with network off, source read-only, no docker.sock, dropped caps, no-new-privileges, non-root user.
- Hunter tools cannot read outside the repo.
- Regression snippets are data, not host shell. They go to `/scratch` in the container.
- Secrets redaction already applies to traces and markdown; keep it on for map rationales that quote source.
## Performance & Scalability
- Map is O(files) plus one pass of findings. No extra model required.
- Tool hunter runs only on `max_hunter_hotspots` (default 8).
- Stage 3 `max_candidates` default 5; timeout from `SandboxConfig.timeout_seconds`.
- Large-repo CI: PR = stage 1; nightly = stage 2 always, stage 3 if probe passes.
## Migration Strategy
```mermaid
flowchart LR
P0[StagePlan plus manifest] --> P1[Heuristic map and hints]
P1 --> P2[Split-sink gate]
P2 --> P3[Tool hunter]
P3 --> P4[Docker runner plus recipes]
P4 --> P5[Ledger and fail-on worth-fixing]
```
| Phase | Ships | Merge gate | Rollback |
|---|---|---|---|
| 0 StagePlan | `deep` no longer exits; skips 2/3 with degradations | existing tests | revert CLI |
| 1 Heuristic map | `complexity_map.json` on `standard` | unit map tests | feature: empty map writer |
| 2 Split-sink gate | new fixtures; map ≠ hit-count | new gate job | gate off |
| 3 Tool hunter | OpenRouter loop, suspicion | scripted-client test | model unset |
| 4 Sandbox | Docker CLI + Python/JS recipes | docker-marked tests skip-ok | probe skip |
| 5 Worth-fixing | ledger + fail-on | integration on fake runner | flag unused |
`deep` changing from hard-exit to skip-or-run is intentional and documented. No artifact schema is removed.
@@ -0,0 +1,129 @@
# Requirements Document
## Introduction
OpenUltraSAST today answers one question well: which regexes fired. It does not answer the three questions a rugged DevOps CI workflow actually asks: what exists in this repository, where problems are most likely and how to aim tests, and whether a finding is worth fixing. This feature turns the scan into those three stages. Stage 1 is a static inventory of what exists. Stage 2 is a complexity map of likely problem areas plus test-tuning hints. Stage 3 is isolated regression that proves triggerability before CI treats a finding as blocking.
The work is a follow-up to the HarnessX ruleset-governance spec, not a replacement of it. CWE policy, project score, and rule-status shadowing stay in place. This spec owns the stage contract, the complexity map, the isolated regression verdict, the tool-using hunter on stages 2–3, and an evaluation corpus that is not a cheat sheet.
## Boundary Context
- **In scope**: a three-stage scan contract mapped onto existing scan depths; a static inventory that reports what exists without claiming verification; a complexity map of hotspots with test-tuning hints; isolated regression that produces a worth-fixing verdict; a tool-using hunter confined to stages 2–3; evaluation against split-sink and mutation corpora; visible degradation when the sandbox or a model is unavailable; evolutionary feedback from regression verdicts back into the complexity map.
- **Out of scope**: rewriting CWE-to-severity policy or the project-score formula; authoring or rewriting detection pattern text; full fuzzing campaigns; generating or applying patches; network pentesting; importing Clearwing.
- **Adjacent expectations**: stage 1 continues to run in the zero-dependency default install with no model and no sandbox. CWE policy, scoring, traces, and SARIF remain owned by the existing governance spec. The operator supplies an isolated-sandbox runtime (Docker CLI) for stage 3 and an LLM credential for the tool-using hunter; neither is a packaged library dependency of the core install.
## Requirements
### Requirement 1: Three-Stage Scan Contract
**Objective:** As a DevOps engineer, I want a scan to run as three ordered stages that map onto the existing scan depths, so that CI can choose inventory-only, map-and-tune, or prove-worth-fixing without a new command family.
#### Acceptance Criteria
1. When a scan runs at depth `quick`, the Scan Orchestrator shall execute stage 1 only and shall not start a model call or execute target-derived code.
2. When a scan runs at depth `standard`, the Scan Orchestrator shall execute stage 1 then stage 2.
3. When a scan runs at depth `deep`, the Scan Orchestrator shall execute stage 1 then stage 2 then stage 3.
4. The Scan Orchestrator shall record the stages requested, the stages completed, and any skipped stage with a reason in the scan manifest.
5. If a later stage cannot run, the Scan Orchestrator shall complete earlier stages, record a degradation, and shall not discard those earlier artifacts.
6. The Scan Orchestrator shall keep the existing scan command and depth names so existing CI invocations continue to work.
### Requirement 2: Stage 1 Static Inventory
**Objective:** As a security reviewer, I want a static inventory of what exists in the repository, so that I can see files, surfaces, and pattern hits without treating them as verified vulnerabilities.
#### Acceptance Criteria
1. When stage 1 completes, the Static Inventory shall emit a repository snapshot, file targets with language and surface tags, entry-point reachability hints, ranked files, and static findings from the existing pattern ruleset and any provided static-analyzer input.
2. The Static Inventory shall label static pattern hits as inventory, not as verified findings, in the report and in the finding evidence level.
3. While no static-analyzer input is provided, the Static Inventory shall still complete using the built-in pattern ruleset and heuristics.
4. If an enabled rule's CWE does not resolve in policy, the Scan Runtime shall fail loud before producing inventory findings (existing startup invariant, preserved).
5. The Static Inventory shall not execute target-derived code and shall not require a model.
### Requirement 3: Stage 2 Complexity Map
**Objective:** As an AppSec engineer, I want a complexity map of where problems are most likely, so that review and testing attention go to real hotspots instead of every regex hit.
#### Acceptance Criteria
1. When stage 2 runs, the Complexity Map shall produce an ordered list of hotspots, each identifying a file and, when known, a function, with a complexity score and a rationale built from size, nesting, surface tags, reachability, existing inventory-hit density, and test-presence gaps.
2. The Complexity Map shall rank hotspots independently of a single regex match: a file with no pattern hit may still be a hotspot, and a pattern hit in a trivial helper may rank below a complex reachable parser.
3. When stage 2 completes, the Complexity Map shall write a map artifact that the report and later stages consume, including the scoring signals used for each hotspot.
4. While a hunter model is unavailable, the Complexity Map shall still produce the heuristic map and shall record that the hunter did not refine it.
5. The Complexity Map shall not mark a hotspot as verified or worth fixing.
### Requirement 4: Test-Tuning Hints
**Objective:** As a developer owning CI tests, I want the complexity map to tell me how to tune tests, so that I add coverage where it reduces real risk instead of mirroring cheat-sheet sinks.
#### Acceptance Criteria
1. When the Complexity Map emits a hotspot, the Test Tuning Advisor shall attach a test-gap hint that names the missing coverage (no adjacent test file, no test referencing the function, or no sanitizer/fuzz entry for a fuzzable surface).
2. The Test Tuning Advisor shall recommend a test kind from a closed set: unit, property, sanitizer, http-contract, or fuzz-harness, with a one-line reason tied to the hotspot's signals.
3. The Test Tuning Advisor shall not write test files into the scanned repository.
4. When a hotspot already has covering tests, the Test Tuning Advisor shall say so and shall not recommend a duplicate test kind.
### Requirement 5: Stage 3 Isolated Regression and Worth-Fixing
**Objective:** As a CI owner, I want isolated regression that answers whether a finding is worth fixing, so that the build fails only on triggerable, reachable issues.
#### Acceptance Criteria
1. When stage 3 runs, the Regression Runner shall select a bounded set of top hotspots and/or inventory findings and shall attempt a minimal regression or proof-of-concept inside an isolated analysis sandbox.
2. The isolated analysis sandbox shall run with network disabled, the source tree mounted read-only, a writable scratch area, and configured memory, process, and timeout limits.
3. The Regression Runner shall emit one of four verdicts per candidate: `triggerable`, `not_triggerable`, `already_covered`, or `inconclusive`.
4. When a candidate is `triggerable` and the related finding is reachable, the Worth-Fixing Gate shall mark it `worth_fixing`.
5. When a candidate is `not_triggerable` or `already_covered`, the Worth-Fixing Gate shall mark it `not_worth_fixing` and shall not fail the build for that candidate.
6. When the verdict is `inconclusive`, the Worth-Fixing Gate shall not fail the build for that candidate and shall record why (timeout, sandbox unavailable, no candidate generated, or setup failed).
7. Where `--fail-on worth-fixing` is selected, the Scan Orchestrator shall fail the scan if and only if at least one `worth_fixing` verdict was produced.
8. The Regression Runner shall never execute target-derived code on the host outside the isolated sandbox.
### Requirement 6: Tool-Using Hunter on Stages 2 and 3
**Objective:** As an OpenUltraSAST maintainer, I want the hunter on later stages to use repository tools instead of dumping a file into a prompt, so that it can follow dataflow across lines and files the way a real review does.
#### Acceptance Criteria
1. Where a hunter model is configured, the Hunter Pool shall run a bounded tool-using loop against stage-2 hotspots, with tools limited to reading a file inside the repository, searching repository text, and listing callers or references already known to the scan.
2. The Hunter Pool shall confine every tool path to the scanned repository root and shall reject path arguments that escape that root.
3. When the hunter reports a candidate issue, the Scan Runtime shall record it at evidence level `suspicion` until independent static or regression evidence raises it.
4. While no hunter model is configured, the Hunter Pool shall skip the tool loop, leave the heuristic complexity map in place, and record a degradation.
5. The Hunter Pool shall not treat a model JSON list as static corroboration.
6. When stage 3 runs and a hunter model is configured, the Hunter Pool may propose a regression snippet, but the Regression Runner shall execute that snippet only inside the isolated sandbox after a structural safety check (no network, no writes outside scratch).
### Requirement 7: Evolutionary Feedback from Regression to Map
**Objective:** As a maintainer, I want regression verdicts to retune the complexity map, so that the scan gets better at spending attention where issues actually trigger.
#### Acceptance Criteria
1. When a hotspot's regression is `triggerable`, the Complexity Map shall raise that hotspot's subsequent score on the same repository.
2. When a hotspot's regression is `not_triggerable`, the Complexity Map shall lower that hotspot's subsequent score without deleting the underlying inventory hit.
3. The Scan Runtime shall persist those adjustments in a loop-owned ledger keyed by repository and hotspot identity, applied at map load the same way rule status overlays apply at ruleset load.
4. If a ledger entry would hide a reachable severity-5 inventory hit from the worth-fixing set, the Worth-Fixing Gate shall still consider that hit.
5. The evolutionary loop shall not edit detection pattern text and shall not edit authoritative CWE severity.
### Requirement 8: Grounded Evaluation, Not Cheat Sheets
**Objective:** As a maintainer, I want stage 2 and stage 3 quality gates to use split-sink and mutation cases, so that a regex that only matches textbook one-liners cannot claim success.
#### Acceptance Criteria
1. The Benchmark Harness shall include at least one split-sink case per in-scope language family (Python, JavaScript, Java, C/C++) where the source and the sink are on different lines or the payload is assigned before use.
2. The Benchmark Harness shall include at least one mutation of a known sink (equivalent API, intermediate variable, or wrapper) that a same-line pattern does not match.
3. When stage 1 is scored alone, the Benchmark Harness may use existing smoke fixtures, and shall not treat stage-1 recall as proof of stage-2 or stage-3 quality.
4. When stage 2 is scored, the Benchmark Harness shall require the complexity map to rank the true vulnerable function in the top band of hotspots on the split-sink corpus, even when stage 1 missed the sink.
5. When stage 3 is scored and the sandbox is available, the Benchmark Harness shall require a `triggerable` verdict on at least one planted split-sink that stage 1 missed.
6. The CI merge gate for this feature shall fail if stage 2 ranking on the split-sink corpus is no better than ranking files by pattern-hit count alone.
### Requirement 9: CI Companion Budgets and Degradation
**Objective:** As a CI owner, I want stage costs and failures to be bounded and visible, so that pull-request jobs stay cheap and nightly jobs can run the later stages without silent fallback.
#### Acceptance Criteria
1. While running in pull-request depth `quick`, the CI Pipeline shall complete stage 1 without a sandbox runtime and without a model.
2. Where a nightly or labeled job requests `standard` or `deep`, the CI Pipeline shall run those stages under the configured time, finding-count, and cost budgets.
3. If the sandbox runtime is missing when stage 3 is requested, the Scan Orchestrator shall skip stage 3, record `sandbox_unavailable`, and complete stages 1–2.
4. If a model is missing when stage 2 hunter refinement is requested, the Scan Orchestrator shall keep the heuristic map, record `hunter_model_unavailable`, and shall not invent hunter findings.
5. The Scan Orchestrator shall bound stage 3 to a configured maximum number of regression candidates per scan.
6. When any stage is skipped or truncated, the report shall show the degradation in the same manifest list used by existing capability fallbacks.
+156
View File
@@ -0,0 +1,156 @@
# Research & Design Decisions
---
**Purpose**: Capture discovery findings and architectural investigation behind the `three-stage-scan` spec. `design.md` carries the committed architecture; this file carries evidence and rejected alternatives.
---
## Summary
- **Feature**: `three-stage-scan`
- **Discovery Scope**: Extension of the existing scan harness (brownfield), with one new runtime prerequisite (Docker CLI for stage 3) and one new evaluation contract (split-sink / mutation corpora).
- **Key Findings**:
- Stage 1 already exists as `quick` mode: preprocess, mapping, rank, `quick_scan_findings`, structural verify, score, SARIF. Do not rebuild it.
- Stage 2 is not a hunter dump and is not `RankingScore`. Ranking is per-file attention. A complexity map is per-file-and-function hotspot attention plus test-gap hints. Existing signals (tags, loc, reachability hints, finding density, `has_fuzz_entry_point`) are enough for a heuristic map; the tool-using hunter refines it.
- Stage 3 has config and a threat-model table, but `cli.py` exits on `--mode deep`. No container is ever started. The first execution of untrusted code must be Docker CLI with the documented isolation flags, not a Python SDK and not host `subprocess` of target code.
- The previous HarnessX spec locked LLM use behind an optional extra that CI does not install. This spec uses the existing stdlib OpenRouter client so stage 2 can be evolutionary without that extra. HarnessX remains an optional runtime, not a prerequisite.
- Cheat-sheet fixtures cannot gate stages 2–3. The Python fixture already documents a split-sink miss (`query` built on the prior line). That class of case becomes the quality bar.
## Research Log
### Existing scan pipeline and mode mapping
- **Context**: Whether to add `--stage` or reuse `--mode quick|standard|deep`.
- **Sources Consulted**: `src/openultrasast/cli.py` (`_run_scan`, mode choices), `README.md` maturity legend, `.kiro/specs/openrouter-sast-harness/requirements.md` (hunter pool, docker sandbox, dynamic analysis), `.kiro/specs/harnessx-self-improving-rulesets/design.md`.
- **Findings**:
- `quick` is implemented and deterministic. `standard` schedules a hunter pool that calls `quick_scan_findings` unless HarnessX extra + hunter model are set. `deep` raises `SystemExit`.
- OpenRouter spec already defined: quick = static + bounded LLM review; standard = sandboxed tool-calling hunters; deep = harness/fuzz/dynamic. Implementation never reached that.
- Existing CI and README teach `--mode`. A parallel `--stage` flag would fork the contract.
- **Implications**: Map modes onto stages 1 / 1+2 / 1+2+3. Record `stages_requested` and `stages_completed` in the manifest so the three questions are explicit even if the flag names stay.
### Ranking vs complexity map
- **Context**: `rank.py` already scores files. Is stage 2 just ranking?
- **Sources Consulted**: `src/openultrasast/rank.py` (`heuristic_rank`, `composite_priority` = surface 0.5 + influence 0.2 + reachability 0.3), `preprocess.py` (`FileTarget` tags, loc, `has_fuzz_entry_point`), `mapping.py` (`analyze_entry_points`).
- **Findings**:
- Ranking is file-level and exists to budget the hunter. It does not name functions, test gaps, or "tune your tests."
- Tags already encode likely problem classes (`memory_unsafe`, `parser`, `network_entry`, `syscall_entry`). Loc ≥ 500 boosts influence. No nesting/branch signal. No test-file presence.
- A hotspot that stage 1 missed (split-sink) can still rank high if the file is a reachable parser. That is the point of stage 2.
- **Implications**: Keep `RankingScore`. Add `ComplexityMap` / `Hotspot` with function identity when recoverable, test-gap hints, and a ledger overlay. Ranking still feeds hunter budgets; the map feeds humans and stage 3.
### Hunter: regex, HarnessX dump, or tools
- **Context**: Previous spec adopted HarnessX "at the hunter loop" but the default hunter is regex and the HarnessX path dumps source into a prompt.
- **Sources Consulted**: `hunter.py` (`_run_hunter_task` → `quick_scan_findings`), `hunter_harness.py` (`_describe`, `_extract_findings`), `provider/openrouter.py` (`OpenRouterChatClient.complete_json`, stdlib `urllib`), `harness_ext.py` (optional extra).
- **Findings**:
- OpenRouter client is already zero-extra (stdlib). It is used for optional ranking, not for hunting.
- HarnessX extra is SHA-pinned, heavy, absent in CI. Requiring it repeats the last spec's failure mode.
- Clearwing hunters use `read_source_file`, `grep_source`, `find_callers` with path clamping. That is the evolutionary piece, not MetaAgent regex-status flips.
- **Implications**: Stage 2/3 hunter is a bounded tool loop over the OpenRouter client (or HarnessX if already wired). Tools are stdlib (read, Python grep, caller search from mapping + text). Path clamp at repo root. Hunter output evidence = `suspicion`.
### Docker sandbox: CLI vs SDK vs skip
- **Context**: Threat model already lists sandbox limits. Need a runner that does not invert zero-dep posture.
- **Sources Consulted**: `docs/threat-model.md` (sandbox table), `config.py` `SandboxConfig`, Clearwing `hunter_sandbox.py` (image build + spawn, Docker SDK), Docker isolation guidance (`docker run --network none --read-only --tmpfs --memory --pids-limit --cap-drop ALL --security-opt no-new-privileges`), initrunner docker-sandbox docs (CLI `docker run --rm --init`, optional `runsc`).
- **Findings**:
- Python `docker` SDK would be a new core or extra dependency. The operator already has the Docker CLI if they can run containers.
- Subprocess `docker run --rm --init` with bind-mount of the repo read-only and a tmpfs/scratch write area matches the threat model and keeps `dependencies = []`.
- Unit tests must not require a daemon: inject a `SandboxRunner` protocol, ship a fake. Integration tests skip when `docker info` fails.
- Full sanitizer-image builds (Clearwing) are out of scope for this spec. Stage 3 runs language-specific commands on a small pinned image per language family, or `inconclusive` if the recipe is missing.
- **Implications**: `SandboxProbe` + `DockerCliRunner`. No SDK. gVisor/`runsc` is optional `security-opt` when `docker info` reports it; not required.
### Evaluation corpora
- **Context**: User rejected cheat-sheet fixtures. Current gate is ≥90% recall on those fixtures.
- **Sources Consulted**: `benchmarks/fixtures/python-vulnerable/app.py` (line 77 split-sink comment), `javascript-vulnerable/src/app.js` (prototype pollution comment), `tests/test_detection_benchmarks.py`, `gate.py`.
- **Findings**:
- Stage 1 smoke fixtures are still useful as unit tests. They must not be the stage 2/3 merge gate.
- A split-sink corpus is small and owned: source and sink on different lines; mutations through wrappers/variables. Success = map ranks the true function in the top band; stage 3 triggers at least one stage-1 miss when Docker is present.
- Comparing map ranking to "sort by pattern-hit count" is an anti-cheat-sheet test: if regex density is the map, the gate fails.
- **Implications**: New `benchmarks/fixtures/split-sink-*` and a stage-2 ranking gate. Keep existing `openultrasast.gate` for stage 1 only.
### Seams that already exist and must not be confused
- **Context**: Brownfield traps for a spec named "map" that also adds Docker.
- **Sources Consulted**: `skills.py` (`STAGES = ("map", "hunt", "verify", "fix")`), `mcp.py:68` (rejects `deep`), `reports.py:115` (`scan_exit_code`), `mapping.py` (no Java/Groovy AST), `config.py` (`SandboxConfig` parsed, never consumed by `_run_scan`), `rank.py` (`rank_targets_with_model` unused by CLI).
- **Findings**:
- Trail of Bits skill routing already uses a stage named `map` (mapping-discipline skills: Semgrep/CodeQL/sharp-edge). That is not the complexity map. `stages.py` must not reuse `skills.STAGES`.
- MCP scan tools reject `deep` and forbid docker/shell. This spec does not widen the MCP surface; deep is CLI/CI only.
- `--fail-on` is implemented in `scan_exit_code`, not only argparse. `worth-fixing` must be added there.
- Java/Groovy entry points are tag-only (`line=None` → `inferred-file-surface`). Hotspot `function_name` will often be empty on Java; the map must still rank the file.
- `[sandbox]`, `[dynamic]`, `[evidence]`, and `[models].ranker` are loaded into resolved config and then ignored. Stage 3 consumes `SandboxConfig`; it does not invent a parallel sandbox TOML.
- **Implications**: Naming, MCP, fail-on, and Java identity are integration constraints, not new products.
### Evidence ladder honesty
- **Context**: HarnessX hunter stamped LLM JSON as `static_corroboration`. Original spec forbids reporting LLM-only claims as verified.
- **Sources Consulted**: `verification.py` (`verify_finding` accepts when reachability is not `unknown`), `hunter_harness.py` evidence_level assignment, openrouter requirements Evidence Ladder / Verification.
- **Findings**:
- Pattern hits as `static_corroboration` is consistent with the ladder. LLM hits as `suspicion` is the missing rule.
- `--fail-on verified` today means reachable + static corroboration. That remains the cheap PR gate. `--fail-on worth-fixing` is the rugged gate.
- **Implications**: Do not change CWE policy. Change hunter evidence. Add fail-on value. Report sections: Inventory, Complexity map, Worth fixing.
## Architecture Pattern Evaluation
| Option | Description | Strengths | Risks / Limitations | Notes |
|--------|-------------|-----------|---------------------|-------|
| Map-then-prove on existing modes | `quick`/`standard`/`deep` = stages 1 / 1+2 / 1+2+3 | Smallest CLI blast radius; matches threat-model "deep executes in sandbox"; reuses preprocess/rank/findings | Two vocabularies (mode vs stage) until the manifest lists both | **Selected** |
| New `--stage` command family | Parallel CLI, leave modes as-is | Clearer naming | Forks CI; `deep` still a stub | Rejected |
| Clearwing-shaped full sourcehunt | Tool hunters + sanitizer images + PoC + patch | Highest detection efficiency | Fork in all but name; out of original non-goals | Rejected as this spec; remains the oracle |
| HarnessX-required agent loop | Stage 2/3 only run with the extra | Reuses last spec's seam | CI never installs it; last spec's failure | Rejected as a requirement; optional runtime only |
## Design Decisions
### Decision D1: Modes map to stages
- **Context**: Need a three-question scan without breaking CI that already passes `--mode`.
- **Selected Approach**: `quick`→stage 1, `standard`→1+2, `deep`→1+2+3. Manifest records both.
- **Rationale**: Evolutionary adoption of the CLI the project already shipped.
- **Trade-offs**: Operators must learn that `standard` now means "map" not "regex hunter pool." README must say so.
### Decision D2: Docker CLI subprocess, no SDK
- **Context**: Zero-dep core vs isolated execution.
- **Selected Approach**: `docker` binary via `subprocess`; probe with `docker info`; fake runner in unit tests.
- **Rationale**: Operator runtime, not a Python dependency. Matches threat-model flags.
- **Trade-offs**: Windows/macOS Docker Desktop variance; integration tests skip without a daemon.
### Decision D3: OpenRouter client for the tool hunter; HarnessX optional
- **Context**: Last spec hid the LLM behind an extra CI does not install.
- **Selected Approach**: Tool loop calls `OpenRouterChatClient` (or any `RankerClient`-shaped chat client) when `[models].hunter` is set. If HarnessX extra is present, `HxScanOrchestrator` may host the same tools; it is not required.
- **Rationale**: Evolutionary use of code already in tree. Stdlib HTTP.
- **Trade-offs**: Two possible runtimes until HarnessX is either used or ignored; tests mock the client.
### Decision D4: Complexity map is a new artifact
- **Context**: Ranking already exists.
- **Selected Approach**: `ComplexityMap` with `Hotspot` records. Ranking remains the hunter budget. Map is what humans and stage 3 consume.
- **Rationale**: Different question (where to look / how to test vs which file to hunt first).
### Decision D5: Worth-fixing is a gate, not an evidence level
- **Context**: Evidence ladder is already six rungs. Do not invent a seventh for CI.
- **Selected Approach**: Verdicts `triggerable|not_triggerable|already_covered|inconclusive` → `worth_fixing|not_worth_fixing`. Evidence levels unchanged. Hunter stays at `suspicion`.
- **Rationale**: CI owners need a fail-on that means "we proved it," without collapsing the ladder.
### Decision D6: Split-sink / mutation gate for stages 2–3
- **Context**: 90% recall on cheat sheets is circular.
- **Selected Approach**: New corpus + "beats pattern-hit ranking" test. Stage 1 smoke gate stays.
- **Rationale**: Directly answers the user's evaluation complaint.
### Decision D7: Ledger overlays hotspot scores, not patterns
- **Context**: Evolutionary feedback without repeating "loop must not edit patterns."
- **Selected Approach**: `.openultrasast/calibration/complexity_ledger.json` keyed by `repo_fingerprint + path + function`. Triggerable boosts, not_triggerable demotes. Cannot hide reachable severity-5 inventory from worth-fixing consideration.
- **Rationale**: Same overlay pattern as `rule_policy.json`, different object.
### Decision D8: Language recipes, not a universal executor
- **Context**: Running untrusted Python vs C vs Java is different.
- **Selected Approach**: Small recipe registry (Python and JavaScript required for MVP; C compile+run when a compiler exists in the image; Java `inconclusive` until a recipe ships). Missing recipe → `inconclusive`, not a crash.
- **Rationale**: Simplification. Do not build Clearwing's sanitizer image matrix in this spec.
## Synthesis
- **Generalization**: Inventory → attention → proof is one pipeline with a `StagePlan`. Map, hints, and regress are processors on that plan, not three products.
- **Build vs adopt**: Adopt Docker CLI and the in-tree OpenRouter client. Build the map, ledger, recipes, and split-sink corpus. Do not adopt Clearwing, docker SDK, or a cyclomatic-complexity library (nesting depth from indentation/braces is enough).
- **Simplification**: No `--stage` flag, no HarnessX requirement, no fuzz campaign, no patch oracle, no Python Docker extra.
## Risks & Mitigations
- Docker-in-Docker / rootless CI — probe, skip stage 3, degrade; do not fail stage 1.
- Hunter cost on large repos — hotspot cap + existing `[harnessx] max_cost_usd` / hardening `max_findings` / new `max_regress_candidates`.
- Untrusted snippet execution — structural deny (network, writes outside scratch, curl, docker socket mount). Never mount `/var/run/docker.sock`.
- Stage 2 still heuristic without a model — allowed; gate is ranking quality on split-sink, which heuristics can pass if tags+reachability work.
- Breaking `deep` from "exits" to "runs" — intended; document in README.
## References
- OpenUltraSAST threat model — `docs/threat-model.md`
- OpenRouter SAST harness requirements — Docker sandbox, hunter pool, evidence ladder
- HarnessX self-improving rulesets — adjacent governance; do not reopen
- Docker run isolation flags — `--network none`, `--read-only`, `--cap-drop ALL`, `--security-opt no-new-privileges`
- Clearwing sourcehunt hunter tools and sandbox — implementation oracle, not a dependency
+22
View File
@@ -0,0 +1,22 @@
{
"feature_name": "three-stage-scan",
"created_at": "2026-09-03T16:10:07Z",
"updated_at": "2026-09-03T16:47:00Z",
"language": "en",
"phase": "implementation",
"approvals": {
"requirements": {
"generated": true,
"approved": true
},
"design": {
"generated": true,
"approved": true
},
"tasks": {
"generated": true,
"approved": true
}
},
"ready_for_implementation": true
}
+203
View File
@@ -0,0 +1,203 @@
# Implementation Plan
This plan ships the three-stage scan without rebuilding CWE policy or the regex inventory. Foundation introduces the stage contract. Core builds the map, tool hunter, sandbox, and regression verdicts in parallel where boundaries do not overlap. Integration wires them onto `quick` / `standard` / `deep`. Validation adds split-sink corpora and the anti-cheat-sheet gate.
## Phase 1 — Foundation: Stage contract
- [x] 1. Introduce the stage plan and mode mapping
- [x] 1.1 Encode the three stages and the mode-to-stage table
- Depth `quick` requests only static, `standard` requests static then map, `deep` requests static then map then regress.
- A skipped stage is a structured reason, not an exception, and can be serialized into the existing degradations list.
- Observable completion: a unit test asserts the three mappings and that `quick` never includes map or regress.
- _Requirements: 1.1, 1.2, 1.3, 1.6_
- _Boundary: StagePlan_
- [x] 1.2 Add complexity and regression configuration with candidate caps
- Configuration exposes map top-k, max hunter hotspots, max regression candidates, and optional language image pins, with defaults that keep pull-request scans cheap.
- Sandbox memory, pids, timeout, network-off, and read-only workspace remain the existing sandbox settings.
- Observable completion: loading config without a file yields those defaults; an override file changes top-k and max candidates only.
- _Requirements: 9.2, 9.5_
- _Boundary: Packaging Configuration_
- _Depends: 1.1_
- [x] 1.3 Expand fail-on vocabulary with worth-fixing
- The scan command accepts `worth-fixing` alongside the existing fail-on values and does not change the meaning of `findings` or `verified`.
- The existing report exit helper must recognize the new value; unknown values still fail loud.
- MCP continues to reject `deep` and must not gain docker or shell tools.
- Observable completion: `--help` lists `worth-fixing`; a scan with no worth-fixing verdicts and `--fail-on worth-fixing` exits 0; MCP still refuses `deep`.
- _Requirements: 5.7, 5.8_
- _Boundary: StagePlan_
- _Depends: 1.1_
## Phase 2 — Core: Map, hunter tools, sandbox (parallel after foundation)
- [x] 2. Build the heuristic complexity map and test-tuning hints
- [x] 2.1 (P) Compute per-file and per-function complexity signals
- Signals include size, nesting, surface tags, reachability, inventory-hit density as a feature not the score, and whether an adjacent test appears to exist.
- Function identity uses entry-point names when present and otherwise remains empty.
- Observable completion: a parser file with no pattern hit scores higher than a one-line helper that matches a pattern, proven by a unit test on a constructed target list.
- _Requirements: 3.1, 3.2_
- _Boundary: ComplexityMapBuilder_
- [x] 2.2 (P) Emit the ordered hotspot map artifact
- Each hotspot carries score, band, rationale, signals, and linked inventory ids; the map records whether it is heuristic-only.
- Observable completion: building a map writes a JSON artifact whose hotspot order is not identical to sorting by inventory-hit count on the same fixture.
- _Requirements: 3.2, 3.3, 3.5, 8.6_
- _Boundary: ComplexityMapBuilder_
- _Depends: 2.1_
- [x] 2.3 Attach test-gap hints without writing into the scanned tree
- Each hotspot gets a gap of no adjacent test, no function reference, no fuzz entry, or covered.
- Recommended test kind is one of unit, property, sanitizer, http-contract, or fuzz-harness, and is omitted when the gap is covered.
- Observable completion: a covered hotspot emits `covered` and no test kind; a fuzzable C parser without a harness emits `fuzz-harness`; the scanned tree is unchanged.
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- _Boundary: TestTuningAdvisor_
- _Depends: 2.2_
- [x] 2.4 Apply a complexity ledger overlay that cannot hide severity-5 reachable inventory
- Triggerable verdicts raise a hotspot's later score; not-triggerable verdicts lower it; the underlying inventory hit remains.
- A reachable policy-severity-5 inventory finding stays in the regression candidate set regardless of a negative ledger delta.
- Observable completion: after a not-triggerable overlay the hotspot score drops, and a sev-5 reachable finding is still selected as a candidate, proven by a unit test.
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
- _Boundary: ComplexityMapBuilder_
- _Depends: 2.2_
- [x] 3. Add repo-bound hunter tools and a scripted tool loop
- [x] 3.1 (P) Clamp tool paths and expose read, grep, and reference search
- Read, grep, and reference listing resolve only under the scan root; `..` and absolute escapes raise a path-escape error.
- Grep runs with the standard library over enumerated source files.
- Observable completion: a unit test reads a repo file, greps a split-sink assignment, and rejects `../etc/passwd`.
- _Requirements: 6.1, 6.2_
- _Boundary: HunterTools_
- [x] 3.2 Run a bounded tool-using hunter that records suspicion only
- When a hunter model is configured, the loop may call the three tools against map hotspots and must not stamp results as static corroboration.
- When no model is configured, the loop does not run and a degradation is produced by the orchestrator later.
- Observable completion: a scripted client that never calls a tool fails the test; a client that greps then returns a finding yields evidence level `suspicion` and id prefix `tool-hunter:`.
- _Requirements: 6.1, 6.3, 6.4, 6.5_
- _Boundary: ToolHunter_
- _Depends: 3.1, 2.2_
- [x] 4. Stand up the isolated sandbox runner
- [x] 4.1 (P) Probe whether the sandbox runtime is usable
- A short `docker info` style probe returns available or unavailable without throwing into the scan.
- Observable completion: the fake probe can be forced off; the real probe is skippable in unit tests.
- _Requirements: 9.3_
- _Boundary: SandboxProbe_
- [x] 4.2 (P) Run jobs through Docker CLI flags that match the threat model
- Network is disabled, source is read-only, scratch is writable, memory/pids/timeout apply, capabilities are dropped, source tree is not writable, and the host Docker socket is never mounted.
- A fake runner records the job and returns a programmed result so default tests do not need a daemon.
- Observable completion: the constructed command line includes network-none and read-only and excludes docker.sock; the fake runner is used by unit tests.
- _Requirements: 5.2, 5.8_
- _Boundary: DockerCliRunner_
- _Depends: 4.1_
- [x] 4.3 Reject unsafe regression snippets before execution
- Snippets that refer to the Docker socket, host network, or writes under the source mount are not executed and become `inconclusive`.
- Observable completion: each forbidden shape is rejected in a unit test and no runner job is created.
- _Requirements: 5.8, 6.6_
- _Boundary: RegressionRunner_
- _Depends: 4.2_
## Phase 3 — Core: Regression verdicts
- [x] 5. Produce worth-fixing verdicts from sandboxed candidates
- [x] 5.1 Select a bounded candidate set from hotspots and severe reachable inventory
- Selection honors max-candidates and always includes reachable severity-5 inventory even when the ledger demoted the hotspot.
- Observable completion: a unit test with six hotspots and max-candidates three returns three, plus the sev-5 reachable extra when present.
- _Requirements: 5.1, 7.4, 9.5_
- _Boundary: RegressionRunner_
- _Depends: 2.4_
- [x] 5.2 (P) Add language recipes with inconclusive fallback
- Python and JavaScript recipes run a scratch snippet with the repo on the language path; C compiles and runs when the image has a compiler; a missing recipe yields `inconclusive` rather than a crash.
- Observable completion: Python and JavaScript recipes produce a job; an unknown language returns inconclusive without calling the runner.
- _Requirements: 5.1, 5.6_
- _Boundary: RegressionRunner_
- _Depends: 5.1_
- [x] 5.3 Map sandbox outcomes to the four verdicts and the worth-fixing gate
- Outcomes map to triggerable, not_triggerable, already_covered, or inconclusive; worth-fixing requires triggerable plus reachable or inferred-file-surface.
- Not-triggerable and already-covered do not fail the build; inconclusive records a reason and does not fail the build.
- Observable completion: a table-driven test covers the four verdicts and the worth-fixing boolean.
- _Requirements: 5.3, 5.4, 5.5, 5.6_
- _Boundary: WorthFixingGate_
- _Depends: 5.2, 4.2_
## Phase 4 — Integration: Wire stages into the scan
- [x] 6. Run stages from the existing scan command
- [x] 6.1 Keep quick as stage-1 inventory with no model and no target execution
- Quick still loads policy, ruleset, preprocess, rank, pattern inventory, structural verify, and score.
- The report labels those hits as inventory, not as worth-fixing.
- Policy still fails loud on an unmapped enabled CWE.
- Observable completion: existing quick tests stay green; the report contains an inventory section and does not start a sandbox or a hunter model.
- _Requirements: 1.1, 2.1, 2.2, 2.3, 2.4, 2.5, 9.1_
- _Boundary: StagePlan_
- _Depends: 1.1_
- [x] 6.2 Run the complexity map on standard and record heuristic-only when no model is set
- Standard writes the map artifact, includes it in the manifest, and records `hunter_model_unavailable` when the hunter is skipped.
- Observable completion: `ousast scan --mode standard` on a fixture produces `complexity_map.json` and a completed map stage without Docker.
- _Requirements: 1.2, 1.4, 3.3, 3.4, 6.4, 9.4, 9.6_
- _Depends: 2.3, 6.1_
- [x] 6.3 Stop exiting on deep; run or skip regression with visible degradation
- Deep no longer hard-exits. If the sandbox is missing, stages 1–2 complete and regress is skipped with `sandbox_unavailable`.
- If the sandbox is present, regression runs under the candidate cap and writes verdicts.
- Observable completion: `ousast scan --mode deep` without Docker exits 0 after stages 1–2 and lists the skip in the manifest; with a fake runner it records verdicts.
- _Requirements: 1.3, 1.5, 5.1, 9.3, 9.6_
- _Depends: 5.3, 6.2_
- [x] 6.4 Attach the tool hunter to map hotspots when a model is configured
- Hunter findings are suspicion-only and may propose a snippet that still must pass the safety check and run only in the sandbox.
- Observable completion: with a scripted client, standard+model produces tool-hunter suspicions; a proposed snippet is executed only on deep after the safety check.
- _Requirements: 6.1, 6.3, 6.5, 6.6_
- _Depends: 3.2, 6.2_
- [x] 6.5 Surface map, hints, and worth-fixing in the report and honor fail-on
- Markdown, manifest, and SARIF remain; the report gains map and worth-fixing sections.
- `--fail-on worth-fixing` fails only when a worth-fixing verdict exists.
- Observable completion: a scan with one worth-fixing verdict fails under that flag and passes under `--fail-on never`.
- _Requirements: 2.2, 4.1, 5.7, 7.3_
- _Depends: 6.3_
## Phase 5 — Validation: Grounded corpora and CI
- [x] 7. Replace cheat-sheet success for stages 2 and 3
- [x] 7.1 Add split-sink fixtures for Python, JavaScript, Java, and C/C++
- Each language family has at least one case where source and sink are on different lines or assigned before use.
- Observable completion: stage-1 pattern scan misses at least one planted sink in each family, documented in the manifest expected set.
- _Requirements: 8.1_
- _Boundary: Benchmark Harness_
- [x] 7.2 (P) Add a mutation of a known sink that a same-line pattern does not match
- At least one wrapper or intermediate-variable mutation exists per the evaluation plan.
- Observable completion: stage 1 misses the mutation; the case is listed as expected in a split-sink or mutation manifest.
- _Requirements: 8.2_
- _Boundary: Benchmark Harness_
- [x] 7.3 Gate stage 2 on ranking the true function in the high band and beating pattern-hit order
- Stage 1 may still use existing smoke fixtures; that recall is not treated as stage-2 success.
- The merge gate fails if map order equals pattern-hit-count order on the split-sink corpus.
- Observable completion: a dedicated gate command or test fails a stub map that sorts by hit count and passes the heuristic map on the split-sink corpus.
- _Requirements: 8.3, 8.4, 8.6_
- _Depends: 7.1, 2.2_
- [x] 7.4 Prove stage 3 can trigger a planted miss when the sandbox is available
- Docker-marked tests skip when the probe fails; when they run, at least one stage-1 miss is `triggerable`.
- Default pytest does not require a daemon. The pull-request job stays stage 1; a nightly or labeled job may request deep under time and candidate caps.
- Observable completion: fake-runner tests always run; docker tests skip-or-pass; the default CI workflow still invokes quick-only detection plus the new split-sink map gate.
- _Requirements: 8.5, 9.1, 9.2_
- _Depends: 6.3, 7.1_
## Implementation Notes
- `RegressConfig.images` is a sorted tuple of `(language, image)` pairs so frozen config stays hashable.
- `scan_exit_code` duck-types `worth_fixing` on verdict objects; `_run_scan` still passes no verdicts until task 6.5. Dict-shaped verdicts would not fail the gate.
- Map `top_k` default is 20 (not named in design.md); `max_hunter_hotspots=8` and `max_candidates=5` follow design.
- `collect_signals` adjacent-test detection is false unless the caller also passes `repo_files`; task 2.2 must pass enumerated repo paths.
- ToolHunter rejects JSON dumps unless a repo tool was invoked; findings are always `suspicion` with prefix `tool-hunter:`.
- Split-sink map gate treats "high band" as the highest band the map emitted on that corpus (small fixtures often top out at medium).
- `@pytest.mark.docker` tests skip when `SandboxProbe` is unavailable; default CI does not require a daemon.
+174 -34
View File
@@ -3,8 +3,8 @@
OpenUltraSAST is an independent OpenCode security harness. It combines OpenUltraSAST is an independent OpenCode security harness. It combines
HarnessX-style harness composition, OpenUltraCode verification discipline, HarnessX-style harness composition, OpenUltraCode verification discipline,
OpenRouter-hosted models and embeddings, Docker-isolated code analysis, and OpenRouter-hosted models and embeddings, Docker-isolated code analysis, and
Trail of Bits security skills into a SAST workflow whose primary product goal is Trail of Bits security skills into a SAST workflow built around one goal:
**false-positive elimination** — turning suspicion into evidence-backed findings eliminating false positives by turning suspicion into evidence-backed findings
and learning from rejected claims. and learning from rejected claims.
The full specification lives in `.kiro/specs/openrouter-sast-harness/` The full specification lives in `.kiro/specs/openrouter-sast-harness/`
@@ -12,7 +12,7 @@ The full specification lives in `.kiro/specs/openrouter-sast-harness/`
as an implementation oracle for proven source-hunting ideas, not as a dependency as an implementation oracle for proven source-hunting ideas, not as a dependency
or fork target. or fork target.
> **Maturity legend** — ✅ implemented and tested · 🟡 primitive exists, not yet > **Maturity legend:** ✅ implemented and tested · 🟡 primitive exists, not yet
> auto-wired into the scan loop · 🧭 designed, on the roadmap. The current > auto-wired into the scan loop · 🧭 designed, on the roadmap. The current
> verified gate is `usable_harness_mvp`. > verified gate is `usable_harness_mvp`.
@@ -29,6 +29,9 @@ uv run ousast index /path/to/repo
# Score the detector against a benchmark manifest # Score the detector against a benchmark manifest
uv run ousast benchmark benchmarks/manifests/python-vulnerable.toml --mode quick uv run ousast benchmark benchmarks/manifests/python-vulnerable.toml --mode quick
# Run the bounded self-improvement loop over the ruleset (benchmark-driven)
uv run ousast improve benchmarks/manifests/python-vulnerable.toml --dry-run
``` ```
Every run writes auditable artifacts under `.openultrasast/runs/<scan-id>/` Every run writes auditable artifacts under `.openultrasast/runs/<scan-id>/`
@@ -52,13 +55,13 @@ repo ──▶ preprocess (language, LOC, tags, fuzz entry points)
──▶ report.md + report.sarif + manifest.json (shared finding IDs) ──▶ report.md + report.sarif + manifest.json (shared finding IDs)
``` ```
- **quick** ✅ — deterministic, language-scoped pattern rules + reachability + - **quick** ✅: deterministic, language-scoped pattern rules + reachability +
verification. No model calls, fully reproducible. verification. No model calls, fully reproducible.
- **standard** ✅ scheduling / 🧭 LLM hunters — runs the tiered hunter pool - **standard** ✅ scheduling / 🧭 LLM hunters: runs the tiered hunter pool
(A/B/C budgets, retrieval context, selected Trail of Bits skill snippets, (A/B/C budgets, retrieval context, selected Trail of Bits skill snippets,
JSONL trajectories) and verification; language-aware LLM hunters are the next JSONL trajectories) and verification; language-aware LLM hunters are the next
upgrade (today standard reuses the deterministic findings per target). upgrade (today standard reuses the deterministic findings per target).
- **deep** 🧭 — sandboxed build/fuzz/dynamic reproduction (not yet implemented). - **deep** 🧭: sandboxed build/fuzz/dynamic reproduction (not yet implemented).
## Detection coverage ## Detection coverage
@@ -72,6 +75,31 @@ removes cross-language false positives):
| JavaScript / TS | command injection (78), code injection (95), reflected/DOM XSS (79), SQLi (89), path traversal (22), SSRF (918), weak hash (327), deserialization (502) | | JavaScript / TS | command injection (78), code injection (95), reflected/DOM XSS (79), SQLi (89), path traversal (22), SSRF (918), weak hash (327), deserialization (502) |
| Java + Groovy templates | command injection (78), SQLi via concatenation (89), weak hash (327), deserialization (502), unescaped template XSS (79) | | Java + Groovy templates | command injection (78), SQLi via concatenation (89), weak hash (327), deserialization (502), unescaped template XSS (79) |
## Project scores and central CWE policy
Severity used to be whatever string a rule set on itself. It is now governed centrally: one CWE, one severity, decided by policy — not by the rule that fired.
**Central CWE policy** ✅ (`policy/verycode.py`): a vendored `CWE_Score.tsv` (verycode-policies, ~167 CWEs) is the single source of truth. Each CWE carries a flaw category, a `severity` (0-5), and `static` / `dynamic` scope flags. `load_policy()` reads it positionally (the upstream file ships CRLF and a trailing space in the `Flaw Severity ` header), and `resolve_severity()` resolves a finding's severity *exclusively* from policy, keyed on CWE — any legacy rule-local severity is discarded. CWEs that are not `static` resolve to `0` and are report-only, never scored.
**Fail-loud startup** ✅: `assert_rules_resolve(PATTERN_RULES, policy)` runs as the `policy_check` stage right after `policy_load`. If any *enabled* rule names a CWE the policy does not govern, it raises `PolicyError` and the scan aborts before doing work — you cannot ship a rule whose severity nobody decided.
**0-100 project score** ✅ (`scoring/project_score.py`): each finding's penalty is `severity weight × reachability multiplier`, and the score is an exponential decay of the total (`100 · e^(−total/k)`, `k=60`).
| Severity | Weight (`SEV_WEIGHT`) | | Reachability | Multiplier (`REACH_MULT`) |
| --- | --- | --- | --- | --- |
| 5 | 50 | | `reachable` | 1.0 |
| 4 | 25 | | `inferred-file-surface` | 0.6 |
| 3 | 10 | | `unknown` | 0.4 |
| 2 / 1 / 0 | 2 / 1 / 0 | | | |
No penalty → 100; one reachable severity-5 finding → ~43. The **reachability multiplier is the false-positive calibration knob**: a confirmed FP lowers a finding's effective reachability instead of deleting the rule, so the score moves without losing the detection.
**Two-condition gate** ✅: a finding that is both severity-5 *and* `reachable` **always** fails the gate. The `score < min_score` threshold (default `min_score=80`) only fails when `blocking` is enabled — so scoring is **advisory-first by default** and turns into a CI gate when you opt in.
**Artifacts** ✅: the `score` stage writes `score.json` (project score, `max_severity`, `penalty_total`, `by_category`, `out_of_scope_dynamic_only`, `unmapped_cwe`, gate verdict) and merges the same block into `manifest.json`. Scoring is zero-dependency (stdlib only). One mapping detail: verycode has no CWE-120 (generic buffer copy), so the C/C++ memory-unsafe rules listed under [Detection coverage](#detection-coverage) carry **CWE-121** (Stack-Based Buffer Overflow, severity 5), which the policy does govern.
🧭 This is the first implemented slice (**Phase 1**) of the `.kiro/specs/harnessx-self-improving-rulesets/` spec: a central, policy-governed severity model and a project score. Rules-as-data and the full HarnessX self-improvement loop over rulesets are later phases on that roadmap.
## Running the benchmarks ## Running the benchmarks
Benchmarks wrap the same scan pipeline and score it against ground-truth Benchmarks wrap the same scan pipeline and score it against ground-truth
@@ -114,16 +142,16 @@ SQLi, prototype pollution) so recall is not self-fulfilling.
| Java | 100% (4/4) | 0.0% | | Java | 100% (4/4) | 0.0% |
| **Overall** | **93.6% (44/47)** | **0.0%** | | **Overall** | **93.6% (44/47)** | **0.0%** |
The project goal — **≥90% recall and <10% false positives** per language — is The project goal is **≥90% recall and <10% false positives** per language. The
enforced as a regression gate in `tests/test_detection_benchmarks.py`, so a rule gate lives in `tests/test_detection_benchmarks.py`, so a rule change that drops
change that drops recall or raises false positives fails CI. recall or raises false positives fails CI.
## OpenCode commands, fusion, and ultra workflows ## OpenCode commands, fusion, and ultra workflows
OpenUltraSAST is driven from the command line. You can call the `ousast` CLI OpenUltraSAST runs from the command line. You can call the `ousast` CLI directly,
directly, or run [OpenCode](https://opencode.ai) in the repository and let the or run [OpenCode](https://opencode.ai) in the repository and let the agent run
agent run the harness for you — OpenCode is invoked from the command line, loads the harness. OpenCode loads the project skills below, then executes `ousast` plus
the project skills below, and executes `ousast` plus the triage/fix workflow. the triage/fix workflow.
```bash ```bash
opencode run "scan this repo with ousast in quick mode and triage the findings" opencode run "scan this repo with ousast in quick mode and triage the findings"
@@ -139,20 +167,48 @@ Project skills that steer the harness from OpenCode (`.opencode/skills/`):
| `openultrasast-triage` ✅ | false-positive elimination, ranking calibration, verifier adjudication | | `openultrasast-triage` ✅ | false-positive elimination, ranking calibration, verifier adjudication |
| `openultrasast-fix-audit` ✅ | OpenUltraCode fix lifecycle and adversarial fix review | | `openultrasast-fix-audit` ✅ | OpenUltraCode fix lifecycle and adversarial fix review |
**Ultra workflows (OpenUltraCode discipline)** — fixes are never a one-shot ### MCP server (narrow surface) ✅
patch prompt. `openultrasast-fix-audit` runs the bounded lifecycle
`ousast mcp` runs a zero-dependency MCP server over stdio (newline-delimited
JSON-RPC) exposing only stable, project-level operations — **never** arbitrary
shell, Docker, or internal hunter tools. Point an MCP client (OpenCode, Claude
Desktop, an IDE) at it:
```jsonc
// e.g. an MCP client config entry
{ "command": "uv", "args": ["run", "ousast", "mcp"] }
```
Tools: `openultrasast.scan`, `status`, `findings`, `get_finding`, `evidence`,
`artifacts`, `benchmark`, `explain`, `propose_patch` (degrades visibly — the patch
oracle is a later phase), and `export_report`. The `scan`/`benchmark` tools run the
bounded analysis pipeline; no tool accepts a free-form command. A typical flow:
`scan {path}` → returns a `run_dir` → `findings {run_dir}` → `explain {run_dir, finding_id}`.
**Ultra workflows (OpenUltraCode discipline):** fixes are never a one-shot patch
prompt. `openultrasast-fix-audit` runs the bounded lifecycle
`intake → plan → minimal patch → adversarial review → reconcile → fresh `intake → plan → minimal patch → adversarial review → reconcile → fresh
verification → ready`, and a patch can only reach `patch_validated` after verification → ready`, and a patch can only reach `patch_validated` after
sandboxed validation passes with no accepted blocking findings. sandboxed validation passes with no accepted blocking findings.
**Fusion (deepening) 🧭** — when a finding needs more reasoning than the normal **Fusion (deepening) ✅:** when a finding needs more reasoning than the normal
ranker → hunter → verifier → mapping loop provides (critical/high severity, ranker → hunter → verifier → mapping loop provides (critical/high severity,
verifier disagreement, conflicting static vs semantic evidence, risky fixes, or verifier disagreement, conflicting static vs semantic evidence, risky fixes, or
an explicit high-assurance request), fusion runs two independent panels that an explicit high-assurance request), fusion runs two independent panels that
critique, revise, vote, and a decider issues the disposition (accepted / steel-man the vulnerability and false-positive cases, vote, and a decider issues
rejected / mitigated / deferred / blocked) with model IDs and degradations the disposition (accepted / rejected / mitigated / deferred / blocked) with votes,
disclosed. The triage skill escalates to fusion *when available*; the panel model IDs, and degradations disclosed. It runs automatically on triggered findings
implementation is on the roadmap (Phase 13). in `--mode standard`, writing `fusion.json` and a `fusion` block in the manifest.
Deterministic by default (`fusion.py`); set `[fusion] panel_model` to route the
panels through the configured provider:
```toml
[fusion]
enabled = true # default; runs on triggered findings in standard mode
panel_model = "gpt-4o" # optional: LLM panels via the [harnessx] provider
decider_model = "gpt-4o" # optional: disclosed in the decision's model IDs
high_assurance = false # true forces fusion on every finding
```
> The `kiro-*` skills and `AGENTS.md` in this repo are the maintainer's > The `kiro-*` skills and `AGENTS.md` in this repo are the maintainer's
> spec-driven development tooling. They are not part of using OpenUltraSAST and > spec-driven development tooling. They are not part of using OpenUltraSAST and
@@ -184,9 +240,9 @@ When a finding is still rejected after review, `openultrasast-triage` records a
taxonomy (`unreachable_path`, `missing_attacker_control`, `sanitizer_disproved`, taxonomy (`unreachable_path`, `missing_attacker_control`, `sanitizer_disproved`,
`static_rule_mismatch`, `incorrect_model_assumption`, `duplicate`, `static_rule_mismatch`, `incorrect_model_assumption`, `duplicate`,
`insufficient_impact`, `unsupported`, `contradicted`, `unverified`), evidence, `insufficient_impact`, `unsupported`, `contradicted`, `unverified`), evidence,
and a **scope**. Scoping is the safety property: a rejected `syscall_entry` claim and a **scope**. Scoping keeps the learning narrow: a rejected `syscall_entry`
in `auth/` demotes only `auth/…`, never the whole vulnerability class — proven by claim in `auth/` demotes only `auth/…`, never the whole vulnerability class. This
`tests/test_calibration.py::test_scoped_false_positive_demotion_does_not_suppress_class_globally`. is covered by `tests/test_calibration.py::test_scoped_false_positive_demotion_does_not_suppress_class_globally`.
Demotion (with an audit trail) is preferred over deletion, so an analyst can Demotion (with an audit trail) is preferred over deletion, so an analyst can
always see *why* something was downgraded. always see *why* something was downgraded.
@@ -209,20 +265,56 @@ overflow the regex engine cannot see:
``` ```
The `next_improvement_candidate` routes the gap to the stage that should close The `next_improvement_candidate` routes the gap to the stage that should close
it — a static rule, SARIF source, entry-point mapping, retrieval package, it: a static rule, SARIF source, entry-point mapping, retrieval package,
language-aware hunter prompt, dynamic reproducer, or skill route. Once a miss is language-aware hunter prompt, dynamic reproducer, or skill route. Once a miss is
addressed, it stays closed because the recall/precision gate runs in CI. addressed, it stays closed because the recall/precision gate runs in CI.
`external_baseline_deltas.json` additionally shows where another tool found a `external_baseline_deltas.json` additionally shows where another tool found a
vulnerability OpenUltraSAST missed (or vice-versa). vulnerability OpenUltraSAST missed (or vice-versa).
## Configuring the HarnessX agentic plane
The LLM hunter pool and the llm-judge verifier are optional. The core install is
zero-dependency and `--mode quick` never calls a model. To enable the agentic
plane, install the extra, name the models, and pick a provider:
```bash
uv sync --extra harnessx # SHA-pinned optional dependency
export OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY, matching the provider
uv run ousast scan /path/to/code --mode standard --config openultrasast.toml
```
```toml
# openultrasast.toml
[models]
hunter = "gpt-4o" # LLM hunter pool (empty -> deterministic hunter)
verifier = "gpt-4o" # llm-judge verifier (empty -> structural verifier)
[harnessx]
provider = "openai" # "anthropic" (default) | "openai" | "litellm"
max_cost_usd = 2.0 # per-task spend cap
token_threshold = 120000 # per-task token budget
```
The provider selects the HarnessX backend for **both** the hunter and the
verifier; each reads its own standard key env var (`openai` → `OPENAI_API_KEY`,
`anthropic` → `ANTHROPIC_API_KEY`). The HarnessX path activates only when the
extra is installed, a model is configured, and the mode is `standard` — all three
gated through one capability seam (`harness_ext.has_harnessx`). If any condition
fails, the scan transparently falls back to the deterministic hunter / structural
verifier and records a `degradations` entry in `manifest.json`, so a run is never
silently downgraded:
```bash
jq '.degradations' "$run/manifest.json" # null = HarnessX ran; entries = fell back
```
## The HarnessX self-improving cycle ## The HarnessX self-improving cycle
Each scan is a composed harness of typed processors with read/write **state Each scan is a composed harness of typed processors with read/write **state
contracts** (strict mode fails the scan on a violation; warn mode records a contracts**. Strict mode fails the scan on a violation; warn mode records a
degradation) and a full event trace. The loop is now **closed automatically degradation. The loop now runs inside the pipeline without an agent: every scan
inside the pipeline** — no agent required: every scan persists its verifier persists verifier rejections as scoped learnings, and the next scan loads them
rejections as scoped learnings and the next scan loads them and demotes those and demotes those scopes before findings are produced.
scopes before findings are produced.
``` ```
┌──────────── automatic ledger (no agent in the loop) ───────────┐ ┌──────────── automatic ledger (no agent in the loop) ───────────┐
@@ -249,11 +341,58 @@ priority is demoted rather than the finding deleted, the audit trail
(`applied_calibrations.json`, the ledger) shows exactly why a surface lost (`applied_calibrations.json`, the ledger) shows exactly why a surface lost
attention. Covered by `tests/test_pipeline_calibration.py`. attention. Covered by `tests/test_pipeline_calibration.py`.
Beyond the automatic ranking loop, **OpenCode still steers the richer ### Rule-level loop: `ousast improve`
adjustments** — prompt constraints, retrieval filters, skill routing, and
benchmark-miss triage — via `openultrasast-triage`; those `RankingCalibration` The ranking loop above adjusts *attention*. A second, bounded loop adjusts the
fields are already produced and will be consumed directly once the language-aware **governed ruleset itself** from benchmark feedback — and writes its result to the
LLM hunters land (`standard_security_harness`). same loop-owned ledger a scan reads, so the loop closes end-to-end:
```bash
# Preview what the loop would change (writes nothing)
uv run ousast improve benchmarks/manifests/java-spring-boot-vulnerable.toml --dry-run
# Apply: accepted rounds land in <target>/.openultrasast/calibration/rule_policy.json,
# which the next `ousast scan`/`benchmark` of that target loads automatically.
uv run ousast improve benchmarks/manifests/java-spring-boot-vulnerable.toml
```
Each round (`improve/evolve.py`): benchmark with the current ledger → per-rule
signals (`rule_signals.json`) → propose **bounded** edits → validate → replay
smoke → re-benchmark → a **hard acceptance gate** → accept or revert
byte-for-byte. The loop may only pull two levers (rule status, score constants);
it can never edit a rule's pattern text or the authoritative 0–5 CWE severity, and
must stage `enabled → shadow` before `disabled`. A round is accepted only if
**recall ≥ 90% AND FP < 10% AND the project score did not regress AND no matched
finding was lost** — otherwise the ledger is never written. Auto-shadowing removes
a precision-dragger's false-positive cost without deleting it (recall is
preserved); persistent misses are *nominated* through the signal file, leaving
pattern authoring to a human pull request. The project score is the optimization
reward; the recall/FP gate is a separate hard constraint that is never folded into
it — and the same gate runs in CI (`python -m openultrasast.gate`), so a loop
result that breached it could never merge. Covered by `tests/test_improve.py` and
`tests/test_cli_improve.py`.
This is the deterministic substrate of the self-improvement loop. When the
optional `openultrasast[harnessx]` extra is present, the HarnessX `MetaAgent`
becomes the richer *proposer* that plugs into this same validated, gated
machinery — the safety contract (bounded levers, replay/novelty/journal gates,
hard acceptance gate, byte-for-byte revert) is identical either way.
Beyond these loops, `openultrasast-triage` still lets OpenCode adjust prompt
constraints, retrieval filters, skill routing, and benchmark-miss triage. Those
`RankingCalibration` fields are already produced and will be consumed directly
once the language-aware LLM hunters land (`standard_security_harness`).
## Security & hardening
OpenUltraSAST treats scanned code as **untrusted input and never executes it** (quick
and standard modes). Scan artifacts are scrubbed of credentials before they are written
(`[hardening] redact_secrets`, on by default), agentic spend and output size are bounded
(`[harnessx] max_cost_usd`/`token_threshold`, `[hardening] max_findings`), provider calls
retry transient failures with backoff, and missing capabilities degrade visibly in the
manifest rather than silently. See [docs/threat-model.md](docs/threat-model.md) for the
full trust boundaries and sandbox limits, and [docs/examples.md](docs/examples.md) for
end-to-end walkthroughs.
## Development ## Development
@@ -262,5 +401,6 @@ uv run pytest # tests, incl. the 90/10 detection gate
uv run ruff check . # lint uv run ruff check . # lint
uv run ruff format --check . # format uv run ruff format --check . # format
uv run mypy src/openultrasast # types uv run mypy src/openultrasast # types
uv run python -m openultrasast.gate # standalone detection gate
uv run python dagger/ci.py # full containerized CI pipeline uv run python dagger/ci.py # full containerized CI pipeline
``` ```
@@ -0,0 +1,13 @@
"""Mutation of os.system: the callable is aliased before use so a same-line regex misses it."""
import os
from flask import Flask, request
app = Flask(__name__)
run_cmd = os.system
@app.get("/ping")
def ping():
host = request.args["host"]
return run_cmd("ping -c 1 " + host) # CWE-78 mutation: os.system aliased before the call
@@ -0,0 +1,8 @@
#include <stdio.h>
#include <sqlite3.h>
void search(sqlite3 *db, char *term) {
char query[512];
snprintf(query, sizeof(query), "SELECT * FROM items WHERE title LIKE '%%%s%%'", term);
sqlite3_exec(db, query, 0, 0, 0); /* CWE-89 split-sink: query built on prior line (regex blind spot) */
}
@@ -0,0 +1,12 @@
package example;
import jakarta.servlet.http.HttpServletRequest;
import java.sql.Connection;
class SplitSinkServlet {
void search(HttpServletRequest request, Connection connection) throws Exception {
String term = request.getParameter("q");
String sql = String.format("select * from items where title like '%%%s%%'", term);
connection.createStatement().executeQuery(sql); // CWE-89 split-sink: sql built on prior line (regex blind spot)
}
}
@@ -0,0 +1,12 @@
// Split-sink SQL injection: query is built, then executed on the next line.
const express = require("express");
const app = express();
app.get("/search", (req, res) => {
const term = req.query.q;
const query = "select * from items where title like '%" + term + "%'";
db.query(query); // CWE-89 split-sink: query built on prior line (regex blind spot)
res.send("ok");
});
module.exports = app;
@@ -0,0 +1,11 @@
"""Split-sink SQL injection: query is built, then executed on the next line."""
from flask import Flask, request
app = Flask(__name__)
@app.get("/search")
def search(db):
term = request.args["q"]
query = "select * from items where title like '%" + term + "%'"
return db.execute(query) # CWE-89 split-sink: query built on prior line (regex blind spot)
@@ -0,0 +1,7 @@
"""Trivial eval helper: many same-line pattern hits, little complexity."""
def ping() -> str:
eval("1")
eval("2")
return "ok"
+20
View File
@@ -0,0 +1,20 @@
name = "mutation-python"
language = "python_web"
frameworks = ["flask"]
setup = ["pip install flask"]
modes = ["quick", "standard"]
known_noise = []
baselines = []
[source]
type = "local"
path = "../fixtures/mutation-python"
[[expected]]
cwe = "CWE-78"
class = "command injection"
path = "app.py"
line = 13
function = "ping"
sink = "run_cmd"
evidence = "aliased os.system runs an attacker-controlled command"
+20
View File
@@ -0,0 +1,20 @@
name = "split-sink-c"
language = "c_cpp"
frameworks = ["native"]
setup = []
modes = ["quick", "standard"]
known_noise = []
baselines = []
[source]
type = "local"
path = "../fixtures/split-sink-c"
[[expected]]
cwe = "CWE-89"
class = "sql injection"
path = "src/vuln.c"
line = 7
function = "search"
sink = "sqlite3_exec"
evidence = "sqlite3_exec runs a SQL query string built from attacker input on a prior line"
+20
View File
@@ -0,0 +1,20 @@
name = "split-sink-java"
language = "java_web"
frameworks = ["servlet"]
setup = ["mvn test"]
modes = ["quick", "standard"]
known_noise = []
baselines = []
[source]
type = "local"
path = "../fixtures/split-sink-java"
[[expected]]
cwe = "CWE-89"
class = "sql injection"
path = "src/main/java/example/SplitSinkServlet.java"
line = 10
function = "search"
sink = "executeQuery"
evidence = "executeQuery runs a SQL string built from request input on a prior line"
@@ -0,0 +1,19 @@
name = "split-sink-javascript"
language = "javascript_node_web"
frameworks = ["node", "express"]
setup = ["npm install"]
modes = ["quick", "standard"]
known_noise = []
baselines = []
[source]
type = "local"
path = "../fixtures/split-sink-javascript"
[[expected]]
cwe = "CWE-89"
class = "sql injection"
path = "src/app.js"
line = 8
sink = "query"
evidence = "query runs a SQL string built from request input on a prior line"
@@ -0,0 +1,20 @@
name = "split-sink-python"
language = "python_web"
frameworks = ["flask"]
setup = ["pip install flask"]
modes = ["quick", "standard"]
known_noise = []
baselines = []
[source]
type = "local"
path = "../fixtures/split-sink-python"
[[expected]]
cwe = "CWE-89"
class = "sql injection"
path = "app.py"
line = 11
function = "search"
sink = "execute"
evidence = "execute runs a SQL query string built from request input on a prior line"
+36 -1
View File
@@ -6,8 +6,25 @@ import anyio
import dagger import dagger
# Substrings that mark a transient registry / image-pull failure (e.g. a Docker
# Hub 5xx on the python:3.11-bookworm pull, or a registry.dagger.io timeout) that
# is worth retrying rather than failing the whole pipeline.
_TRANSIENT_MARKERS = (
"pull image",
"failed to authorize",
"failed to fetch oauth",
"TransportQueryError",
"i/o timeout",
"connection reset",
"context deadline exceeded",
" 502",
" 503",
" 520",
" 429",
)
async def main() -> None:
async def _run_checks() -> None:
config = dagger.Config(log_output=sys.stderr) config = dagger.Config(log_output=sys.stderr)
async with dagger.Connection(config) as client: async with dagger.Connection(config) as client:
source = client.host().directory( source = client.host().directory(
@@ -33,11 +50,29 @@ async def main() -> None:
.with_exec(["uv", "run", "ruff", "check", "."]) .with_exec(["uv", "run", "ruff", "check", "."])
.with_exec(["uv", "run", "mypy", "src/openultrasast"]) .with_exec(["uv", "run", "mypy", "src/openultrasast"])
.with_exec(["uv", "run", "pytest"]) .with_exec(["uv", "run", "pytest"])
.with_exec(["uv", "run", "python", "-m", "openultrasast.gate"])
.with_exec(["uv", "run", "python", "-m", "openultrasast.map_gate"])
.with_exec(["uv", "run", "python", "-m", "compileall", "src", "tests"]) .with_exec(["uv", "run", "python", "-m", "compileall", "src", "tests"])
.with_exec(["uv", "build"]) .with_exec(["uv", "build"])
) )
await checks.sync() await checks.sync()
async def main() -> None:
attempts = 3
for attempt in range(1, attempts + 1):
try:
await _run_checks()
return
except Exception as exc: # noqa: BLE001 — retry only transient registry/pull failures
message = str(exc)
transient = any(marker in message for marker in _TRANSIENT_MARKERS)
if attempt < attempts and transient:
print(f"dagger: transient registry/pull failure on attempt {attempt}; retrying in 20s", file=sys.stderr)
await anyio.sleep(20)
continue
raise
if __name__ == "__main__": if __name__ == "__main__":
anyio.run(main) anyio.run(main)
+73
View File
@@ -0,0 +1,73 @@
# End-to-end examples
Each example is self-contained. Artifacts land under
`<target>/.openultrasast/runs/<scan-id>/` and `.openultrasast/` is gitignored.
## 1. Quick scan a local repo (deterministic, no keys)
```bash
uv run ousast scan /path/to/code --mode quick
run=/path/to/code/.openultrasast/runs/<scan-id>
cat "$run/report.md" # findings + evidence (secrets redacted)
jq . "$run/score.json" # 0–100 project score + gate verdict
"$run/report.sarif" # SARIF for code-scanning / IDEs
```
## 2. CI gate
```bash
# Fail the build on any evidence-verified finding:
uv run ousast scan . --mode quick --fail-on verified
# The hard detection gate (>=90% recall, <10% FP), score-independent:
uv run python -m openultrasast.gate
```
## 3. Benchmark a known-vulnerable corpus
```bash
uv run ousast benchmark benchmarks/manifests/python-vulnerable.toml --mode quick
# -> expected / matched / missed counts + a per-rule recommendation delta
```
## 4. Self-improve the ruleset from benchmark feedback
```bash
uv run ousast improve benchmarks/manifests/java-spring-boot-vulnerable.toml --dry-run # preview
uv run ousast improve benchmarks/manifests/java-spring-boot-vulnerable.toml # apply
# Accepted edits land in <target>/.openultrasast/calibration/rule_policy.json,
# which the next scan/benchmark of that target loads automatically.
```
## 5. Standard mode with the HarnessX agentic plane (OpenAI)
```bash
uv sync --extra harnessx
export OPENAI_API_KEY=sk-...
cat > openultrasast.toml <<'TOML'
[models]
hunter = "gpt-4o"
verifier = "gpt-4o"
[harnessx]
provider = "openai"
max_cost_usd = 2.0
token_threshold = 120000
[fusion]
panel_model = "gpt-4o" # two-panel adjudication on triggered findings
[hardening]
redact_secrets = true
max_findings = 0
TOML
uv run ousast scan /path/to/code --mode standard --config openultrasast.toml
jq '.degradations' "$run/manifest.json" # null = HarnessX ran; entries = it fell back
jq '.fusion' "$run/manifest.json" # per-finding dispositions
```
## 6. Drive it from an MCP client (OpenCode / IDE)
```jsonc
{ "command": "uv", "args": ["run", "ousast", "mcp"] }
```
Then: `openultrasast.scan {path}` → `run_dir` → `openultrasast.findings {run_dir}` →
`openultrasast.explain {run_dir, finding_id}`. See the README "MCP server" section.
+66
View File
@@ -0,0 +1,66 @@
# Threat model & hardening
OpenUltraSAST analyzes **untrusted code**. This document states what it trusts, what
it does not, and the controls that keep a scan safe to run in CI and safe to share.
## Trust boundaries
- **Scanned code is untrusted input, never executed.** Quick and standard modes read
source as text (regex rules, evidence ladder, optional LLM review). They never import,
build, or run the target. Only `deep` mode would execute target-derived code, and only
inside the sandbox below — and deep mode is not yet implemented (it exits with a clear
message rather than running anything).
- **The ruleset and CWE policy are trusted, governed data.** They change only through
reviewed commits or the bounded self-improvement loop (`ousast improve`), which can
flip rule *status* and tune score constants but can never edit pattern text or the
authoritative 0–5 severity. See the self-improving cycle in the README.
- **Provider credentials are the operator's.** The zero-dependency core makes no network
calls. The optional HarnessX agentic plane sends source excerpts to the configured LLM
provider only when a model is set in `[models]`; keys are read from the provider's
standard env var (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`), never written to artifacts.
## Controls
### Secret redaction
Scan artifacts can quote source that contains live credentials. `redaction.py` masks
recognizable secret shapes (provider API keys, AWS/GitHub/Slack/Google tokens, bearer
tokens, URL-embedded credentials, PEM private keys, and `key = value` assignments for
sensitive names) before traces (`trace/events.jsonl`) and the markdown report are
written. On by default; disable with `[hardening] redact_secrets = false`.
### Cost & CI budgets
- **Agentic spend** is bounded per task by `[harnessx] max_cost_usd` and
`token_threshold` (enforced inside the HarnessX run loop) and by the per-tier hunter
budgets.
- **Output size** is bounded by `[hardening] max_findings` (0 = unlimited). Truncation is
severity-ordered and disclosed as a `budget` degradation in the manifest.
### Provider reliability
LLM/embedding calls retry transient failures (HTTP 429/5xx, connection errors, timeouts)
with exponential backoff; non-transient errors (e.g. 4xx, malformed JSON) fail fast.
### Visible degradation & determinism
When an optional capability is missing (the extra, a model, a provider key), the scan
falls back to its deterministic equivalent and records a `degradations` entry in the
manifest — it is never silently downgraded. Runs are reproducible: fixed config,
artifact manifests, prompt hashes, and model identifiers are recorded.
### MCP surface
`ousast mcp` exposes only the ten narrow project tools. No tool runs arbitrary shell,
Docker, or internal hunter tools, and no tool accepts a free-form command argument.
## Sandbox limits (deep mode, when implemented)
Dynamic analysis runs under the bounds in `[sandbox]` (`config.SandboxConfig`):
| Control | Default |
|---|---|
| network | disabled (`network = false`) |
| workspace | read-only (`workspace_readonly = true`) |
| memory | 2048 MB (`memory_mb`) |
| pids | 512 (`pids_limit`) |
| timeout | 300 s (`timeout_seconds`) |
Dynamic probes additionally declare their network scope, log commands, and capture
artifacts; a hardened runtime (e.g. gVisor) is used when available. Until deep mode
ships, no target code is executed at all.
+19 -1
View File
@@ -4,18 +4,30 @@ build-backend = "uv_build"
[project] [project]
name = "openultrasast" name = "openultrasast"
version = "0.1.0" version = "1.1.0"
description = "OpenCode-oriented SAST harness for evidence-gated security analysis" description = "OpenCode-oriented SAST harness for evidence-gated security analysis"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [] dependencies = []
[project.optional-dependencies]
# HarnessX is the optional agentic plane (LLM hunter/verifier + self-improvement).
# SHA-pinned for reproducibility; the heavy transitive footprint (see
# .kiro/specs/harnessx-self-improving-rulesets/dependency-audit.md) lands ONLY when
# this extra is installed. The core install stays zero-dependency and every HarnessX
# import is guarded by openultrasast.harness_ext (lazy). pip does not pull the
# recipe/verl_harnessX submodule (it is outside the packaged harnessx* modules).
harnessx = [
"harnessx @ git+https://github.com/Darwin-Agent/HarnessX.git@274c512eea67c64701f992d3b6fe06f0fd590ef0",
]
[project.scripts] [project.scripts]
ousast = "openultrasast.cli:main" ousast = "openultrasast.cli:main"
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["src"] pythonpath = ["src"]
markers = ["docker: requires a docker daemon"]
[tool.ruff] [tool.ruff]
line-length = 140 line-length = 140
@@ -37,6 +49,12 @@ warn_redundant_casts = true
warn_return_any = true warn_return_any = true
warn_unused_configs = true warn_unused_configs = true
# HarnessX is an optional extra, absent in the type-check environment; its lazy
# imports inside guarded functions must not fail mypy.
[[tool.mypy.overrides]]
module = "harnessx.*"
ignore_missing_imports = true
[dependency-groups] [dependency-groups]
dev = [ dev = [
"dagger-io>=0.21.7", "dagger-io>=0.21.7",
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.0" __version__ = "1.1.0"
+76 -1
View File
@@ -60,6 +60,7 @@ class BenchmarkMiss:
path: str path: str
evidence: str evidence: str
reason: str reason: str
rule_id: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -79,6 +80,7 @@ class BenchmarkCalibrationRecord:
failed_stage: str failed_stage: str
next_improvement_candidate: str next_improvement_candidate: str
reason: str reason: str
rule_id: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -87,6 +89,17 @@ class BenchmarkFalsePositive:
path: str path: str
line: int | None line: int | None
reason: str reason: str
rule_id: str = ""
@dataclass(frozen=True)
class RuleRecommendation:
rule_id: str
action: str # keep | shadow | loosen
matched: int
missed: int
false_positives: int
rationale: str
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -101,6 +114,7 @@ class BenchmarkMetrics:
runtime_seconds: float | None runtime_seconds: float | None
model_usage: dict[str, object] model_usage: dict[str, object]
artifact_links: dict[str, str] artifact_links: dict[str, str]
per_rule: dict[str, dict[str, int]]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -123,6 +137,7 @@ class BenchmarkResult:
false_positives: list[BenchmarkFalsePositive] false_positives: list[BenchmarkFalsePositive]
baseline_deltas: list[BenchmarkBaselineDelta] baseline_deltas: list[BenchmarkBaselineDelta]
calibration_records: list[BenchmarkCalibrationRecord] calibration_records: list[BenchmarkCalibrationRecord]
rule_recommendations: list[RuleRecommendation]
def load_benchmark_manifest(path: Path) -> BenchmarkManifest: def load_benchmark_manifest(path: Path) -> BenchmarkManifest:
@@ -224,6 +239,8 @@ def evaluate_benchmark(
false_positives = [_false_positive(finding) for index, finding in enumerate(findings) if index not in matched_finding_indexes] false_positives = [_false_positive(finding) for index, finding in enumerate(findings) if index not in matched_finding_indexes]
baseline_deltas = _baseline_deltas(run.manifest.expected, baseline_findings or []) baseline_deltas = _baseline_deltas(run.manifest.expected, baseline_findings or [])
calibration_records = [_calibration_record(miss) for miss in misses] calibration_records = [_calibration_record(miss) for miss in misses]
per_rule = _per_rule_metrics(findings, matched_finding_indexes, misses)
rule_recommendations = _rule_recommendations(per_rule)
expected_total = len(run.manifest.expected) expected_total = len(run.manifest.expected)
actual_total = len(findings) actual_total = len(findings)
missed_total = len(misses) missed_total = len(misses)
@@ -249,11 +266,13 @@ def evaluate_benchmark(
runtime_seconds=runtime_seconds, runtime_seconds=runtime_seconds,
model_usage={}, model_usage={},
artifact_links=_artifact_links(scan_run_dir), artifact_links=_artifact_links(scan_run_dir),
per_rule=per_rule,
), ),
misses=misses, misses=misses,
false_positives=false_positives, false_positives=false_positives,
baseline_deltas=baseline_deltas, baseline_deltas=baseline_deltas,
calibration_records=calibration_records, calibration_records=calibration_records,
rule_recommendations=rule_recommendations,
) )
@@ -266,6 +285,9 @@ def write_benchmark_artifacts(run: BenchmarkRun, manifest_path: Path, result: Be
(run.root / "external_baseline_deltas.json").write_text( (run.root / "external_baseline_deltas.json").write_text(
json.dumps(_jsonable([asdict(delta) for delta in result.baseline_deltas]), indent=2, sort_keys=True) + "\n" json.dumps(_jsonable([asdict(delta) for delta in result.baseline_deltas]), indent=2, sort_keys=True) + "\n"
) )
(run.root / "rule_policy_delta.json").write_text(
json.dumps(_jsonable([asdict(rec) for rec in result.rule_recommendations]), indent=2, sort_keys=True) + "\n"
)
def _matching_finding_index(expected: ExpectedFinding, findings: list[StaticFinding], used_indexes: set[int]) -> int | None: def _matching_finding_index(expected: ExpectedFinding, findings: list[StaticFinding], used_indexes: set[int]) -> int | None:
@@ -301,6 +323,7 @@ def _miss(expected: ExpectedFinding) -> BenchmarkMiss:
path=expected.path, path=expected.path,
evidence=expected.evidence, evidence=expected.evidence,
reason="no OpenUltraSAST finding matched the expected benchmark vulnerability", reason="no OpenUltraSAST finding matched the expected benchmark vulnerability",
rule_id=expected.rule_id,
) )
@@ -310,9 +333,57 @@ def _false_positive(finding: StaticFinding) -> BenchmarkFalsePositive:
path=finding.path, path=finding.path,
line=finding.line, line=finding.line,
reason="finding did not match any expected benchmark vulnerability", reason="finding did not match any expected benchmark vulnerability",
rule_id=_rule_id_of(finding.finding_id),
) )
def _rule_id_of(finding_id: str) -> str:
return finding_id.split(":", 1)[0]
def _per_rule_metrics(
findings: list[StaticFinding],
matched_finding_indexes: set[int],
misses: list[BenchmarkMiss],
) -> dict[str, dict[str, int]]:
per_rule: dict[str, dict[str, int]] = {}
def bucket(rule_id: str) -> dict[str, int]:
return per_rule.setdefault(rule_id, {"matched": 0, "missed": 0, "false_positives": 0})
for index, finding in enumerate(findings):
rule_id = _rule_id_of(finding.finding_id)
key = "matched" if index in matched_finding_indexes else "false_positives"
bucket(rule_id)[key] += 1
for miss in misses:
if miss.rule_id:
bucket(miss.rule_id)["missed"] += 1
return dict(sorted(per_rule.items()))
def _rule_recommendations(per_rule: dict[str, dict[str, int]]) -> list[RuleRecommendation]:
recommendations: list[RuleRecommendation] = []
for rule_id, counts in per_rule.items():
matched, missed, fps = counts["matched"], counts["missed"], counts["false_positives"]
if fps > 0:
action, rationale = "shadow", "rule produced false positives; stage it before it drags precision"
elif missed > 0:
action, rationale = "loosen", "rule missed expected findings; widen or lower its evidence floor"
else:
action, rationale = "keep", "rule matched expected findings with no false positives"
recommendations.append(
RuleRecommendation(
rule_id=rule_id,
action=action,
matched=matched,
missed=missed,
false_positives=fps,
rationale=rationale,
)
)
return recommendations
def _baseline_deltas(expected_findings: list[ExpectedFinding], baseline_findings: list[BaselineFinding]) -> list[BenchmarkBaselineDelta]: def _baseline_deltas(expected_findings: list[ExpectedFinding], baseline_findings: list[BaselineFinding]) -> list[BenchmarkBaselineDelta]:
tools = sorted({finding.tool for finding in baseline_findings}) tools = sorted({finding.tool for finding in baseline_findings})
deltas: list[BenchmarkBaselineDelta] = [] deltas: list[BenchmarkBaselineDelta] = []
@@ -359,14 +430,18 @@ def _baseline_matches_expected(expected: ExpectedFinding, finding: BaselineFindi
def _calibration_record(miss: BenchmarkMiss) -> BenchmarkCalibrationRecord: def _calibration_record(miss: BenchmarkMiss) -> BenchmarkCalibrationRecord:
# A miss tied to a known rule points the loop at that rule; an unattributed
# miss (no rule fired for the class) is a coverage gap for a language hunter.
candidate = f"rule:{miss.rule_id}" if miss.rule_id else "language_hunter"
return BenchmarkCalibrationRecord( return BenchmarkCalibrationRecord(
cwe=miss.cwe, cwe=miss.cwe,
vulnerability_class=miss.vulnerability_class, vulnerability_class=miss.vulnerability_class,
path=miss.path, path=miss.path,
evidence=miss.evidence, evidence=miss.evidence,
failed_stage="benchmark_ground_truth_matching", failed_stage="benchmark_ground_truth_matching",
next_improvement_candidate="rules_or_language_hunter", next_improvement_candidate=candidate,
reason=miss.reason, reason=miss.reason,
rule_id=miss.rule_id,
) )
+25 -2
View File
@@ -9,8 +9,7 @@ from .findings import StaticFinding
from .rank import RankingScore from .rank import RankingScore
from .verification import VerificationResult, VerificationStatus from .verification import VerificationResult, VerificationStatus
# Calibration may push a repeatedly-rejected scope below the natural ranking # Repeated rejections can push a scope below the normal 1.0 ranking floor.
# minimum (1.0) so accumulated false positives keep losing priority over runs.
MIN_CALIBRATED_PRIORITY = 0.1 MIN_CALIBRATED_PRIORITY = 0.1
@@ -250,6 +249,30 @@ def learnings_from_verifications(
return learnings return learnings
# A confirmed false positive lowers a finding's effective reachability multiplier
# by this factor in the project score (it does not delete the rule).
FP_REACHABILITY_FACTOR = 0.4
_BASE_REACH_MULT = {"reachable": 1.0, "inferred-file-surface": 0.6, "unknown": 0.4}
def fp_reachability_overrides(
findings: list[StaticFinding],
learnings: list[FalsePositiveLearning],
) -> dict[str, float]:
"""Lowered score multipliers for findings whose scope has a confirmed FP.
Keyed by ``finding_id``; consumed by the scoring model so a confirmed false
positive reduces the score penalty instead of the rule being deleted.
"""
overrides: dict[str, float] = {}
for finding in findings:
vulnerability_class = _vulnerability_class(finding)
if any(_learning_applies(finding.path, learning) and learning.vulnerability_class == vulnerability_class for learning in learnings):
base = _BASE_REACH_MULT.get(finding.reachability_status, _BASE_REACH_MULT["unknown"])
overrides[finding.finding_id] = round(base * FP_REACHABILITY_FACTOR, 3)
return overrides
def _reason_from_verification(finding: StaticFinding, status: VerificationStatus) -> FalsePositiveReason: def _reason_from_verification(finding: StaticFinding, status: VerificationStatus) -> FalsePositiveReason:
if finding.reachability_status == "unknown": if finding.reachability_status == "unknown":
return FalsePositiveReason.UNREACHABLE_PATH return FalsePositiveReason.UNREACHABLE_PATH
+406 -13
View File
@@ -2,10 +2,12 @@ from __future__ import annotations
import argparse import argparse
import json import json
from dataclasses import dataclass import tempfile
from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
from time import perf_counter from time import perf_counter
from . import tool_hunter
from .benchmark import ( from .benchmark import (
create_benchmark_run, create_benchmark_run,
evaluate_benchmark, evaluate_benchmark,
@@ -18,23 +20,38 @@ from .benchmark import (
from .calibration import ( from .calibration import (
FalsePositiveLearning, FalsePositiveLearning,
calibrate_rankings, calibrate_rankings,
fp_reachability_overrides,
learnings_from_verifications, learnings_from_verifications,
load_false_positive_learnings, load_false_positive_learnings,
merge_false_positive_learnings, merge_false_positive_learnings,
write_false_positive_learnings, write_false_positive_learnings,
write_ranking_calibrations, write_ranking_calibrations,
) )
from .complexity import map as complexity_map
from .complexity.ledger import persist_verdicts
from .config import load_config from .config import load_config
from .findings import StaticFinding, quick_scan_findings, write_findings from .findings import StaticFinding, quick_scan_findings, write_findings
from .fusion import FusionDecision, fuse_findings_dispatch
from .gate import FALSE_POSITIVE_CEILING, RECALL_FLOOR
from .harness import HarnessRuntime, HarnessTraceWriter, write_harness_config from .harness import HarnessRuntime, HarnessTraceWriter, write_harness_config
from .harness_ext import has_harnessx
from .hunter import run_hunter_pool, write_hunter_trajectories from .hunter import run_hunter_pool, write_hunter_trajectories
from .hunter_harness import HxScanOrchestrator
from .improve import RoundOutcome, run_improvement
from .index import build_code_chunks from .index import build_code_chunks
from .mapping import analyze_entry_points, attach_reachability_hints, ingest_sarif, write_entry_points, write_static_hints from .mapping import analyze_entry_points, attach_reachability_hints, ingest_sarif, write_entry_points, write_static_hints
from .policy import assert_rules_resolve, load_policy
from .preprocess import preprocess_repository, write_preprocess_artifact from .preprocess import preprocess_repository, write_preprocess_artifact
from .rank import rank_targets, write_rankings from .rank import rank_targets, write_rankings
from .regress import CandidateVerdict, run_regression, write_verdicts
from .reports import scan_exit_code, write_manifest, write_markdown_report, write_sarif_report from .reports import scan_exit_code, write_manifest, write_markdown_report, write_sarif_report
from .ruleset import DEFAULT_RULESET_DIR, load_ruleset
from .run import ScanRun, create_scan_run from .run import ScanRun, create_scan_run
from .sandbox import resolve_sandbox_probe, resolve_sandbox_runner
from .scoring import build_score_artifact
from .stages import Stage, plan_for_mode, record_completed, record_skip, skip_as_degradation, stages_payload
from .verification import VerificationResult, verify_findings, write_verification_results from .verification import VerificationResult, verify_findings, write_verification_results
from .verify_judge import verify_findings_dispatch
CALIBRATION_DIR = ".openultrasast/calibration" CALIBRATION_DIR = ".openultrasast/calibration"
@@ -58,7 +75,7 @@ def main(argv: list[str] | None = None) -> int:
scan.add_argument("path", type=Path) scan.add_argument("path", type=Path)
scan.add_argument("--mode", choices=("quick", "standard", "deep"), default="quick") scan.add_argument("--mode", choices=("quick", "standard", "deep"), default="quick")
scan.add_argument("--config", type=Path, default=Path("openultrasast.toml")) scan.add_argument("--config", type=Path, default=Path("openultrasast.toml"))
scan.add_argument("--fail-on", choices=("never", "findings", "verified"), default="never") scan.add_argument("--fail-on", choices=("never", "findings", "verified", "worth-fixing"), default="never")
index = subparsers.add_parser("index", help="chunk a local repository for embedding index construction") index = subparsers.add_parser("index", help="chunk a local repository for embedding index construction")
index.add_argument("path", type=Path) index.add_argument("path", type=Path)
@@ -70,6 +87,25 @@ def main(argv: list[str] | None = None) -> int:
benchmark.add_argument("--mode", choices=("quick", "standard", "deep"), default="quick") benchmark.add_argument("--mode", choices=("quick", "standard", "deep"), default="quick")
benchmark.add_argument("--config", type=Path, default=Path("openultrasast.toml")) benchmark.add_argument("--config", type=Path, default=Path("openultrasast.toml"))
improve = subparsers.add_parser(
"improve",
help="run the bounded self-improvement loop against a benchmark manifest and update the loop-owned ruleset ledger",
)
improve.add_argument("manifest", type=Path)
improve.add_argument("--max-rounds", type=int, default=5)
improve.add_argument("--recall-floor", type=float, default=RECALL_FLOOR)
improve.add_argument("--fp-ceiling", type=float, default=FALSE_POSITIVE_CEILING)
improve.add_argument(
"--ledger", type=Path, default=None, help="ruleset ledger path (default: <target>/.openultrasast/calibration/rule_policy.json)"
)
improve.add_argument("--journal", type=Path, default=None, help="improvement journal path (default: alongside the ledger)")
improve.add_argument(
"--ruleset-dir", type=Path, default=DEFAULT_RULESET_DIR, help="ruleset directory to improve (default: the bundled ruleset)"
)
improve.add_argument("--dry-run", action="store_true", help="run rounds against a throwaway ledger; never touch the target's ledger")
subparsers.add_parser("mcp", help="run the narrow MCP server over stdio for OpenCode integration")
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.command == "scan": if args.command == "scan":
return _scan(args.path, args.config, args.mode, args.fail_on) return _scan(args.path, args.config, args.mode, args.fail_on)
@@ -77,6 +113,21 @@ def main(argv: list[str] | None = None) -> int:
return _index(args.path, args.config, args.chunk_lines) return _index(args.path, args.config, args.chunk_lines)
if args.command == "benchmark": if args.command == "benchmark":
return _benchmark(args.manifest, args.config, args.mode) return _benchmark(args.manifest, args.config, args.mode)
if args.command == "improve":
return _improve(
args.manifest,
max_rounds=args.max_rounds,
recall_floor=args.recall_floor,
fp_ceiling=args.fp_ceiling,
ledger=args.ledger,
journal=args.journal,
ruleset_dir=args.ruleset_dir,
dry_run=args.dry_run,
)
if args.command == "mcp":
from .mcp import serve # lazy: keeps the import cycle (mcp -> cli) one-directional
return serve()
return 2 return 2
@@ -87,20 +138,31 @@ def _scan(path: Path, config_path: Path, mode: str, fail_on: str) -> int:
def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOutcome: def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOutcome:
if mode == "deep":
raise SystemExit("mode 'deep' is specified but sandboxed dynamic analysis is not implemented")
if not path.exists() or not path.is_dir(): if not path.exists() or not path.is_dir():
raise SystemExit(f"scan path is not a directory: {path}") raise SystemExit(f"scan path is not a directory: {path}")
plan = plan_for_mode(mode)
config = load_config(config_path if config_path.exists() else None) config = load_config(config_path if config_path.exists() else None)
# Capability gates for the optional HarnessX agentic plane. When the extra is
# absent (default/CI), both stay False and the deterministic path runs unchanged.
hunter_model = config.models.hunter
verifier_model = config.models.verifier
harnessx_present = has_harnessx()
hx_hunter = mode == "standard" and bool(hunter_model) and harnessx_present
hx_verify = mode == "standard" and bool(verifier_model) and harnessx_present
run = create_scan_run(path, config) run = create_scan_run(path, config)
runtime = HarnessRuntime( runtime = HarnessRuntime(
scan_id=run.scan_id, scan_id=run.scan_id,
config=config, config=config,
trace_writer=HarnessTraceWriter(run.root / "trace" / "events.jsonl"), trace_writer=HarnessTraceWriter(run.root / "trace" / "events.jsonl", redact=config.hardening.redact_secrets),
) )
write_harness_config(config=config, processors=[], contract_mode="strict", path=run.root / "harness.json") write_harness_config(config=config, processors=[], contract_mode="strict", path=run.root / "harness.json")
runtime.start(mode=mode, target=run.target) runtime.start(mode=mode, target=run.target)
policy = runtime.run_stage("policy_load", load_policy)
ledger_path = run.target / CALIBRATION_DIR / "false_positive_learnings.json"
rule_ledger_path = run.target / CALIBRATION_DIR / "rule_policy.json"
ruleset = runtime.run_stage("ruleset_load", lambda: load_ruleset(DEFAULT_RULESET_DIR, rule_ledger_path))
runtime.run_stage("policy_check", lambda: assert_rules_resolve(ruleset, policy))
static_hints = runtime.run_stage("static_mapping", lambda: _load_static_hints(config.static_analysis.sarif_paths)) static_hints = runtime.run_stage("static_mapping", lambda: _load_static_hints(config.static_analysis.sarif_paths))
write_static_hints(static_hints, run.root / "mapping" / "static_hints.json") write_static_hints(static_hints, run.root / "mapping" / "static_hints.json")
snapshot, targets = runtime.run_stage( snapshot, targets = runtime.run_stage(
@@ -112,19 +174,64 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
targets = attach_reachability_hints(targets, entry_points) targets = attach_reachability_hints(targets, entry_points)
write_preprocess_artifact(snapshot, targets, run.root / "preprocess" / "file_targets.json") write_preprocess_artifact(snapshot, targets, run.root / "preprocess" / "file_targets.json")
rankings = runtime.run_stage("rank", lambda: rank_targets(targets)) rankings = runtime.run_stage("rank", lambda: rank_targets(targets))
ledger_path = run.target / CALIBRATION_DIR / "false_positive_learnings.json"
prior_learnings = load_false_positive_learnings(ledger_path) prior_learnings = load_false_positive_learnings(ledger_path)
rankings, calibrations = runtime.run_stage("calibrate", lambda: calibrate_rankings(rankings, prior_learnings)) rankings, calibrations = runtime.run_stage("calibrate", lambda: calibrate_rankings(rankings, prior_learnings))
applied_calibrations = [calibration for calibration in calibrations if calibration.applied_learning_ids] applied_calibrations = [calibration for calibration in calibrations if calibration.applied_learning_ids]
write_rankings(rankings, run.root / "rank" / "ranking.json") write_rankings(rankings, run.root / "rank" / "ranking.json")
write_ranking_calibrations(calibrations, run.root / "calibration" / "applied_calibrations.json") write_ranking_calibrations(calibrations, run.root / "calibration" / "applied_calibrations.json")
if mode == "standard": if mode == "standard":
hunter_result = runtime.run_stage("hunter_pool", lambda: run_hunter_pool(run.target, targets, rankings, scan_id=run.scan_id)) if hx_hunter and hunter_model:
orchestrator = HxScanOrchestrator(
provider_model=hunter_model,
provider=config.harnessx.provider,
max_cost_usd=config.harnessx.max_cost_usd,
token_threshold=config.harnessx.token_threshold,
)
hunter_result = runtime.run_stage(
"hunter_pool",
lambda: orchestrator.run_pool(
run.target, targets, rankings, scan_id=run.scan_id, ruleset=ruleset, policy=policy, emit=runtime.emit
),
)
else:
if bool(hunter_model) and not harnessx_present:
runtime.state["degradations"].append(
{"stage": "hunter_pool", "requested": "harnessx", "reason": "harnessx_extra_unavailable", "fallback": "run_hunter_pool"}
)
hunter_result = runtime.run_stage(
"hunter_pool",
lambda: run_hunter_pool(run.target, targets, rankings, scan_id=run.scan_id, ruleset=ruleset, policy=policy),
)
findings = hunter_result.findings findings = hunter_result.findings
trajectories = hunter_result.trajectories trajectories = hunter_result.trajectories
else: else:
findings = runtime.run_stage("quick_findings", lambda: quick_scan_findings(run.target, targets, rankings)) findings = runtime.run_stage(
"quick_findings",
lambda: quick_scan_findings(
run.target,
targets,
rankings,
ruleset,
policy,
min_emit_priority=config.ruleset.min_emit_priority,
min_emit_precision=config.ruleset.min_emit_precision,
),
)
trajectories = [] trajectories = []
# Shadow-status rules fire but are excluded from the report and the score;
# their outcomes are preserved separately for precision tracking.
shadow_findings = [finding for finding in findings if finding.status == "shadow"]
if shadow_findings:
findings = [finding for finding in findings if finding.status != "shadow"]
write_findings(shadow_findings, run.root / "shadow_findings.json")
# Bounded-CI budget: cap the reported finding count (0 = unlimited). Findings are
# severity/priority-sorted, so truncation keeps the most severe and is disclosed.
max_findings = config.hardening.max_findings
if max_findings and len(findings) > max_findings:
runtime.state["degradations"].append(
{"stage": "budget", "reason": "max_findings_exceeded", "requested": max_findings, "actual": len(findings)}
)
findings = findings[:max_findings]
findings_path = run.root / "findings.json" findings_path = run.root / "findings.json"
verification_path = run.root / "verification.json" verification_path = run.root / "verification.json"
markdown_path = run.root / "report.md" markdown_path = run.root / "report.md"
@@ -134,13 +241,151 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
write_findings(findings, findings_path) write_findings(findings, findings_path)
if trajectories: if trajectories:
write_hunter_trajectories(trajectories, trajectories_path) write_hunter_trajectories(trajectories, trajectories_path)
verifications = runtime.run_stage("verify", lambda: verify_findings(findings)) if mode == "standard" and bool(verifier_model) and not harnessx_present:
runtime.state["degradations"].append(
{"stage": "verify", "requested": "harnessx", "reason": "harnessx_extra_unavailable", "fallback": "structural_verifier"}
)
verifications = runtime.run_stage(
"verify",
lambda: verify_findings_dispatch(
findings,
verifier_model=verifier_model,
verifier_provider=config.harnessx.provider,
use_harnessx=hx_verify,
),
)
write_verification_results(verifications, verification_path) write_verification_results(verifications, verification_path)
runtime.run_stage( runtime.run_stage(
"record_calibration", "record_calibration",
lambda: _persist_calibration_feedback(run, ledger_path, prior_learnings, findings, verifications), lambda: _persist_calibration_feedback(run, ledger_path, prior_learnings, findings, verifications),
) )
runtime.run_stage("report", lambda: write_markdown_report(findings, markdown_path, verifications)) # Fusion: two-panel adjudication for triggered findings (standard mode). Runs
# deterministically by default; routes panels through the configured provider when
# a panel model is set and the extra is present, else falls back + records a degradation.
fusion_decisions: list[FusionDecision] = []
fusion_path = run.root / "fusion.json"
if mode == "standard" and config.fusion.enabled:
hx_fusion = bool(config.fusion.panel_model) and harnessx_present
if bool(config.fusion.panel_model) and not harnessx_present:
runtime.state["degradations"].append(
{"stage": "fusion", "requested": "harnessx", "reason": "harnessx_extra_unavailable", "fallback": "deterministic_panels"}
)
fusion_decisions = runtime.run_stage(
"fusion",
lambda: fuse_findings_dispatch(
findings,
verifications,
panel_model=config.fusion.panel_model,
decider_model=config.fusion.decider_model,
provider=config.harnessx.provider,
use_harnessx=hx_fusion,
high_assurance=config.fusion.high_assurance,
),
)
if fusion_decisions:
fusion_path.write_text(json.dumps([decision.to_dict() for decision in fusion_decisions], indent=2, sort_keys=True) + "\n")
cwe_by_rule = {rule.rule_id: rule.cwe for rule in ruleset}
rule_cwe_by_id = {finding.finding_id: cwe_by_rule.get(finding.finding_id.split(":", 1)[0], "") for finding in findings}
# A confirmed false positive (prior-scan learning) lowers a finding's effective
# reachability multiplier in the score instead of deleting the rule.
reachability_override = fp_reachability_overrides(findings, prior_learnings)
score_artifact = runtime.run_stage(
"score",
lambda: build_score_artifact(
findings,
rule_cwe_by_id,
policy,
k=config.score.k,
min_score=config.score.min_score,
block_severity_reachable=config.score.block_severity_reachable,
blocking=config.score.blocking,
reachability_override=reachability_override,
),
)
score_path = run.root / "score.json"
score_path.write_text(json.dumps(score_artifact.to_dict(), indent=2, sort_keys=True) + "\n")
plan = record_completed(plan, Stage.STATIC)
complexity_payload: dict[str, object] | None = None
complexity_map_path = run.root / "complexity_map.json"
built_map = None
if Stage.MAP in plan.requested:
built_map = runtime.run_stage(
"map",
lambda: complexity_map.build_complexity_map(
targets,
findings,
complexity_map_path,
repo_files=[target.path for target in targets],
ledger_path=run.target / CALIBRATION_DIR / "complexity_ledger.json",
),
)
plan = record_completed(plan, Stage.MAP)
if not hunter_model:
runtime.state["degradations"].append(skip_as_degradation(Stage.MAP, "hunter_model_unavailable"))
else:
built_map, findings, verifications = _attach_tool_hunter(
root=run.target,
built_map=built_map,
findings=findings,
verifications=verifications,
hunter_model=hunter_model,
max_hotspots=config.complexity.max_hunter_hotspots,
max_findings=max_findings,
findings_path=findings_path,
verification_path=verification_path,
complexity_map_path=complexity_map_path,
runtime=runtime,
)
complexity_payload = {
"hotspot_count": len(built_map.hotspots),
"heuristic_only": built_map.heuristic_only,
}
verdicts_path = run.root / "verdicts.json"
wrote_verdicts = False
verdict_records: tuple[CandidateVerdict, ...] = ()
if Stage.REGRESS in plan.requested:
probe = resolve_sandbox_probe()
if not probe.available():
plan = record_skip(plan, Stage.REGRESS, "sandbox_unavailable")
runtime.state["degradations"].append(skip_as_degradation(Stage.REGRESS, "sandbox_unavailable"))
else:
sandbox = resolve_sandbox_runner()
hotspots = built_map.hotspots if built_map is not None else ()
cwe_by_rule_id = {rule.rule_id: rule.cwe for rule in ruleset}
rule_cwe_by_finding = {finding.finding_id: cwe_by_rule_id.get(finding.finding_id.split(":", 1)[0], "") for finding in findings}
languages_by_path = {target.path: target.language for target in targets}
records = runtime.run_stage(
"regress",
lambda: run_regression(
hotspots,
findings,
max_candidates=config.regress.max_candidates,
policy=policy,
rule_cwe=rule_cwe_by_finding,
languages_by_path=languages_by_path,
repo_root=run.target,
sandbox=sandbox,
sandbox_limits=config.sandbox,
images=dict(config.regress.images),
),
)
write_verdicts(records, verdicts_path)
persist_verdicts(run.target / CALIBRATION_DIR / "complexity_ledger.json", records)
verdict_records = records
wrote_verdicts = True
plan = record_completed(plan, Stage.REGRESS)
worth_fixing_payload = _worth_fixing_payload(verdict_records) if wrote_verdicts else None
runtime.run_stage(
"report",
lambda: write_markdown_report(
findings,
markdown_path,
verifications,
redact=config.hardening.redact_secrets,
complexity_map=built_map,
verdicts=verdict_records if wrote_verdicts else None,
),
)
runtime.run_stage("sarif", lambda: write_sarif_report(findings, verifications, sarif_path)) runtime.run_stage("sarif", lambda: write_sarif_report(findings, verifications, sarif_path))
runtime.run_stage( runtime.run_stage(
"manifest", "manifest",
@@ -153,9 +398,19 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
verification=verification_path, verification=verification_path,
markdown=markdown_path, markdown=markdown_path,
sarif=sarif_path, sarif=sarif_path,
score=score_path,
trajectories=trajectories_path if trajectories else None, trajectories=trajectories_path if trajectories else None,
fusion=fusion_path if fusion_decisions else None,
complexity_map=complexity_map_path if complexity_payload is not None else None,
verdicts=verdicts_path if wrote_verdicts else None,
), ),
path=manifest_path, path=manifest_path,
score=score_artifact.to_dict(),
degradations=runtime.state["degradations"] or None,
fusion=[_fusion_summary(decision) for decision in fusion_decisions] or None,
stages=stages_payload(plan),
complexity=complexity_payload,
worth_fixing=worth_fixing_payload,
), ),
) )
runtime.finish(status="succeeded") runtime.finish(status="succeeded")
@@ -166,10 +421,59 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
ranked_target_count=len(rankings), ranked_target_count=len(rankings),
finding_count=len(findings), finding_count=len(findings),
calibrations_applied=len(applied_calibrations), calibrations_applied=len(applied_calibrations),
exit_code=scan_exit_code(findings, verifications, fail_on), exit_code=scan_exit_code(findings, verifications, fail_on, worth_fixing_verdicts=verdict_records),
) )
def _attach_tool_hunter(
*,
root: Path,
built_map: complexity_map.ComplexityMap,
findings: list[StaticFinding],
verifications: list[VerificationResult],
hunter_model: str,
max_hotspots: int,
max_findings: int,
findings_path: Path,
verification_path: Path,
complexity_map_path: Path,
runtime: HarnessRuntime,
) -> tuple[complexity_map.ComplexityMap, list[StaticFinding], list[VerificationResult]]:
client = tool_hunter.resolve_hunter_client()
if client is None:
return built_map, findings, verifications
hunter_hotspots = list(built_map.hotspots[: max(max_hotspots, 0)])
hunter_findings = runtime.run_stage(
"tool_hunter",
lambda: tool_hunter.run_tool_hunter(
root,
hunter_hotspots,
client=client,
model=hunter_model,
max_steps=tool_hunter.DEFAULT_MAX_STEPS,
),
)
seen = {finding.finding_id for finding in findings}
extra = [finding for finding in hunter_findings if finding.finding_id not in seen]
if extra:
findings = findings + extra
if max_findings and len(findings) > max_findings:
if not any(entry.get("reason") == "max_findings_exceeded" for entry in runtime.state["degradations"]):
runtime.state["degradations"].append(
{"stage": "budget", "reason": "max_findings_exceeded", "requested": max_findings, "actual": len(findings)}
)
findings = findings[:max_findings]
kept = {finding.finding_id for finding in findings}
extra = [finding for finding in extra if finding.finding_id in kept]
if extra:
verifications = verifications + verify_findings(extra)
write_verification_results(verifications, verification_path)
write_findings(findings, findings_path)
built_map = replace(built_map, heuristic_only=False)
complexity_map.write_complexity_map(built_map, complexity_map_path)
return built_map, findings, verifications
def _persist_calibration_feedback( def _persist_calibration_feedback(
run: ScanRun, run: ScanRun,
ledger_path: Path, ledger_path: Path,
@@ -177,8 +481,7 @@ def _persist_calibration_feedback(
findings: list[StaticFinding], findings: list[StaticFinding],
verifications: list[VerificationResult], verifications: list[VerificationResult],
) -> list[FalsePositiveLearning]: ) -> list[FalsePositiveLearning]:
# Turn this run's non-accepted verifier outcomes into scoped learnings and # Store rejected or held findings as scoped learnings for the next scan.
# merge them into the persistent ledger so the next scan demotes those scopes.
new_learnings = learnings_from_verifications(findings, verifications) new_learnings = learnings_from_verifications(findings, verifications)
merged = merge_false_positive_learnings(prior_learnings, new_learnings) merged = merge_false_positive_learnings(prior_learnings, new_learnings)
write_false_positive_learnings(merged, ledger_path) write_false_positive_learnings(merged, ledger_path)
@@ -204,10 +507,36 @@ def _load_static_hints(sarif_paths: tuple[str, ...]) -> list[object]:
return hints return hints
def _worth_fixing_payload(verdicts: tuple[CandidateVerdict, ...]) -> dict[str, object]:
worth = [record for record in verdicts if record.worth_fixing]
return {
"count": len(worth),
"verdicts": [
{
"path": record.path,
"function_name": record.function_name,
"verdict": record.verdict,
"reason": record.reason,
"worth_fixing": True,
}
for record in worth
],
}
def _artifact_paths(**paths: Path | None) -> dict[str, Path]: def _artifact_paths(**paths: Path | None) -> dict[str, Path]:
return {name: path for name, path in paths.items() if path is not None} return {name: path for name, path in paths.items() if path is not None}
def _fusion_summary(decision: FusionDecision) -> dict[str, object]:
return {
"finding_id": decision.finding_id,
"disposition": str(decision.disposition),
"decision_source": decision.decision_source,
"triggers": decision.triggers,
}
def _benchmark(manifest_path: Path, config_path: Path, mode: str) -> int: def _benchmark(manifest_path: Path, config_path: Path, mode: str) -> int:
if not manifest_path.exists() or not manifest_path.is_file(): if not manifest_path.exists() or not manifest_path.is_file():
raise SystemExit(f"benchmark manifest is not a file: {manifest_path}") raise SystemExit(f"benchmark manifest is not a file: {manifest_path}")
@@ -238,6 +567,70 @@ def _benchmark(manifest_path: Path, config_path: Path, mode: str) -> int:
return 0 return 0
def _improve(
manifest_path: Path,
*,
max_rounds: int,
recall_floor: float,
fp_ceiling: float,
ledger: Path | None,
journal: Path | None,
ruleset_dir: Path,
dry_run: bool,
) -> int:
if not manifest_path.exists() or not manifest_path.is_file():
raise SystemExit(f"benchmark manifest is not a file: {manifest_path}")
manifest = load_benchmark_manifest(manifest_path)
target = resolve_benchmark_source(manifest_path, manifest)
policy = load_policy()
# The loop writes its accepted ledger where `scan`/`benchmark` read it, so a
# subsequent scan of this target automatically picks up the improved ruleset.
default_ledger = target / CALIBRATION_DIR / "rule_policy.json"
with tempfile.TemporaryDirectory(prefix="ousast-improve-") as scratch:
if dry_run:
ledger_path = Path(scratch) / "rule_policy.json"
journal_path = Path(scratch) / "improve_journal.json"
else:
ledger_path = ledger or default_ledger
journal_path = journal or ledger_path.with_name("improve_journal.json")
outcomes = run_improvement(
target,
manifest,
ledger_path=ledger_path,
journal_path=journal_path,
ruleset_dir=ruleset_dir,
policy=policy,
max_rounds=max_rounds,
recall_floor=recall_floor,
fp_ceiling=fp_ceiling,
)
_print_improve_outcomes(outcomes, manifest_path, target, ledger_path, dry_run=dry_run)
return 0
def _print_improve_outcomes(outcomes: list[RoundOutcome], manifest_path: Path, target: Path, ledger_path: Path, *, dry_run: bool) -> None:
print(f"manifest={manifest_path}")
print(f"target={target}")
accepted = [o for o in outcomes if o.accepted]
for outcome in outcomes:
edits = ", ".join(f"{e.rule_id}:{e.from_status}->{e.to_status}" for e in outcome.edits) or "-"
print(
f"round {outcome.round}: {outcome.reason} | "
f"recall {outcome.recall_before:.2%}->{outcome.recall_after:.2%} "
f"fp {outcome.fp_before:.2%}->{outcome.fp_after:.2%} "
f"score {outcome.score_before}->{outcome.score_after} | edits: {edits}"
)
print(f"rounds={len(outcomes)} accepted={len(accepted)}")
if dry_run:
print("dry_run=true (no ledger written)")
elif accepted:
print(f"ledger={ledger_path}")
else:
print("ledger=unchanged (no round accepted)")
def _load_scan_findings(run_dir: Path) -> list[StaticFinding]: def _load_scan_findings(run_dir: Path) -> list[StaticFinding]:
return load_findings(run_dir / "findings.json") return load_findings(run_dir / "findings.json")
+36
View File
@@ -0,0 +1,36 @@
"""Heuristic complexity map for stage 2."""
from .hints import TestHint, attach_test_hints
from .ledger import (
LedgerEntry,
apply_overlay,
hotspot_key,
load_ledger,
must_keep_as_candidate,
persist_verdicts,
record_verdict,
select_forced_candidates,
write_ledger,
)
from .map import ComplexityMap, Hotspot, build_complexity_map, write_complexity_map
from .signals import ComplexitySignals, collect_signals
__all__ = [
"ComplexityMap",
"ComplexitySignals",
"Hotspot",
"LedgerEntry",
"TestHint",
"apply_overlay",
"attach_test_hints",
"build_complexity_map",
"collect_signals",
"hotspot_key",
"load_ledger",
"must_keep_as_candidate",
"persist_verdicts",
"record_verdict",
"select_forced_candidates",
"write_complexity_map",
"write_ledger",
]
+220
View File
@@ -0,0 +1,220 @@
from __future__ import annotations
import re
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, replace
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING
from ..preprocess import FileTarget
from .signals import _is_test_filename
if TYPE_CHECKING:
from .map import Hotspot
GAP_NO_ADJACENT_TEST = "no_adjacent_test"
GAP_NO_FUNCTION_REFERENCE = "no_function_reference"
GAP_NO_FUZZ_ENTRY = "no_fuzz_entry"
GAP_COVERED = "covered"
GAPS = frozenset({GAP_NO_ADJACENT_TEST, GAP_NO_FUNCTION_REFERENCE, GAP_NO_FUZZ_ENTRY, GAP_COVERED})
KIND_UNIT = "unit"
KIND_PROPERTY = "property"
KIND_SANITIZER = "sanitizer"
KIND_HTTP_CONTRACT = "http-contract"
KIND_FUZZ_HARNESS = "fuzz-harness"
TEST_KINDS = frozenset({KIND_UNIT, KIND_PROPERTY, KIND_SANITIZER, KIND_HTTP_CONTRACT, KIND_FUZZ_HARNESS})
_MEMORY_UNSAFE_LANGUAGES = frozenset({"c", "cpp"})
@dataclass(frozen=True)
class TestHint:
path: str
function_name: str | None
gap: str # no_adjacent_test | no_function_reference | no_fuzz_entry | covered
test_kind: str | None
reason: str
__test__ = False
def __post_init__(self) -> None:
if self.gap not in GAPS:
raise ValueError(f"unknown test gap: {self.gap}")
if self.test_kind is not None and self.test_kind not in TEST_KINDS:
raise ValueError(f"unknown test kind: {self.test_kind}")
if self.gap == GAP_COVERED:
if self.test_kind is not None:
raise ValueError("covered gap must omit test_kind")
elif self.test_kind is None:
raise ValueError(f"{self.gap} gap requires a test_kind")
def attach_test_hints(
hotspots: Sequence[Hotspot],
targets: Sequence[FileTarget] = (),
*,
repo_files: Iterable[str] = (),
sources: Mapping[str, str] | None = None,
) -> tuple[Hotspot, ...]:
targets_by_path = {target.path: target for target in targets}
repo_paths = {str(path).replace("\\", "/") for path in repo_files}
repo_root = _repo_root(targets)
test_files = tuple(path for path in repo_paths if _is_test_filename(PurePosixPath(path).name))
advised: list[Hotspot] = []
for hotspot in hotspots:
target = targets_by_path.get(hotspot.path)
hint = advise_test_hint(
path=hotspot.path,
function_name=hotspot.function_name,
has_adjacent_test=bool(hotspot.signals.get("has_adjacent_test")),
language=_language(target, hotspot.path),
tags=_tags(target, hotspot.signals),
has_fuzz_entry_point=_has_fuzz_harness(target, hotspot.path, sources, repo_root),
function_referenced=_function_referenced(hotspot.function_name, test_files, sources, repo_root),
)
advised.append(replace(hotspot, test_hint=hint))
return tuple(advised)
def advise_test_hint(
*,
path: str,
function_name: str | None,
has_adjacent_test: bool,
language: str,
tags: Sequence[str],
has_fuzz_entry_point: bool,
function_referenced: bool,
) -> TestHint:
tag_set = tuple(tags)
if _needs_fuzz_or_sanitizer(language, tag_set) and not has_fuzz_entry_point:
gap, test_kind = GAP_NO_FUZZ_ENTRY, (KIND_FUZZ_HARNESS if "parser" in tag_set else KIND_SANITIZER)
elif not has_adjacent_test:
gap, test_kind = GAP_NO_ADJACENT_TEST, _kind_for_surface(tag_set)
elif function_name and not function_referenced:
gap, test_kind = GAP_NO_FUNCTION_REFERENCE, _kind_for_surface(tag_set)
else:
gap, test_kind = GAP_COVERED, None
return TestHint(
path=path,
function_name=function_name,
gap=gap,
test_kind=test_kind,
reason=_reason(
gap,
test_kind,
path=path,
function_name=function_name,
language=language,
tags=tag_set,
has_adjacent_test=has_adjacent_test,
),
)
def _needs_fuzz_or_sanitizer(language: str, tags: Sequence[str]) -> bool:
if language not in _MEMORY_UNSAFE_LANGUAGES:
return False
return "parser" in tags or "memory_unsafe" in tags
def _kind_for_surface(tags: Sequence[str]) -> str:
if "network_entry" in tags:
return KIND_HTTP_CONTRACT
if "parser" in tags or "deserialization" in tags:
return KIND_PROPERTY
return KIND_UNIT
def _reason(
gap: str,
test_kind: str | None,
*,
path: str,
function_name: str | None,
language: str,
tags: Sequence[str],
has_adjacent_test: bool,
) -> str:
identity = f"{path}::{function_name}" if function_name else path
tag_text = ",".join(tags) or "none"
if gap == GAP_COVERED:
return f"covering tests already exist for {identity}; has_adjacent_test=true."
if gap == GAP_NO_FUZZ_ENTRY:
return f"{language} tags={tag_text} surface {identity} has no sanitizer or fuzz harness; recommend {test_kind}."
if gap == GAP_NO_FUNCTION_REFERENCE:
return f"adjacent tests do not reference {function_name}; recommend {test_kind}."
return f"no adjacent test file for {identity}; has_adjacent_test={str(has_adjacent_test).lower()}; recommend {test_kind}."
def _language(target: FileTarget | None, path: str) -> str:
if target is not None:
return target.language
suffix = PurePosixPath(path).suffix.lower()
if suffix in {".c", ".h"}:
return "c"
if suffix in {".cc", ".cpp", ".cxx", ".hpp"}:
return "cpp"
return "unknown"
def _tags(target: FileTarget | None, signals: Mapping[str, float | int | bool | str]) -> tuple[str, ...]:
if target is not None:
return tuple(target.tags)
raw = signals.get("tags", "")
if not isinstance(raw, str) or not raw:
return ()
return tuple(tag for tag in raw.split(",") if tag)
def _has_fuzz_harness(
target: FileTarget | None,
path: str,
sources: Mapping[str, str] | None,
repo_root: Path | None,
) -> bool:
if target is not None and target.has_fuzz_entry_point:
return True
return "LLVMFuzzerTestOneInput" in _lookup_source(path, sources, repo_root)
def _function_referenced(
function_name: str | None,
test_files: Sequence[str],
sources: Mapping[str, str] | None,
repo_root: Path | None,
) -> bool:
if not function_name:
return True
pattern = re.compile(rf"\b{re.escape(function_name)}\b")
return any(pattern.search(_lookup_source(path, sources, repo_root)) for path in test_files)
def _lookup_source(path: str, sources: Mapping[str, str] | None, repo_root: Path | None) -> str:
normalized = path.replace("\\", "/")
if sources:
if normalized in sources:
return sources[normalized]
if path in sources:
return sources[path]
if repo_root is None:
return ""
candidate = repo_root / normalized
if not candidate.is_file():
return ""
try:
return candidate.read_text(errors="ignore")
except OSError:
return ""
def _repo_root(targets: Sequence[FileTarget]) -> Path | None:
for target in targets:
absolute = Path(target.absolute_path).as_posix()
relative = target.path.replace("\\", "/")
if not relative or not absolute.endswith(relative):
continue
root = Path(absolute[: -len(relative)].rstrip("/"))
if root.exists():
return root
return None
+196
View File
@@ -0,0 +1,196 @@
"""Loop-owned complexity ledger: overlay hotspot scores without hiding sev-5 inventory."""
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from typing import TYPE_CHECKING
from ..findings import StaticFinding
if TYPE_CHECKING:
from .map import Hotspot
TRIGGERABLE_DELTA = 1.5
NOT_TRIGGERABLE_DELTA = -1.5
VERDICT_DELTAS = {
"triggerable": TRIGGERABLE_DELTA,
"not_triggerable": NOT_TRIGGERABLE_DELTA,
}
FORCED_CANDIDATE_SEVERITY = 5
FORCED_CANDIDATE_REACHABILITY = frozenset({"reachable", "inferred-file-surface"})
@dataclass(frozen=True)
class LedgerEntry:
score_delta: float
last_verdict: str
round: int = 0
def hotspot_key(path: str, function_name: str | None) -> str:
return f"{path}:{function_name or ''}"
def load_ledger(path: Path | None) -> dict[str, LedgerEntry]:
if path is None or not path.exists():
return {}
payload = json.loads(path.read_text())
entries: dict[str, LedgerEntry] = {}
for key, value in payload.items():
entry = _coerce_entry(value)
if entry is not None:
entries[str(key)] = entry
return entries
def write_ledger(path: Path, ledger: Mapping[str, LedgerEntry | Mapping[str, object]]) -> None:
payload: dict[str, dict[str, object]] = {}
for key, value in ledger.items():
entry = _coerce_entry(value)
if entry is None:
continue
payload[str(key)] = asdict(entry)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
def persist_verdicts(path: Path, verdicts: Sequence[object]) -> dict[str, LedgerEntry]:
"""Record triggerable / not_triggerable outcomes into the loop-owned complexity ledger."""
ledger = load_ledger(path)
changed = False
for item in verdicts:
verdict = str(getattr(item, "verdict", ""))
if verdict not in VERDICT_DELTAS:
continue
raw_function = getattr(item, "function_name", None)
ledger = record_verdict(
ledger,
path=str(getattr(item, "path", "")),
function_name=raw_function if isinstance(raw_function, str) else None,
verdict=verdict,
)
changed = True
if changed:
write_ledger(path, ledger)
return ledger
def record_verdict(
ledger: Mapping[str, LedgerEntry | Mapping[str, object]],
*,
path: str,
function_name: str | None,
verdict: str,
) -> dict[str, LedgerEntry]:
updated: dict[str, LedgerEntry] = {}
for key, value in ledger.items():
entry = _coerce_entry(value)
if entry is not None:
updated[str(key)] = entry
key = hotspot_key(path, function_name)
previous = updated.get(key)
updated[key] = LedgerEntry(
score_delta=round((previous.score_delta if previous else 0.0) + VERDICT_DELTAS.get(verdict, 0.0), 4),
last_verdict=verdict,
round=(previous.round if previous else 0) + 1,
)
return updated
def apply_overlay(
hotspots: Sequence[Hotspot],
ledger: Mapping[str, LedgerEntry | Mapping[str, object]],
) -> tuple[Hotspot, ...]:
if not ledger:
return tuple(hotspots)
adjusted: list[Hotspot] = []
for hotspot in hotspots:
entry = _coerce_entry(ledger.get(hotspot_key(hotspot.path, hotspot.function_name)))
if entry is None or entry.score_delta == 0.0:
adjusted.append(hotspot)
continue
score = round(hotspot.score + entry.score_delta, 4)
signals = dict(hotspot.signals)
signals["ledger_delta"] = entry.score_delta
adjusted.append(
replace(
hotspot,
score=score,
band=_band_for(score),
signals=signals,
rationale=(
f"{hotspot.rationale} ledger overlay last_verdict={entry.last_verdict} "
f"score_delta={entry.score_delta} (inventory retained)."
),
)
)
return tuple(sorted(adjusted, key=lambda item: (-item.score, item.path, item.function_name or "")))
def must_keep_as_candidate(
finding: StaticFinding,
policy_severity: int | object,
reachability: str | None = None,
) -> bool:
status = finding.reachability_status if reachability is None else reachability
return _as_severity(policy_severity) >= FORCED_CANDIDATE_SEVERITY and status in FORCED_CANDIDATE_REACHABILITY
def select_forced_candidates(
findings: Sequence[StaticFinding],
policy: Mapping[str, object],
*,
rule_cwe: Mapping[str, str] | None = None,
) -> tuple[StaticFinding, ...]:
cwe_by_id = rule_cwe or {}
return tuple(
finding
for finding in findings
if must_keep_as_candidate(finding, _policy_severity_for(finding, policy, cwe_by_id), finding.reachability_status)
)
def _policy_severity_for(finding: StaticFinding, policy: Mapping[str, object], cwe_by_id: Mapping[str, str]) -> int:
if finding.finding_id in policy:
return _as_severity(policy[finding.finding_id])
cwe = cwe_by_id.get(finding.finding_id)
if cwe is not None and cwe in policy:
return _as_severity(policy[cwe])
return 0
def _as_severity(value: object) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return value
severity = getattr(value, "severity", None)
if isinstance(severity, bool) or not isinstance(severity, int):
return 0
return severity
def _coerce_entry(value: object) -> LedgerEntry | None:
if isinstance(value, LedgerEntry):
return value
if not isinstance(value, Mapping):
return None
raw_delta = value.get("score_delta")
if isinstance(raw_delta, bool) or not isinstance(raw_delta, int | float):
return None
raw_round = value.get("round", 0)
round_n = raw_round if isinstance(raw_round, int) and not isinstance(raw_round, bool) else 0
return LedgerEntry(
score_delta=float(raw_delta),
last_verdict=str(value.get("last_verdict", "")),
round=round_n,
)
def _band_for(score: float) -> str:
from .map import _band
return _band(score)
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
import json
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import asdict, dataclass
from pathlib import Path
from ..findings import StaticFinding
from ..preprocess import FileTarget
from .hints import TestHint, attach_test_hints
from .ledger import apply_overlay, load_ledger
from .signals import ComplexitySignals, collect_signals
# Score cutoffs for hotspot bands; inventory density is not the rank driver.
_HIGH_SCORE = 6.0
_MEDIUM_SCORE = 3.0
@dataclass(frozen=True)
class Hotspot:
path: str
function_name: str | None
score: float
band: str # high | medium | low
signals: dict[str, float | int | bool | str]
rationale: str
test_hint: TestHint | None
inventory_finding_ids: tuple[str, ...]
@dataclass(frozen=True)
class ComplexityMap:
hotspots: tuple[Hotspot, ...]
heuristic_only: bool
def build_complexity_map(
targets: Sequence[FileTarget],
findings: Sequence[StaticFinding],
output_path: Path,
*,
repo_files: Iterable[str] = (),
sources: Mapping[str, str] | None = None,
ledger_path: Path | None = None,
) -> ComplexityMap:
collected = collect_signals(targets, findings, repo_files=repo_files, sources=sources)
ids_by_path = _inventory_ids_by_path(findings)
hotspots = tuple(_hotspot_from_signals(item, ids_by_path.get(item.path, ())) for item in collected)
hotspots = attach_test_hints(hotspots, targets, repo_files=repo_files, sources=sources)
if ledger_path is not None:
hotspots = apply_overlay(hotspots, load_ledger(ledger_path))
complexity_map = ComplexityMap(hotspots=hotspots, heuristic_only=True)
write_complexity_map(complexity_map, output_path)
return complexity_map
def write_complexity_map(complexity_map: ComplexityMap, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(asdict(complexity_map), indent=2, sort_keys=True) + "\n")
def _hotspot_from_signals(item: ComplexitySignals, inventory_finding_ids: tuple[str, ...]) -> Hotspot:
band = _band(item.score)
return Hotspot(
path=item.path,
function_name=item.function_name,
score=item.score,
band=band,
signals=_signal_payload(item),
rationale=_rationale(item, band),
test_hint=None,
inventory_finding_ids=inventory_finding_ids,
)
def _band(score: float) -> str:
if score >= _HIGH_SCORE:
return "high"
if score >= _MEDIUM_SCORE:
return "medium"
return "low"
def _signal_payload(item: ComplexitySignals) -> dict[str, float | int | bool | str]:
return {
"loc": item.loc,
"nesting": item.nesting,
"reachability": item.reachability,
"inventory_hit_count": item.inventory_hit_count,
"has_adjacent_test": item.has_adjacent_test,
"tags": ",".join(item.tags),
}
def _rationale(item: ComplexitySignals, band: str) -> str:
tags = ",".join(item.tags) or "none"
return (
f"{band} band: score {item.score} against cutoffs high>={_HIGH_SCORE}, medium>={_MEDIUM_SCORE}; "
f"loc={item.loc}, nesting={item.nesting}, tags={tags}, reachability={item.reachability}, "
f"inventory_hit_count={item.inventory_hit_count} (density is a feature not the rank driver), "
f"has_adjacent_test={str(item.has_adjacent_test).lower()}."
)
def _inventory_ids_by_path(findings: Sequence[StaticFinding]) -> dict[str, tuple[str, ...]]:
grouped: dict[str, list[str]] = {}
for finding in findings:
grouped.setdefault(finding.path, []).append(finding.finding_id)
return {path: tuple(ids) for path, ids in grouped.items()}
+232
View File
@@ -0,0 +1,232 @@
from __future__ import annotations
import math
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from ..findings import StaticFinding
from ..preprocess import FileTarget
# Inventory density is a recorded feature and a capped rationale term, not the rank driver.
_LOC_COEFF = 0.85
_NESTING_COEFF = 0.2
_REACHABILITY_COEFF = 0.55
_TEST_GAP_COEFF = 0.35
_INVENTORY_COEFF = 0.12
_TAG_WEIGHTS: dict[str, float] = {
"parser": 4.0,
"deserialization": 3.5,
"network_entry": 1.75,
"auth_boundary": 1.5,
"fuzzable": 1.5,
"memory_unsafe": 1.25,
"crypto": 1.25,
"syscall_entry": 1.25,
"filesystem_entry": 1.0,
}
_TEST_ROOT_PARTS = frozenset({"tests", "test", "__tests__", "spec"})
@dataclass(frozen=True)
class ComplexitySignals:
path: str
function_name: str | None
loc: int
nesting: int
tags: tuple[str, ...]
reachability: int
inventory_hit_count: int
has_adjacent_test: bool
score: float
def collect_signals(
targets: Sequence[FileTarget],
findings: Sequence[StaticFinding] = (),
repo_files: Iterable[str] = (),
sources: Mapping[str, str] | None = None,
) -> tuple[ComplexitySignals, ...]:
inventory = _inventory_counts(findings)
repo_paths = {str(path).replace("\\", "/") for path in repo_files}
collected: list[ComplexitySignals] = []
for target in targets:
text = _source_text(target, sources)
nesting = measure_nesting(text)
hit_count = inventory.get(target.path, 0)
adjacent = _has_adjacent_test(target.path, repo_paths)
tags = tuple(target.tags)
names = _function_names(target.reachability_hints)
identities: tuple[str | None, ...] = names or (None,)
for function_name in identities:
reachability = _reachability(target.reachability_hints, function_name)
collected.append(
ComplexitySignals(
path=target.path,
function_name=function_name,
loc=target.loc,
nesting=nesting,
tags=tags,
reachability=reachability,
inventory_hit_count=hit_count,
has_adjacent_test=adjacent,
score=_score(
loc=target.loc,
nesting=nesting,
tags=tags,
reachability=reachability,
inventory_hit_count=hit_count,
has_adjacent_test=adjacent,
),
)
)
return tuple(sorted(collected, key=lambda item: (-item.score, item.path, item.function_name or "")))
def measure_nesting(text: str) -> int:
if not text:
return 0
max_indent = 0
max_braces = 0
braces = 0
for raw in text.splitlines():
if not raw.strip():
continue
expanded = raw.expandtabs(4)
indent = len(expanded) - len(expanded.lstrip(" "))
max_indent = max(max_indent, indent // 4)
for char in raw:
if char == "{":
braces += 1
max_braces = max(max_braces, braces)
elif char == "}":
braces = max(0, braces - 1)
return max(max_indent, max_braces)
def _score(
*,
loc: int,
nesting: int,
tags: Sequence[str],
reachability: int,
inventory_hit_count: int,
has_adjacent_test: bool,
) -> float:
loc_term = math.log1p(max(loc, 0)) * _LOC_COEFF
nest_term = min(max(nesting, 0), 16) * _NESTING_COEFF
tag_term = sum(_TAG_WEIGHTS.get(tag, 0.0) for tag in dict.fromkeys(tags))
reach_term = max(reachability, 0) * _REACHABILITY_COEFF
gap_term = 0.0 if has_adjacent_test else _TEST_GAP_COEFF
density = inventory_hit_count / max(loc, 1)
inventory_term = min(max(density, 0.0), 1.0) * _INVENTORY_COEFF
return round(loc_term + nest_term + tag_term + reach_term + gap_term + inventory_term, 4)
def _source_text(target: FileTarget, sources: Mapping[str, str] | None) -> str:
if sources:
if target.path in sources:
return sources[target.path]
if target.absolute_path in sources:
return sources[target.absolute_path]
path = Path(target.absolute_path)
if not path.is_file():
return ""
try:
return path.read_text(errors="ignore")
except OSError:
return ""
def _inventory_counts(findings: Sequence[StaticFinding]) -> dict[str, int]:
counts: dict[str, int] = {}
for finding in findings:
counts[finding.path] = counts.get(finding.path, 0) + 1
return counts
def _function_names(hints: Sequence[Mapping[str, object]]) -> tuple[str, ...]:
names: list[str] = []
seen: set[str] = set()
for hint in hints:
name = _hint_function_name(hint)
if name is not None and name not in seen:
names.append(name)
seen.add(name)
return tuple(names)
def _hint_function_name(hint: Mapping[str, object]) -> str | None:
if "function_name" in hint:
value = hint.get("function_name")
return value if isinstance(value, str) and value else None
value = hint.get("name")
return value if isinstance(value, str) and value else None
def _reachability(hints: Sequence[Mapping[str, object]], function_name: str | None) -> int:
selected: Sequence[Mapping[str, object]] = hints
if function_name is not None:
matching = [hint for hint in hints if _hint_function_name(hint) == function_name]
if matching:
selected = matching
if not selected:
return 0
score = 2
for hint in selected:
access_level = hint.get("access_level")
kind = hint.get("kind")
if access_level == "public":
score = max(score, 5 if kind in {"route", "parser", "fuzz"} else 4)
elif access_level in {"authenticated", "contract-only/callback"}:
score = max(score, 4)
elif access_level == "role-restricted":
score = max(score, 3)
else:
score = max(score, 2)
return score
def _has_adjacent_test(path: str, repo_files: set[str]) -> bool:
source = PurePosixPath(path)
source_stem = source.stem.lower()
source_parent = _parent(source)
for candidate in repo_files:
name = PurePosixPath(candidate).name
if not _is_test_filename(name):
continue
if _tested_stem(name).lower() != source_stem:
continue
parent = _parent(PurePosixPath(candidate))
if parent == source_parent or _under_common_test_root(candidate):
return True
return False
def _parent(path: PurePosixPath) -> str:
parent = path.parent.as_posix()
return "" if parent == "." else parent
def _is_test_filename(name: str) -> bool:
if name.startswith("test_") or name.endswith("Test.java"):
return True
return PurePosixPath(name).stem.endswith("_test")
def _tested_stem(filename: str) -> str:
if filename.endswith("Test.java"):
return filename[: -len("Test.java")]
stem = PurePosixPath(filename).stem
if stem.startswith("test_"):
return stem[len("test_") :]
if stem.endswith("_test"):
return stem[: -len("_test")]
return stem
def _under_common_test_root(path: str) -> bool:
parts = PurePosixPath(path).parts
if len(parts) >= 2 and parts[0] == "src" and parts[1] == "test":
return True
return any(part in _TEST_ROOT_PARTS for part in parts[:-1])
+158
View File
@@ -50,6 +50,66 @@ class StaticAnalysisConfig:
sarif_paths: tuple[str, ...] = () sarif_paths: tuple[str, ...] = ()
@dataclass(frozen=True)
class ScoreConfig:
k: float = 60.0
min_score: int = 80
block_severity_reachable: int = 5
blocking: bool = False
@dataclass(frozen=True)
class RulesetConfig:
# Emission floors that replace "every regex match becomes a finding". The
# defaults (0.0) emit everything, keeping output byte-identical by default.
min_emit_priority: float = 0.0
min_emit_precision: float = 0.0
@dataclass(frozen=True)
class HarnessxConfig:
# Optional agentic plane (the openultrasast[harnessx] extra). `provider`
# selects the HarnessX LLM provider used by both the hunter pool and the
# llm-judge verifier; the budget caps bound per-task cost and token spend.
# Inert unless the extra is installed and a model is configured in [models].
provider: str = "anthropic"
max_cost_usd: float = 2.0
token_threshold: int = 120_000
@dataclass(frozen=True)
class HardeningConfig:
# Release-readiness controls. redact_secrets masks credentials in traces/reports;
# max_findings caps the reported finding count for bounded CI runs (0 = unlimited).
redact_secrets: bool = True
max_findings: int = 0
@dataclass(frozen=True)
class FusionConfig:
# Two-panel fusion adjudication for triggered findings (standard mode). Runs
# deterministically by default; setting panel_model routes the panels through
# the configured HarnessX provider. high_assurance forces fusion on every finding.
enabled: bool = True
panel_model: str | None = None
decider_model: str | None = None
high_assurance: bool = False
@dataclass(frozen=True)
class ComplexityConfig:
# Stage-2 map size and hunter attention. Small defaults keep PR/nightly map work cheap.
top_k: int = 20
max_hunter_hotspots: int = 8
@dataclass(frozen=True)
class RegressConfig:
# Stage-3 candidate cap and optional per-language sandbox image pins. Isolation limits stay on SandboxConfig.
max_candidates: int = 5
images: tuple[tuple[str, str], ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
class ResolvedConfig: class ResolvedConfig:
models: ModelConfig = ModelConfig() models: ModelConfig = ModelConfig()
@@ -58,6 +118,13 @@ class ResolvedConfig:
dynamic: DynamicConfig = DynamicConfig() dynamic: DynamicConfig = DynamicConfig()
evidence: EvidenceConfig = EvidenceConfig() evidence: EvidenceConfig = EvidenceConfig()
static_analysis: StaticAnalysisConfig = StaticAnalysisConfig() static_analysis: StaticAnalysisConfig = StaticAnalysisConfig()
score: ScoreConfig = ScoreConfig()
ruleset: RulesetConfig = RulesetConfig()
harnessx: HarnessxConfig = HarnessxConfig()
fusion: FusionConfig = FusionConfig()
hardening: HardeningConfig = HardeningConfig()
complexity: ComplexityConfig = ComplexityConfig()
regress: RegressConfig = RegressConfig()
runs_dir: str = ".openultrasast/runs" runs_dir: str = ".openultrasast/runs"
@@ -74,6 +141,13 @@ def load_config(config_path: Path | None = None) -> ResolvedConfig:
dynamic=_load_dynamic(data.get("dynamic", {})), dynamic=_load_dynamic(data.get("dynamic", {})),
evidence=_load_evidence(data.get("evidence", {})), evidence=_load_evidence(data.get("evidence", {})),
static_analysis=_load_static_analysis(data.get("static_analysis", {})), static_analysis=_load_static_analysis(data.get("static_analysis", {})),
score=_load_score(data.get("score", {})),
ruleset=_load_ruleset_config(data.get("ruleset", {})),
harnessx=_load_harnessx(data.get("harnessx", {})),
fusion=_load_fusion(data.get("fusion", {})),
hardening=_load_hardening(data.get("hardening", {})),
complexity=_load_complexity(data.get("complexity", {})),
regress=_load_regress(data.get("regress", {})),
runs_dir=os.environ.get("OPENULTRASAST_RUNS_DIR", ".openultrasast/runs"), runs_dir=os.environ.get("OPENULTRASAST_RUNS_DIR", ".openultrasast/runs"),
) )
@@ -148,3 +222,87 @@ def _load_static_analysis(value: object) -> StaticAnalysisConfig:
if not isinstance(paths, list): if not isinstance(paths, list):
paths = [] paths = []
return StaticAnalysisConfig(sarif_paths=tuple(str(item) for item in paths)) return StaticAnalysisConfig(sarif_paths=tuple(str(item) for item in paths))
def _load_score(value: object) -> ScoreConfig:
data = _section(value)
return ScoreConfig(
k=_float_value(data.get("k"), 60.0),
min_score=_int_value(data.get("min_score"), 80),
block_severity_reachable=_int_value(data.get("block_severity_reachable"), 5),
blocking=bool(data.get("blocking", False)),
)
def _load_ruleset_config(value: object) -> RulesetConfig:
data = _section(value)
return RulesetConfig(
min_emit_priority=_float_value(data.get("min_emit_priority"), 0.0),
min_emit_precision=_float_value(data.get("min_emit_precision"), 0.0),
)
def _load_harnessx(value: object) -> HarnessxConfig:
data = _section(value)
return HarnessxConfig(
provider=_string(data.get("provider")) or "anthropic",
max_cost_usd=_float_value(data.get("max_cost_usd"), 2.0),
token_threshold=_int_value(data.get("token_threshold"), 120_000),
)
def _load_hardening(value: object) -> HardeningConfig:
data = _section(value)
return HardeningConfig(
redact_secrets=bool(data.get("redact_secrets", True)),
max_findings=_int_value(data.get("max_findings"), 0),
)
def _load_fusion(value: object) -> FusionConfig:
data = _section(value)
return FusionConfig(
enabled=bool(data.get("enabled", True)),
panel_model=_string(data.get("panel_model")),
decider_model=_string(data.get("decider_model")),
high_assurance=bool(data.get("high_assurance", False)),
)
def _load_complexity(value: object) -> ComplexityConfig:
data = _section(value)
return ComplexityConfig(
top_k=_int_value(data.get("top_k"), 20),
max_hunter_hotspots=_int_value(data.get("max_hunter_hotspots"), 8),
)
def _load_regress(value: object) -> RegressConfig:
data = _section(value)
return RegressConfig(
max_candidates=_int_value(data.get("max_candidates"), 5),
images=_string_map(data.get("images")),
)
def _string_map(value: object) -> tuple[tuple[str, str], ...]:
data = _section(value)
images: list[tuple[str, str]] = []
for key, item in data.items():
pinned = _string(item)
if pinned is not None:
images.append((str(key), pinned))
return tuple(sorted(images))
def _float_value(value: object, default: float) -> float:
if isinstance(value, bool):
return default
if isinstance(value, int | float):
return float(value)
if isinstance(value, str):
try:
return float(value)
except ValueError:
return default
return default
+33 -330
View File
@@ -5,8 +5,10 @@ import re
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
from .policy import CwePolicy, load_policy, resolve_severity
from .preprocess import FileTarget from .preprocess import FileTarget
from .rank import RankingScore from .rank import RankingScore
from .ruleset import DEFAULT_RULESET_DIR, PatternRule, load_ruleset
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -25,348 +27,46 @@ class StaticFinding:
reachability_conditions: list[str] reachability_conditions: list[str]
tags: list[str] tags: list[str]
ranking_priority: float ranking_priority: float
status: str = "enabled"
proposed_snippet: str | None = None
@dataclass(frozen=True) SEVERITY_LABEL = {5: "critical", 4: "high", 3: "medium", 2: "low", 1: "low", 0: "info"}
class PatternRule:
rule_id: str # Default ruleset loaded from versioned TOML data (rules-as-data).
title: str PATTERN_RULES = load_ruleset(DEFAULT_RULESET_DIR)
severity: str
tags: tuple[str, ...]
pattern: re.Pattern[str]
cwe: str
languages: tuple[str, ...]
C_LANGUAGES = ("c", "cpp") def quick_scan_findings(
PYTHON_LANGUAGES = ("python",) root: Path,
JS_LANGUAGES = ("javascript", "typescript") targets: list[FileTarget],
JAVA_LANGUAGES = ("java",) rankings: list[RankingScore],
GROOVY_LANGUAGES = ("groovy",) ruleset: tuple[PatternRule, ...] | None = None,
policy: dict[str, CwePolicy] | None = None,
PATTERN_RULES = ( *,
# --- C / C++ ----------------------------------------------------------------- min_emit_priority: float = 0.0,
PatternRule( min_emit_precision: float = 0.0,
rule_id="c-unsafe-copy", ) -> list[StaticFinding]:
title="Unsafe C string copy needs review", ruleset = PATTERN_RULES if ruleset is None else ruleset
severity="high", policy = load_policy() if policy is None else policy
tags=("memory_unsafe",),
pattern=re.compile(r"\b(gets|strcpy|strcat|sprintf)\s*\("),
cwe="CWE-120",
languages=C_LANGUAGES,
),
PatternRule(
rule_id="c-unsafe-memory-copy",
title="Unsafe C memory copy needs review",
severity="high",
tags=("memory_unsafe",),
pattern=re.compile(r"\b(memcpy|memmove|bcopy)\s*\("),
cwe="CWE-120",
languages=C_LANGUAGES,
),
PatternRule(
rule_id="c-unsafe-format",
title="Unsafe C format string needs review",
severity="high",
tags=("memory_unsafe",),
# Format function whose format argument is a bare identifier (not a string
# literal), i.e. the classic format-string sink. printf("...") is ignored.
pattern=re.compile(r"\b(printf|vprintf|vsprintf|vsnprintf|vfprintf|syslog)\s*\(\s*[A-Za-z_]"),
cwe="CWE-134",
languages=C_LANGUAGES,
),
PatternRule(
rule_id="c-unsafe-scanf",
title="Unbounded C scanf needs review",
severity="high",
tags=("memory_unsafe",),
pattern=re.compile(r"\b(scanf|fscanf|sscanf)\s*\([^\n]*%s"),
cwe="CWE-120",
languages=C_LANGUAGES,
),
PatternRule(
rule_id="c-shell-exec",
title="C shell execution needs review",
severity="high",
tags=("syscall_entry",),
pattern=re.compile(r"\b(system|popen)\s*\("),
cwe="CWE-78",
languages=C_LANGUAGES,
),
PatternRule(
rule_id="c-exec-family",
title="C exec command execution needs review",
severity="high",
tags=("syscall_entry",),
pattern=re.compile(r"\b(execl|execlp|execle|execv|execvp|execvpe)\s*\("),
cwe="CWE-78",
languages=C_LANGUAGES,
),
# --- Python ------------------------------------------------------------------
PatternRule(
rule_id="python-unsafe-eval",
title="Dynamic Python execution needs review",
severity="high",
tags=("syscall_entry",),
pattern=re.compile(r"\b(eval|exec)\s*\("),
cwe="CWE-95",
languages=PYTHON_LANGUAGES,
),
PatternRule(
rule_id="python-os-command",
title="Python OS command execution needs review",
severity="high",
tags=("syscall_entry",),
pattern=re.compile(r"\b(os\.system|os\.popen|os\.execl|os\.execlp|os\.execv|os\.execvp)\s*\("),
cwe="CWE-78",
languages=PYTHON_LANGUAGES,
),
PatternRule(
rule_id="python-shell-true",
title="Subprocess shell execution needs review",
severity="medium",
tags=("syscall_entry",),
pattern=re.compile(r"subprocess\.[a-zA-Z_]+\s*\([^\n]*shell\s*=\s*True"),
cwe="CWE-78",
languages=PYTHON_LANGUAGES,
),
PatternRule(
rule_id="python-unsafe-deserialization",
title="Unsafe Python deserialization needs review",
severity="high",
tags=("deserialization",),
pattern=re.compile(r"\b(pickle\.loads|pickle\.load|cPickle\.loads|yaml\.load|marshal\.loads)\s*\("),
cwe="CWE-502",
languages=PYTHON_LANGUAGES,
),
PatternRule(
rule_id="python-sql-injection",
title="Python SQL string composition needs review",
severity="high",
tags=("injection",),
# .execute(...) where the argument is built with %-formatting, .format(),
# string concatenation, or an f-string. Parameterised %s placeholders pass.
pattern=re.compile(r"(\.execute)\s*\(\s*[^\n]*(?:[\"']\s*%\s*[(A-Za-z_]|\.format\s*\(|[\"']\s*\+|\bf[\"'])"),
cwe="CWE-89",
languages=PYTHON_LANGUAGES,
),
PatternRule(
rule_id="python-path-traversal",
title="Python file open with external input needs review",
severity="medium",
tags=("filesystem_entry",),
pattern=re.compile(r"\b(open)\s*\(\s*[^\n]*(?:request\.|req\.|argv|input\s*\(|params\[|GET\[|POST\[)"),
cwe="CWE-22",
languages=PYTHON_LANGUAGES,
),
PatternRule(
rule_id="python-ssrf",
title="Python outbound request with external input needs review",
severity="medium",
tags=("network_entry",),
pattern=re.compile(
r"\b(requests\.(?:get|post|put|delete|head|patch)|urllib\.request\.urlopen|urlopen)\s*\(\s*[^\n]*(?:request\.|req\.|argv|input\s*\()"
),
cwe="CWE-918",
languages=PYTHON_LANGUAGES,
),
PatternRule(
rule_id="python-weak-hash",
title="Python weak hash needs review",
severity="low",
tags=("crypto",),
pattern=re.compile(r"\bhashlib\.(md5|sha1)\s*\("),
cwe="CWE-327",
languages=PYTHON_LANGUAGES,
),
PatternRule(
rule_id="python-ssti",
title="Python server-side template injection needs review",
severity="high",
tags=("injection",),
pattern=re.compile(r"\b(render_template_string)\s*\("),
cwe="CWE-94",
languages=PYTHON_LANGUAGES,
),
PatternRule(
rule_id="python-flask-debug",
title="Flask debug mode enabled needs review",
severity="low",
tags=("insecure_default",),
pattern=re.compile(r"\.run\s*\([^\n]*(debug)\s*=\s*True"),
cwe="CWE-489",
languages=PYTHON_LANGUAGES,
),
# --- JavaScript / TypeScript -------------------------------------------------
PatternRule(
rule_id="js-command-exec",
title="Node child_process command execution needs review",
severity="high",
tags=("syscall_entry",),
# exec/execSync invoke a shell; execFile/spawn with an argv array do not and
# are the safe alternatives, so they are intentionally excluded here.
pattern=re.compile(r"\b(exec|execSync)\s*\("),
cwe="CWE-78",
languages=JS_LANGUAGES,
),
PatternRule(
rule_id="js-spawn-shell",
title="Node spawn with shell needs review",
severity="high",
tags=("syscall_entry",),
pattern=re.compile(r"\b(spawn|spawnSync)\s*\([^\n]*shell\s*:\s*true"),
cwe="CWE-78",
languages=JS_LANGUAGES,
),
PatternRule(
rule_id="js-eval",
title="Dynamic JavaScript evaluation needs review",
severity="high",
tags=("syscall_entry",),
pattern=re.compile(r"\b(eval)\s*\("),
cwe="CWE-95",
languages=JS_LANGUAGES,
),
PatternRule(
rule_id="js-function-constructor",
title="JavaScript Function constructor needs review",
severity="high",
tags=("syscall_entry",),
pattern=re.compile(r"new\s+(Function)\s*\("),
cwe="CWE-95",
languages=JS_LANGUAGES,
),
PatternRule(
rule_id="js-xss-response",
title="Unsanitized HTTP response reflection needs review",
severity="high",
tags=("injection",),
pattern=re.compile(r"\.(send|write|end)\s*\(\s*[^\n]*\b(?:req|request)\.(?:query|params|body)"),
cwe="CWE-79",
languages=JS_LANGUAGES,
),
PatternRule(
rule_id="js-dom-xss",
title="DOM sink with untrusted input needs review",
severity="high",
tags=("injection",),
pattern=re.compile(r"\.(innerHTML|outerHTML)\s*=\s*[^\n]*\b(?:req|request|location|document\.location)"),
cwe="CWE-79",
languages=JS_LANGUAGES,
),
PatternRule(
rule_id="js-sql-injection",
title="JavaScript SQL string composition needs review",
severity="high",
tags=("injection",),
pattern=re.compile(r"\.(query|execute)\s*\(\s*[^\n]*(?:\$\{|\+\s*[A-Za-z_$])"),
cwe="CWE-89",
languages=JS_LANGUAGES,
),
PatternRule(
rule_id="js-path-traversal",
title="Node filesystem access with external input needs review",
severity="medium",
tags=("filesystem_entry",),
pattern=re.compile(
r"\bfs\.(readFile|readFileSync|writeFile|writeFileSync|createReadStream|unlink|unlinkSync)\s*\(\s*[^\n]*\b(?:req|request)\."
),
cwe="CWE-22",
languages=JS_LANGUAGES,
),
PatternRule(
rule_id="js-ssrf",
title="JavaScript outbound request with external input needs review",
severity="medium",
tags=("network_entry",),
pattern=re.compile(r"\b(axios|fetch|got)\s*[.(][^\n]*\b(?:req|request)\."),
cwe="CWE-918",
languages=JS_LANGUAGES,
),
PatternRule(
rule_id="js-weak-hash",
title="JavaScript weak hash needs review",
severity="low",
tags=("crypto",),
pattern=re.compile(r"createHash\s*\(\s*[\"'](md5|sha1)[\"']"),
cwe="CWE-327",
languages=JS_LANGUAGES,
),
PatternRule(
rule_id="js-deserialize",
title="JavaScript unsafe deserialization needs review",
severity="high",
tags=("deserialization",),
pattern=re.compile(r"\b(unserialize)\s*\("),
cwe="CWE-502",
languages=JS_LANGUAGES,
),
# --- Java --------------------------------------------------------------------
PatternRule(
rule_id="java-command-exec",
title="Java runtime command execution needs review",
severity="high",
tags=("syscall_entry",),
pattern=re.compile(r"\b(exec)\s*\("),
cwe="CWE-78",
languages=JAVA_LANGUAGES,
),
PatternRule(
rule_id="java-sql-injection",
title="Java SQL string composition needs review",
severity="high",
tags=("injection",),
# A string literal that begins with a SQL keyword and is concatenated with
# untrusted data. Parameterised queries (placeholders, no '+') are ignored.
pattern=re.compile(r"(?i)\"\s*(select|insert|update|delete)\b[^\"]*\"\s*\+"),
cwe="CWE-89",
languages=JAVA_LANGUAGES,
),
PatternRule(
rule_id="java-weak-hash",
title="Java weak hash needs review",
severity="low",
tags=("crypto",),
pattern=re.compile(r"MessageDigest\.getInstance\s*\(\s*\"(MD5|SHA-1)\""),
cwe="CWE-327",
languages=JAVA_LANGUAGES,
),
PatternRule(
rule_id="java-deserialization",
title="Java unsafe deserialization needs review",
severity="high",
tags=("deserialization",),
pattern=re.compile(r"\.(readObject)\s*\("),
cwe="CWE-502",
languages=JAVA_LANGUAGES,
),
# --- Groovy server-side templates -------------------------------------------
PatternRule(
rule_id="groovy-xss-unescaped",
title="Groovy template unescaped output needs review",
severity="high",
tags=("injection",),
# Unescaped output of a bound variable; string literals are not flagged.
pattern=re.compile(r"(yieldUnescaped)\s+[A-Za-z_]"),
cwe="CWE-79",
languages=GROOVY_LANGUAGES,
),
)
def quick_scan_findings(root: Path, targets: list[FileTarget], rankings: list[RankingScore]) -> list[StaticFinding]:
ranking_by_path = {ranking.path: ranking for ranking in rankings} ranking_by_path = {ranking.path: ranking for ranking in rankings}
# Compile once; disabled rules and rules below the precision floor never fire.
compiled = [
(rule, re.compile(rule.pattern)) for rule in ruleset if rule.status != "disabled" and rule.precision_estimate >= min_emit_precision
]
findings: list[StaticFinding] = [] findings: list[StaticFinding] = []
for target in targets: for target in targets:
text = _read_text(root / target.path) text = _read_text(root / target.path)
for rule in PATTERN_RULES: for rule, pattern in compiled:
if rule.languages and target.language not in rule.languages: if rule.languages and target.language not in rule.languages:
continue continue
ranking = ranking_by_path.get(target.path) ranking = ranking_by_path.get(target.path)
for match in rule.pattern.finditer(text): for match in pattern.finditer(text):
if _match_is_in_comment(text, match.start(), target.language): if _match_is_in_comment(text, match.start(), target.language):
continue continue
findings.append(_finding_from_match(target, rule, text, match, ranking)) finding = _finding_from_match(target, rule, text, match, ranking, policy)
if finding.ranking_priority >= min_emit_priority:
findings.append(finding)
return sorted(findings, key=lambda item: (_severity_sort(item.severity), -item.ranking_priority, item.path)) return sorted(findings, key=lambda item: (_severity_sort(item.severity), -item.ranking_priority, item.path))
@@ -393,17 +93,19 @@ def _finding_from_match(
text: str, text: str,
match: re.Match[str], match: re.Match[str],
ranking: RankingScore | None, ranking: RankingScore | None,
policy: dict[str, CwePolicy],
) -> StaticFinding: ) -> StaticFinding:
offset = match.start() offset = match.start()
line = text.count("\n", 0, offset) + 1 line = text.count("\n", 0, offset) + 1
reachability_status, function_name, reachability_evidence = _reachability_for_line(target, line) reachability_status, function_name, reachability_evidence = _reachability_for_line(target, line)
priority = ranking.priority if ranking is not None else 1.0 priority = ranking.priority if ranking is not None else 1.0
matched_token = match.group(1) if match.groups() else match.group(0).split("(", 1)[0].strip() matched_token = match.group(1) if match.groups() else match.group(0).split("(", 1)[0].strip()
severity = SEVERITY_LABEL.get(resolve_severity(policy, rule.cwe), "info")
return StaticFinding( return StaticFinding(
finding_id=f"{rule.rule_id}:{target.path}:{line}", finding_id=f"{rule.rule_id}:{target.path}:{line}",
path=target.path, path=target.path,
title=rule.title, title=rule.title,
severity=rule.severity, severity=severity,
confidence="medium", confidence="medium",
evidence_level="static_corroboration", evidence_level="static_corroboration",
rationale=( rationale=(
@@ -416,6 +118,7 @@ def _finding_from_match(
reachability_conditions=_reachability_conditions(reachability_evidence), reachability_conditions=_reachability_conditions(reachability_evidence),
tags=sorted(set(target.tags) | set(rule.tags)), tags=sorted(set(target.tags) | set(rule.tags)),
ranking_priority=_finding_priority(priority, reachability_status), ranking_priority=_finding_priority(priority, reachability_status),
status=rule.status,
) )
+345
View File
@@ -0,0 +1,345 @@
"""Fusion adjudication — OpenUltraCode-style two-panel deepening (Phase 13).
Fusion is the deepening mechanism for findings that need more reasoning than the
ranker → hunter → verifier → mapping loop provides: critical/high severity, verifier
disagreement, static-vs-semantic evidence conflict, findings that gate a risky fix or
disclosure, or an explicit high-assurance request. Two panels independently review the
bounded evidence (one steel-manning the vulnerability case, one the false-positive
case), critique and revise, vote, and a decider issues the final disposition.
The deterministic engine here is the zero-dependency core (and the fallback). When the
``openultrasast[harnessx]`` extra and panel models are configured, ``fuse_findings_dispatch``
routes the panels through real LLM reviews; the decider reconciliation stays deterministic
and auditable. Every fused finding receives exactly one of the five dispositions, and the
decision discloses model IDs, panel roles, votes, degradations, and the decision source.
"""
from __future__ import annotations
from collections import Counter
from dataclasses import asdict, dataclass, field
from enum import StrEnum
from .findings import StaticFinding
from .harness_ext import has_harnessx
from .verification import VerificationResult, VerificationStatus
class FusionDisposition(StrEnum):
ACCEPTED = "accepted"
REJECTED = "rejected"
MITIGATED = "mitigated"
DEFERRED = "deferred"
BLOCKED = "blocked"
_HIGH_SEVERITY = frozenset({"critical", "high"})
@dataclass(frozen=True)
class PanelVerdict:
role: str # "panel_a" | "panel_b"
leaning: str # "vulnerability" | "false_positive"
disposition: FusionDisposition
confidence: float
vulnerability_case: str
false_positive_case: str
rationale: str
model_id: str | None = None # None => deterministic panel
def to_dict(self) -> dict[str, object]:
return asdict(self)
@dataclass(frozen=True)
class FusionDecision:
finding_id: str
triggered: bool
triggers: list[str]
disposition: FusionDisposition
decision_source: str # "deterministic-reconciler" | "llm-panels" | "no-fusion"
panels: list[PanelVerdict]
votes: dict[str, int]
model_ids: list[str]
degradations: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, object]:
data = asdict(self)
data["disposition"] = str(self.disposition)
data["panels"] = [panel.to_dict() for panel in self.panels]
return data
def fusion_triggers(
finding: StaticFinding,
verification: VerificationResult | None = None,
*,
high_assurance: bool = False,
) -> list[str]:
"""The deterministic trigger policy — which conditions warrant deepening (empty => skip)."""
triggers: set[str] = set()
if finding.severity in _HIGH_SEVERITY:
triggers.add(f"severity:{finding.severity}")
reachable = finding.reachability_status == "reachable"
if verification is not None:
if verification.status == VerificationStatus.NEEDS_EVIDENCE:
triggers.add("verifier_disagreement")
if verification.status == VerificationStatus.REJECTED and reachable:
triggers.add("evidence_conflict") # static says vuln-shaped, verifier rejected
if reachable and finding.severity in _HIGH_SEVERITY:
triggers.add("gates_risky_fix")
if high_assurance:
triggers.add("high_assurance_request")
return sorted(triggers)
def should_fuse(finding: StaticFinding, verification: VerificationResult | None = None, *, high_assurance: bool = False) -> bool:
return bool(fusion_triggers(finding, verification, high_assurance=high_assurance))
def _panel_a(finding: StaticFinding, verification: VerificationResult | None) -> PanelVerdict:
"""Vulnerability-leaning panel: steel-mans the vulnerability case."""
reachable = finding.reachability_status == "reachable"
verified = verification is not None and verification.verified
if reachable and finding.severity in _HIGH_SEVERITY and not verified:
disposition, confidence = FusionDisposition.BLOCKED, 0.7
elif reachable:
disposition, confidence = FusionDisposition.ACCEPTED, 0.75 if verified else 0.6
else:
disposition, confidence = FusionDisposition.DEFERRED, 0.5
vuln_case = verification.pro_case if verification else f"{finding.title}: {finding.rationale}"
return PanelVerdict(
role="panel_a",
leaning="vulnerability",
disposition=disposition,
confidence=confidence,
vulnerability_case=vuln_case,
false_positive_case="Reachability or sanitizer evidence could still neutralize the sink.",
rationale=f"reachability={finding.reachability_status}, severity={finding.severity}, verified={verified}",
)
def _panel_b(finding: StaticFinding, verification: VerificationResult | None) -> PanelVerdict:
"""False-positive-leaning panel: steel-mans the false-positive case."""
reachable = finding.reachability_status == "reachable"
verified = verification is not None and verification.verified
rejected = verification is not None and verification.status == VerificationStatus.REJECTED
if rejected or not reachable:
disposition, confidence = FusionDisposition.REJECTED, 0.7 if rejected else 0.55
elif verified:
disposition, confidence = FusionDisposition.ACCEPTED, 0.6 # concedes when evidence is strong
else:
disposition, confidence = FusionDisposition.DEFERRED, 0.5
fp_case = verification.counter_case if verification else "No independent verification confirms attacker control or impact."
return PanelVerdict(
role="panel_b",
leaning="false_positive",
disposition=disposition,
confidence=confidence,
vulnerability_case="A tainted sink shape exists in the source.",
false_positive_case=fp_case,
rationale=f"reachable={reachable}, rejected={rejected}, verified={verified}",
)
def _reconcile(
finding: StaticFinding,
verification: VerificationResult | None,
panels: list[PanelVerdict],
mitigated_ids: frozenset[str],
) -> tuple[FusionDisposition, str]:
"""Deterministic decider: map evidence + panels onto one disposition + a rationale."""
reachable = finding.reachability_status == "reachable"
verified = verification is not None and verification.verified
rejected = verification is not None and verification.status == VerificationStatus.REJECTED
if finding.finding_id in mitigated_ids:
return FusionDisposition.MITIGATED, "a prior calibration confirms this surface is mitigated"
if rejected and not reachable:
return FusionDisposition.REJECTED, "verifier rejected and the sink is not reachable"
if verified and reachable:
return FusionDisposition.ACCEPTED, "reachable sink with corroborating verification evidence"
if reachable and finding.severity in _HIGH_SEVERITY and not verified:
return FusionDisposition.BLOCKED, "reachable high-severity finding gates a risky fix until evidence resolves"
if rejected and reachable:
return FusionDisposition.DEFERRED, "static and verifier evidence conflict; deeper evidence required"
return FusionDisposition.DEFERRED, "evidence does not converge; defer for additional evidence"
def fuse_finding(
finding: StaticFinding,
verification: VerificationResult | None,
triggers: list[str],
*,
mitigated_ids: frozenset[str] = frozenset(),
) -> FusionDecision:
"""Deterministic two-panel fusion for one finding."""
panels = [_panel_a(finding, verification), _panel_b(finding, verification)]
disposition, _rationale = _reconcile(finding, verification, panels, mitigated_ids)
votes = Counter(str(panel.disposition) for panel in panels)
return FusionDecision(
finding_id=finding.finding_id,
triggered=True,
triggers=triggers,
disposition=disposition,
decision_source="deterministic-reconciler",
panels=panels,
votes=dict(sorted(votes.items())),
model_ids=[],
degradations=[],
warnings=[],
)
def fuse_findings_dispatch(
findings: list[StaticFinding],
verifications: list[VerificationResult],
*,
panel_model: str | None = None,
decider_model: str | None = None,
provider: str = "anthropic",
use_harnessx: bool = False,
high_assurance: bool = False,
mitigated_ids: frozenset[str] = frozenset(),
) -> list[FusionDecision]:
"""Fuse every finding that the trigger policy selects.
Deterministic by default. When ``use_harnessx`` is set, a panel model is configured,
and the extra is present, the panels are produced by real LLM reviews; otherwise the
deterministic panels run and (if an LLM run was requested) a degradation is recorded.
"""
verification_by_id = {result.finding_id: result for result in verifications}
want_llm = use_harnessx and bool(panel_model)
decisions: list[FusionDecision] = []
for finding in findings:
verification = verification_by_id.get(finding.finding_id)
triggers = fusion_triggers(finding, verification, high_assurance=high_assurance)
if not triggers:
continue
if want_llm and has_harnessx():
decisions.append(_fuse_with_llm_panels(finding, verification, triggers, panel_model, decider_model, provider, mitigated_ids))
else:
decision = fuse_finding(finding, verification, triggers, mitigated_ids=mitigated_ids)
if want_llm and not has_harnessx():
decision = _with_degradation(decision, "fusion_llm_unavailable: harnessx extra absent; used deterministic panels")
decisions.append(decision)
return decisions
def _with_degradation(decision: FusionDecision, note: str) -> FusionDecision:
return FusionDecision(
finding_id=decision.finding_id,
triggered=decision.triggered,
triggers=decision.triggers,
disposition=decision.disposition,
decision_source=decision.decision_source,
panels=decision.panels,
votes=decision.votes,
model_ids=decision.model_ids,
degradations=[*decision.degradations, note],
warnings=decision.warnings,
)
def _fuse_with_llm_panels(
finding: StaticFinding,
verification: VerificationResult | None,
triggers: list[str],
panel_model: str | None,
decider_model: str | None,
provider: str,
mitigated_ids: frozenset[str],
) -> FusionDecision:
"""LLM-backed panels with a deterministic, auditable decider (lazy/guarded)."""
import asyncio
model = panel_model or ""
try:
panels = asyncio.run(_run_llm_panels(finding, verification, model, provider))
source = "llm-panels"
warnings: list[str] = []
except Exception as exc: # noqa: BLE001 — panel/provider failure falls back, never crashes the scan
panels = [_panel_a(finding, verification), _panel_b(finding, verification)]
source = "deterministic-reconciler"
warnings = [f"llm_panels_failed: {type(exc).__name__}"]
disposition, _rationale = _reconcile(finding, verification, panels, mitigated_ids)
votes = Counter(str(panel.disposition) for panel in panels)
model_ids = sorted({panel.model_id for panel in panels if panel.model_id})
if decider_model:
model_ids = sorted({*model_ids, decider_model})
return FusionDecision(
finding_id=finding.finding_id,
triggered=True,
triggers=triggers,
disposition=disposition,
decision_source=source,
panels=panels,
votes=dict(sorted(votes.items())),
model_ids=model_ids,
degradations=[],
warnings=warnings,
)
async def _run_llm_panels(
finding: StaticFinding,
verification: VerificationResult | None,
model: str,
provider: str,
) -> list[PanelVerdict]:
from harnessx.core.events import Message
from harnessx.processors.evaluation.llm_judge import build_judge_prompt, parse_judge_response
from .harness_ext import build_provider
client = build_provider(model, provider)
panels: list[PanelVerdict] = []
for role, leaning, stance in (
("panel_a", "vulnerability", "Argue the vulnerability case as strongly as the evidence allows."),
("panel_b", "false_positive", "Argue the false-positive case as strongly as the evidence allows."),
):
summary = f"reachability={finding.reachability_status}; severity={finding.severity}; tags={', '.join(finding.tags) or 'none'}"
prompt = build_judge_prompt(
task_description=f"{stance} Finding: {finding.title} at {finding.path}:{finding.line}.",
trajectory_summary=summary,
extracted_answer=verification.pro_case if verification else finding.rationale,
)
response = await client.complete(messages=[Message(role="user", content=prompt)], tools=[])
verdict = parse_judge_response(_text(response.content))
base = _panel_a(finding, verification) if role == "panel_a" else _panel_b(finding, verification)
plausible = str(verdict.get("verdict", "")) == "plausible"
confidence = (
float(verdict.get("confidence", base.confidence)) if isinstance(verdict.get("confidence"), int | float) else base.confidence
)
panels.append(
PanelVerdict(
role=role,
leaning=leaning,
disposition=base.disposition if plausible or role == "panel_a" else FusionDisposition.REJECTED,
confidence=confidence,
vulnerability_case=base.vulnerability_case,
false_positive_case=base.false_positive_case,
rationale=str(verdict.get("cause", base.rationale)),
model_id=model,
)
)
return panels
def _text(content: object) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(str(block.get("text", "")) if isinstance(block, dict) else str(block) for block in content)
return str(content)
__all__ = [
"FusionDecision",
"FusionDisposition",
"PanelVerdict",
"fuse_finding",
"fuse_findings_dispatch",
"fusion_triggers",
"should_fuse",
]
+205
View File
@@ -0,0 +1,205 @@
"""Detection-quality gate — the hard feasibility constraint (Phase 4 task 8).
Aggregate recall and false-positive rate over the benchmark corpus must clear the
project goal: recall >= 90% AND false-positive rate < 10%. This gate is computed
**independently of the project score** so a score improvement can never unblock a
gate-breaching change — the recall/FP constraint is never folded into the score
reward. ``merge_gate`` composes this hard constraint with the (advisory-first)
score gate by AND, so the score can only *add* a block, never remove one.
Run as a module to evaluate the corpus and exit non-zero on a breach::
python -m openultrasast.gate
"""
from __future__ import annotations
import sys
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from .benchmark import BenchmarkRun, evaluate_benchmark, load_benchmark_manifest, resolve_benchmark_source
from .findings import quick_scan_findings
from .mapping import analyze_entry_points, attach_reachability_hints
from .preprocess import preprocess_repository
from .rank import rank_targets
# The in-scope corpus: language -> benchmark manifest names (smoke + real repo).
# Single source of truth shared by the CI gate and the detection-benchmark test.
LANGUAGE_MANIFESTS: dict[str, list[str]] = {
"c_cpp": ["c-cpp-smoke", "cpp-damn-vulnerable"],
"python": ["python-web-smoke", "python-vulnerable"],
"javascript": ["javascript-node-web-smoke", "javascript-vulnerable"],
"java": ["java-web-smoke", "java-spring-boot-vulnerable"],
}
RECALL_FLOOR = 0.90
FALSE_POSITIVE_CEILING = 0.10
DEFAULT_MANIFEST_DIR = Path("benchmarks/manifests")
@dataclass(frozen=True)
class DetectionMetrics:
"""Aggregate detection counts and the two rates derived from them."""
expected: int
matched: int
actual: int
false_positives: int
@property
def recall(self) -> float:
return self.matched / self.expected if self.expected else 1.0
@property
def false_positive_rate(self) -> float:
return self.false_positives / self.actual if self.actual else 0.0
def __add__(self, other: DetectionMetrics) -> DetectionMetrics:
return DetectionMetrics(
expected=self.expected + other.expected,
matched=self.matched + other.matched,
actual=self.actual + other.actual,
false_positives=self.false_positives + other.false_positives,
)
@dataclass(frozen=True)
class DetectionGateVerdict:
"""Score-independent verdict: pass only if recall and FP both clear the goal."""
passed: bool
overall: DetectionMetrics
per_language: dict[str, DetectionMetrics]
recall_floor: float
fp_ceiling: float
reasons: list[str]
def _quick_scan(target: Path): # type: ignore[no-untyped-def]
_, targets = preprocess_repository(target)
targets = attach_reachability_hints(targets, analyze_entry_points(target, targets))
rankings = rank_targets(targets)
return quick_scan_findings(target, targets, rankings)
def language_metrics(manifest_names: list[str], manifest_dir: Path = DEFAULT_MANIFEST_DIR) -> DetectionMetrics:
"""Aggregate detection metrics across one language's manifests."""
total = DetectionMetrics(0, 0, 0, 0)
for name in manifest_names:
manifest_path = manifest_dir / f"{name}.toml"
manifest = load_benchmark_manifest(manifest_path)
target = resolve_benchmark_source(manifest_path, manifest)
findings = _quick_scan(target)
run = BenchmarkRun(benchmark_run_id="gate", root=Path("/tmp/gate"), manifest=manifest)
result = evaluate_benchmark(run=run, mode="quick", findings=findings, scan_id=None, scan_run_dir=None)
m = result.metrics
total = total + DetectionMetrics(
expected=m.expected_findings_total,
matched=m.matched_findings_total,
actual=m.actual_findings_total,
false_positives=m.false_positive_findings_total,
)
return total
def evaluate_corpus(
corpus: Mapping[str, list[str]] = LANGUAGE_MANIFESTS, manifest_dir: Path = DEFAULT_MANIFEST_DIR
) -> dict[str, DetectionMetrics]:
"""Compute per-language detection metrics across the whole corpus."""
return {language: language_metrics(names, manifest_dir) for language, names in sorted(corpus.items())}
def detection_gate(
per_language: Mapping[str, DetectionMetrics],
*,
recall_floor: float = RECALL_FLOOR,
fp_ceiling: float = FALSE_POSITIVE_CEILING,
) -> DetectionGateVerdict:
"""Evaluate the hard recall/FP gate. No score input — it cannot be unblocked by score."""
overall = DetectionMetrics(0, 0, 0, 0)
for metrics in per_language.values():
overall = overall + metrics
reasons: list[str] = []
for language, metrics in sorted(per_language.items()):
if metrics.recall < recall_floor:
reasons.append(f"{language} recall {metrics.recall:.2%} below {recall_floor:.0%} ({metrics.matched}/{metrics.expected})")
if metrics.false_positive_rate >= fp_ceiling:
reasons.append(
f"{language} FP rate {metrics.false_positive_rate:.2%} at or above {fp_ceiling:.0%} "
f"({metrics.false_positives}/{metrics.actual})"
)
if overall.recall < recall_floor:
reasons.append(f"overall recall {overall.recall:.2%} below {recall_floor:.0%} ({overall.matched}/{overall.expected})")
if overall.false_positive_rate >= fp_ceiling:
reasons.append(
f"overall FP rate {overall.false_positive_rate:.2%} at or above {fp_ceiling:.0%} ({overall.false_positives}/{overall.actual})"
)
return DetectionGateVerdict(
passed=not reasons,
overall=overall,
per_language=dict(sorted(per_language.items())),
recall_floor=recall_floor,
fp_ceiling=fp_ceiling,
reasons=reasons,
)
def merge_gate(detection: DetectionGateVerdict, score_gate: Mapping[str, object] | None = None) -> dict[str, object]:
"""Compose the hard detection gate with the (advisory-first) score gate by AND.
The merge blocks if detection fails OR a *blocking* score gate fails. A passing
score gate (however high the score) can never flip a failed detection gate to
pass — the recall/FP constraint is hard and never folded into the score reward.
"""
score_passed = True if score_gate is None else bool(score_gate.get("passed", True))
return {
"passed": detection.passed and score_passed,
"detection_passed": detection.passed,
"score_passed": score_passed,
"reasons": list(detection.reasons),
}
def within_tolerance(
baseline: DetectionMetrics,
candidate: DetectionMetrics,
*,
recall_tolerance: float = 0.05,
fp_tolerance: float = 0.05,
) -> bool:
"""True if ``candidate`` recall/FP stay within tolerance of ``baseline``.
Used to confirm a HarnessX-backed stage does not drift detection beyond the
configured band of the zero-dependency baseline. Recall may not fall more than
``recall_tolerance`` below baseline; FP may not rise more than ``fp_tolerance``
above it.
"""
recall_ok = candidate.recall >= baseline.recall - recall_tolerance
fp_ok = candidate.false_positive_rate <= baseline.false_positive_rate + fp_tolerance
return recall_ok and fp_ok
def main() -> int:
verdict = detection_gate(evaluate_corpus())
o = verdict.overall
print(
f"detection gate: recall {o.recall:.2%} ({o.matched}/{o.expected}), FP {o.false_positive_rate:.2%} ({o.false_positives}/{o.actual})"
)
for language, m in verdict.per_language.items():
print(
f" {language:<11} recall {m.recall:>6.2%} ({m.matched}/{m.expected}) "
f"FP {m.false_positive_rate:>6.2%} ({m.false_positives}/{m.actual})"
)
if verdict.passed:
print("detection gate: PASS")
return 0
print("detection gate: FAIL", file=sys.stderr)
for reason in verdict.reasons:
print(f" - {reason}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+8 -2
View File
@@ -62,13 +62,19 @@ class SerializedHarnessConfig:
class HarnessTraceWriter: class HarnessTraceWriter:
def __init__(self, path: Path) -> None: def __init__(self, path: Path, *, redact: bool = True) -> None:
self.path = path self.path = path
self.redact = redact
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
def append(self, event: HarnessEvent) -> None: def append(self, event: HarnessEvent) -> None:
line = json.dumps(asdict(event), sort_keys=True)
if self.redact:
from .redaction import redact_secrets
line = redact_secrets(line)
with self.path.open("a") as handle: with self.path.open("a") as handle:
handle.write(json.dumps(asdict(event), sort_keys=True) + "\n") handle.write(line + "\n")
class HarnessRuntime: class HarnessRuntime:
+97
View File
@@ -0,0 +1,97 @@
"""Capability guard for the optional HarnessX agentic plane.
HarnessX is an optional extra (``pip install 'openultrasast[harnessx]'``). The core
install is zero-dependency, so nothing here imports ``harnessx`` at module load:
:func:`has_harnessx` only probes importability via ``find_spec`` and
:func:`require_harnessx` performs the import lazily, on demand. Every HarnessX-backed
code path (Phase 3+: build_sast, the hunter sub-harness, the llm-judge verifier, the
MetaAgent.evolve loop) routes through this seam.
"""
from __future__ import annotations
import importlib
import importlib.util
from types import ModuleType
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from harnessx.core.harness import HarnessConfig
HARNESSX_EXTRA = "openultrasast[harnessx]"
# HarnessX providers OpenUltraSAST can drive for the hunter / llm-judge stages.
# Each reads its own standard API-key env var (ANTHROPIC_API_KEY / OPENAI_API_KEY).
SUPPORTED_PROVIDERS = ("anthropic", "openai", "litellm")
class HarnessXUnavailableError(RuntimeError):
"""Raised when a HarnessX-backed capability is requested but the extra is absent."""
def has_harnessx() -> bool:
"""Return True if the HarnessX extra is importable, without importing it."""
return importlib.util.find_spec("harnessx") is not None
def require_harnessx() -> ModuleType:
"""Lazily import and return the ``harnessx`` module, or raise with install guidance."""
if not has_harnessx():
raise HarnessXUnavailableError(f"HarnessX is not installed. Install the optional agentic extra: pip install '{HARNESSX_EXTRA}'")
return importlib.import_module("harnessx")
def build_provider(model: str, provider: str = "anthropic") -> Any:
"""Build a HarnessX LLM provider for ``model`` (lazy, capability-guarded).
The single place that knows HarnessX provider class names. The provider name is
validated before any import so an unknown choice fails loudly even when the extra
is absent; each provider reads its own standard API-key env var. Raises
:class:`ValueError` on an unknown provider and :class:`HarnessXUnavailableError`
when the extra is not installed.
"""
key = provider.lower()
if key not in SUPPORTED_PROVIDERS:
raise ValueError(f"unknown HarnessX provider {provider!r}; choose one of {SUPPORTED_PROVIDERS}")
require_harnessx()
if key == "anthropic":
from harnessx.providers.anthropic_provider import AnthropicProvider
return AnthropicProvider(model=model)
if key == "openai":
from harnessx.providers.openai_provider import OpenAIProvider
return OpenAIProvider(model=model)
from harnessx.providers.litellm_provider import LiteLLMProvider
return LiteLLMProvider(model=model)
def build_sast(*, max_cost_usd: float = 2.0, token_threshold: int = 120_000) -> HarnessConfig:
"""Compose the SAST hunter HarnessConfig (behaviour pipeline, model-free).
The single, strictly-localized HarnessX composition site (mirrors DocuHarnessX
``make_docgen``): every ``harnessx`` import is inside this function so importing
this module never touches the optional extra. Raises :class:`HarnessXUnavailableError`
when the extra is absent. The per-task step budget is enforced on the task at run
time, not composed here.
"""
require_harnessx()
from harnessx.bundles.context import make_context
from harnessx.bundles.control import make_control
from harnessx.bundles.tools import make_tools
from harnessx.core.builder import HarnessBuilder
from harnessx.processors.context.strategies.system_prompt.default import DefaultSystemPromptBuilder
builder = (
HarnessBuilder()
| make_context(system_builder=DefaultSystemPromptBuilder())
| make_tools(skill_loading=False)
| make_control(
include_reliability=True,
include_budget=True,
token_threshold=token_threshold,
max_cost_usd=max_cost_usd,
)
)
return builder.build()
+24 -17
View File
@@ -7,8 +7,10 @@ from pathlib import Path
from typing import Literal from typing import Literal
from .findings import StaticFinding, quick_scan_findings from .findings import StaticFinding, quick_scan_findings
from .policy import CwePolicy
from .preprocess import FileTarget from .preprocess import FileTarget
from .rank import RankingScore from .rank import RankingScore
from .ruleset import PatternRule
HunterTier = Literal["A", "B", "C"] HunterTier = Literal["A", "B", "C"]
@@ -98,13 +100,21 @@ def schedule_hunter_tasks(
return scheduled return scheduled
def run_hunter_pool(root: Path, targets: list[FileTarget], rankings: list[RankingScore], *, scan_id: str) -> HunterPoolResult: def run_hunter_pool(
root: Path,
targets: list[FileTarget],
rankings: list[RankingScore],
*,
scan_id: str,
ruleset: tuple[PatternRule, ...] | None = None,
policy: dict[str, CwePolicy] | None = None,
) -> HunterPoolResult:
tasks = schedule_hunter_tasks(targets, rankings, retrieval_context_by_path=build_retrieval_context_by_path(root, targets)) tasks = schedule_hunter_tasks(targets, rankings, retrieval_context_by_path=build_retrieval_context_by_path(root, targets))
findings: list[StaticFinding] = [] findings: list[StaticFinding] = []
trajectories: list[HunterTrajectory] = [] trajectories: list[HunterTrajectory] = []
max_workers = min(8, max(1, len(tasks))) max_workers = min(8, max(1, len(tasks)))
with ThreadPoolExecutor(max_workers=max_workers) as executor: with ThreadPoolExecutor(max_workers=max_workers) as executor:
for task_findings, trajectory in executor.map(lambda task: _run_hunter_task(root, scan_id, task), tasks): for task_findings, trajectory in executor.map(lambda task: _run_hunter_task(root, scan_id, task, ruleset, policy), tasks):
findings.extend(task_findings) findings.extend(task_findings)
trajectories.append(trajectory) trajectories.append(trajectory)
return HunterPoolResult(findings=sorted(findings, key=lambda item: item.finding_id), trajectories=trajectories) return HunterPoolResult(findings=sorted(findings, key=lambda item: item.finding_id), trajectories=trajectories)
@@ -125,23 +135,20 @@ def build_retrieval_context_by_path(root: Path, targets: list[FileTarget], *, ma
def select_skill_snippets(target: FileTarget, *, max_snippets: int = 4) -> list[str]: def select_skill_snippets(target: FileTarget, *, max_snippets: int = 4) -> list[str]:
tags = set(target.tags) """Route the target to its most relevant Trail of Bits skills (ids), via the skill index."""
snippets: list[str] = [] from .skills import route_skill_ids
if target.language in {"c", "cpp", "c++"} or "memory_unsafe" in tags:
snippets.extend(["address-sanitizer", "libfuzzer", "harness-writing"]) return route_skill_ids(target, max_snippets=max_snippets)
if "parser" in tags or "fuzzable" in tags:
snippets.extend(["fuzzing-dictionary", "fuzzing-obstacles"])
if "crypto" in tags:
snippets.extend(["constant-time-analysis", "wycheproof"])
if "auth_boundary" in tags or "network_entry" in tags:
snippets.extend(["sharp-edges", "insecure-defaults"])
if "deserialization" in tags:
snippets.append("semgrep-rule-creator")
return sorted(dict.fromkeys(snippets))[:max_snippets]
def _run_hunter_task(root: Path, scan_id: str, task: HunterTask) -> tuple[list[StaticFinding], HunterTrajectory]: def _run_hunter_task(
findings = quick_scan_findings(root, [task.target], [task.ranking])[: task.budget.max_findings_per_target] root: Path,
scan_id: str,
task: HunterTask,
ruleset: tuple[PatternRule, ...] | None = None,
policy: dict[str, CwePolicy] | None = None,
) -> tuple[list[StaticFinding], HunterTrajectory]:
findings = quick_scan_findings(root, [task.target], [task.ranking], ruleset, policy)[: task.budget.max_findings_per_target]
return findings, HunterTrajectory( return findings, HunterTrajectory(
scan_id=scan_id, scan_id=scan_id,
target_path=task.target.path, target_path=task.target.path,
+197
View File
@@ -0,0 +1,197 @@
"""HarnessX-backed hunter pool — a real LLM agent loop per ranked target.
A drop-in for :func:`hunter.run_hunter_pool` (same call shape, returns the same
:class:`hunter.HunterPoolResult`) that runs the optional HarnessX agentic plane. It
is lazy/guarded: importing this module never imports ``harnessx``; the extra is only
touched once :meth:`HxScanOrchestrator.run_pool` runs. The agent ``.run()`` path makes
LLM API calls and is therefore exercised only with the extra + credentials present;
the default scan keeps using the deterministic :func:`hunter.run_hunter_pool`.
"""
from __future__ import annotations
import json
import re
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .findings import SEVERITY_LABEL, StaticFinding, _reachability_conditions, _reachability_for_line
from .harness_ext import build_provider, build_sast, require_harnessx
from .hunter import (
HunterPoolResult,
HunterTask,
HunterTrajectory,
build_retrieval_context_by_path,
schedule_hunter_tasks,
)
from .policy import CwePolicy, load_policy, resolve_severity
from .preprocess import FileTarget
from .rank import RankingScore
from .ruleset import PatternRule
# The hunter agent is asked to emit findings as a JSON array of these objects.
_FINDINGS_JSON = re.compile(r"\[\s*\{.*\}\s*\]", re.DOTALL)
@dataclass(frozen=True)
class ScanTask:
"""Thin description of one bounded hunter task (wraps, not subclasses, BaseTask)."""
description: str
success_criteria: str
max_steps: int
token_budget: int | None
max_cost_usd: float | None
class HxScanOrchestrator:
"""Runs the hunter pool on a real HarnessX agent loop, one bounded task per target."""
def __init__(
self,
*,
provider_model: str,
provider: str = "anthropic",
max_cost_usd: float = 2.0,
token_threshold: int = 120_000,
) -> None:
require_harnessx()
self._provider_model = provider_model
self._provider = provider
self._max_cost_usd = max_cost_usd
self._token_threshold = token_threshold
self._agent: Any = None
def _build_agent(self) -> Any:
from harnessx.core.model_config import ModelConfig
config = build_sast(max_cost_usd=self._max_cost_usd, token_threshold=self._token_threshold)
return ModelConfig(main=build_provider(self._provider_model, self._provider)).agentic(config)
def run_pool(
self,
root: Path,
targets: list[FileTarget],
rankings: list[RankingScore],
*,
scan_id: str,
ruleset: tuple[PatternRule, ...] | None = None,
policy: dict[str, CwePolicy] | None = None,
emit: Callable[..., None] | None = None,
) -> HunterPoolResult:
import asyncio
tasks = schedule_hunter_tasks(targets, rankings, retrieval_context_by_path=build_retrieval_context_by_path(root, targets))
resolved_policy = policy if policy is not None else load_policy()
self._agent = self._agent or self._build_agent()
return asyncio.run(self._run_all(scan_id, tasks, resolved_policy, emit))
async def _run_all(
self,
scan_id: str,
tasks: list[HunterTask],
policy: dict[str, CwePolicy],
emit: Callable[..., None] | None,
) -> HunterPoolResult:
from harnessx.core.harness import BaseTask
findings: list[StaticFinding] = []
trajectories: list[HunterTrajectory] = []
agent = self._agent
for task in tasks:
base_task = BaseTask(
description=self._describe(task),
success_criteria="Enumerate each reachable tainted sink with file:line and a one-line rationale.",
max_steps=task.budget.max_findings_per_target,
token_budget=task.budget.retrieval_budget_chars * 4,
max_cost_usd=self._max_cost_usd,
)
result = await agent.run(base_task, session_id=scan_id)
task_findings = self._findings_from_result(task, result, policy)
findings.extend(task_findings)
trajectories.append(self._trajectory_from_result(scan_id, task, result, task_findings))
if emit is not None:
emit(
"stage_end",
f"hunter_pool:{task.target.path}",
{
"exit_reason": getattr(result, "exit_reason", "unknown"),
"total_steps": getattr(result, "total_steps", 0),
"total_cost_usd": getattr(result, "total_cost_usd", 0.0),
},
)
await agent.cleanup()
return HunterPoolResult(findings=sorted(findings, key=lambda item: item.finding_id), trajectories=trajectories)
def _describe(self, task: HunterTask) -> str:
from .skills import select_skill_context
context = task.retrieval_context or f"[repo_code] {task.target.path}"
skills = select_skill_context(task.target, stage="hunt", budget_chars=task.budget.skill_budget_chars)
skill_block = f"\n\nRelevant security skills:\n{skills}" if skills else ""
return (
f"Audit `{task.target.path}` ({task.target.language}) for security-relevant vulnerabilities. "
f"Report ONLY evidence-backed findings as a JSON array, each object: "
f'{{"line": <int>, "title": <str>, "cwe": "CWE-NN" (optional), "rationale": <str>}}. '
f"Emit `[]` if none.{skill_block}\n\nSource context:\n{context}"
)
def _findings_from_result(self, task: HunterTask, result: object, policy: dict[str, CwePolicy]) -> list[StaticFinding]:
items = _extract_findings(getattr(result, "final_output", "") or "")
findings: list[StaticFinding] = []
for item in items:
line = item.get("line")
line = int(line) if isinstance(line, int) else None
reachability_status, function_name, reachability_evidence = (
_reachability_for_line(task.target, line) if line is not None else ("unknown", None, [])
)
cwe = str(item.get("cwe", "")) or None
severity = SEVERITY_LABEL.get(resolve_severity(policy, cwe), "medium") if cwe else "medium"
line_part = line if line is not None else 0
findings.append(
StaticFinding(
finding_id=f"hx-hunter:{task.target.path}:{line_part}",
path=task.target.path,
title=str(item.get("title", "HarnessX hunter finding")),
severity=severity,
confidence="medium",
evidence_level="static_corroboration",
rationale=str(item.get("rationale", "Reported by the HarnessX hunter; manual verification required.")),
line=line,
function_name=function_name,
reachability_status=reachability_status,
reachability_evidence=reachability_evidence,
reachability_conditions=_reachability_conditions(reachability_evidence),
tags=sorted(set(task.target.tags)),
ranking_priority=task.ranking.priority,
)
)
return findings
def _trajectory_from_result(
self, scan_id: str, task: HunterTask, result: object, task_findings: list[StaticFinding]
) -> HunterTrajectory:
return HunterTrajectory(
scan_id=scan_id,
target_path=task.target.path,
tier=task.tier,
priority=task.ranking.priority,
selected_skills=task.selected_skills,
retrieval_context_chars=len(task.retrieval_context),
findings=[finding.finding_id for finding in task_findings],
status=str(getattr(result, "exit_reason", "completed")),
)
def _extract_findings(text: str) -> list[dict[str, object]]:
"""Best-effort extraction of the findings JSON array from the agent's final output."""
match = _FINDINGS_JSON.search(text)
if match is None:
return []
try:
parsed = json.loads(match.group(0))
except json.JSONDecodeError:
return []
return [item for item in parsed if isinstance(item, dict)] if isinstance(parsed, list) else []
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
import os
import re
from collections.abc import Iterable, Iterator, Mapping
from pathlib import Path
from .preprocess import enumerate_source_files
class PathEscapesRepo(ValueError):
"""Raised when a resolved tool path is not under the scan root."""
def clamp_repo_path(root: Path, user_path: str) -> Path:
resolved_root = root.resolve()
candidate = Path(user_path)
resolved = candidate.resolve() if candidate.is_absolute() else (resolved_root / candidate).resolve()
try:
common = os.path.commonpath([str(resolved_root), str(resolved)])
except ValueError as exc:
raise PathEscapesRepo(f"path {user_path!r} escapes scan root {resolved_root}") from exc
if common != str(resolved_root):
raise PathEscapesRepo(f"path {user_path!r} escapes scan root {resolved_root}")
return resolved
def read_file(root: Path, path: str, *, max_chars: int) -> str:
clamped = clamp_repo_path(root, path)
if max_chars <= 0:
return ""
return clamped.read_text(errors="ignore")[:max_chars]
def grep_repo(root: Path, pattern: str, *, max_matches: int) -> list[dict[str, object]]:
if max_matches <= 0:
return []
compiled = re.compile(pattern)
matches: list[dict[str, object]] = []
for relative, path in _iter_clamped_source_files(root):
for line_no, line in enumerate(path.read_text(errors="ignore").splitlines(), start=1):
if compiled.search(line):
matches.append({"path": relative, "line": line_no, "text": line})
if len(matches) >= max_matches:
return matches
return matches
def find_refs(root: Path, symbol: str, mapping_index: object) -> list[dict[str, object]]:
resolved_root = root.resolve()
refs: list[dict[str, object]] = []
for record in _iter_mapping_records(mapping_index):
if not _record_matches_symbol(record, symbol):
continue
raw_path = _record_path(record)
if raw_path is None:
continue
clamped = clamp_repo_path(resolved_root, raw_path)
name = _record_value(record, "name", "function_name", "symbol")
refs.append(
{
"path": clamped.relative_to(resolved_root).as_posix(),
"name": str(name) if name is not None else symbol,
"line": _as_line(_record_value(record, "line")),
"source": "mapping",
}
)
if symbol:
compiled = re.compile(rf"\b{re.escape(symbol)}\b")
for relative, path in _iter_clamped_source_files(resolved_root):
for line_no, line in enumerate(path.read_text(errors="ignore").splitlines(), start=1):
if compiled.search(line):
refs.append({"path": relative, "name": symbol, "line": line_no, "text": line, "source": "text"})
return refs
def _iter_clamped_source_files(root: Path) -> Iterator[tuple[str, Path]]:
resolved_root = root.resolve()
for path in enumerate_source_files(resolved_root):
relative = path.relative_to(resolved_root).as_posix()
yield relative, clamp_repo_path(resolved_root, relative)
def _iter_mapping_records(mapping_index: object) -> Iterable[object]:
if mapping_index is None:
return ()
if isinstance(mapping_index, Mapping):
return (mapping_index,)
if isinstance(mapping_index, (str, bytes, bytearray)):
return ()
if isinstance(mapping_index, Iterable):
return mapping_index
return (mapping_index,)
def _record_matches_symbol(record: object, symbol: str) -> bool:
for field in ("name", "function_name", "symbol"):
value = _record_value(record, field)
if value is not None and str(value) == symbol:
return True
return False
def _record_path(record: object) -> str | None:
value = record.get("path") if isinstance(record, Mapping) else getattr(record, "path", None)
if value is None:
return None
return str(value)
def _record_value(record: object, *names: str) -> object | None:
for name in names:
raw: object | None
if isinstance(record, Mapping):
if name not in record:
continue
raw = record[name]
else:
raw = getattr(record, name, None)
if raw is not None:
return raw
return None
def _as_line(value: object) -> int | None:
if isinstance(value, bool) or value is None:
return None
if isinstance(value, int):
return value
return None
+30
View File
@@ -0,0 +1,30 @@
"""Bounded, gated self-improvement loop over the ruleset/policy governance data."""
from .evolve import RoundOutcome, build_rule_signals, propose_status_edits, run_improvement, run_round
from .journal import load_journal, reverted_edit_keys
from .validator import (
VALID_LEVERS,
EvolveBounds,
EvolveValidator,
PolicyConstantEdit,
RuleStatusEdit,
StrictValidationError,
edits_to_ledger,
)
__all__ = [
"EvolveBounds",
"EvolveValidator",
"PolicyConstantEdit",
"RoundOutcome",
"RuleStatusEdit",
"StrictValidationError",
"VALID_LEVERS",
"build_rule_signals",
"edits_to_ledger",
"load_journal",
"propose_status_edits",
"reverted_edit_keys",
"run_improvement",
"run_round",
]
+267
View File
@@ -0,0 +1,267 @@
"""Deterministic self-improvement loop (tasks 7.4, 7.5, 7.6).
One round: benchmark with the current ledger -> per-rule signals -> propose bounded
edits (auto-shadow precision-draggers) -> validate -> replay smoke -> re-benchmark ->
hard acceptance gate (recall >= floor AND FP < ceiling AND project score not regressed
AND no matched-finding regression) -> accept (persist the ledger) or revert byte-for-byte.
The edit *proposer* here is the benchmark's per-rule signal; the LLM ``MetaAgent.evolve``
proposer plugs into this same validated/gated machinery when the HarnessX extra is present.
The project score is the optimization reward; the recall/FP gate is a separate hard
feasibility constraint that is never folded into the reward.
"""
from __future__ import annotations
import tempfile
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from ..benchmark import BenchmarkManifest, BenchmarkResult, BenchmarkRun, evaluate_benchmark
from ..findings import StaticFinding, quick_scan_findings
from ..mapping import analyze_entry_points, attach_reachability_hints
from ..policy import CwePolicy, load_policy
from ..preprocess import preprocess_repository
from ..rank import rank_targets
from ..ruleset import DEFAULT_RULESET_DIR, PatternRule, load_ruleset, read_rule_ledger, write_rule_ledger
from ..scoring import build_score_artifact
from .journal import append_round, load_journal, next_round_index, reverted_edit_keys
from .validator import EvolveValidator, RuleStatusEdit, StrictValidationError, edits_to_ledger
@dataclass(frozen=True)
class RoundOutcome:
round: int
accepted: bool
reason: str
edits: list[RuleStatusEdit] = field(default_factory=list)
recall_before: float = 0.0
recall_after: float = 0.0
fp_before: float = 0.0
fp_after: float = 0.0
score_before: int = 0
score_after: int = 0
matched_before: int = 0
matched_after: int = 0
def build_rule_signals(result: BenchmarkResult) -> list[dict[str, object]]:
"""Convert a benchmark result into per-rule loop signals (recall: miss; precision: fp)."""
signals: list[dict[str, object]] = []
for miss in result.misses:
signals.append({"rule_id": miss.rule_id or "", "signal": "miss", "cwe": miss.cwe, "path": miss.path})
for fp in result.false_positives:
signals.append({"rule_id": fp.rule_id, "signal": "fp", "path": fp.path, "line": fp.line})
return sorted(signals, key=lambda item: (str(item.get("signal")), str(item.get("rule_id")), str(item.get("path"))))
def propose_status_edits(
per_rule: Mapping[str, dict[str, int]],
ruleset_by_id: Mapping[str, PatternRule],
current_ledger: Mapping[str, dict[str, object]],
blocked_keys: set[str],
) -> list[RuleStatusEdit]:
"""Auto-shadow precision-draggers: enabled rules with false positives -> shadow (never delete)."""
edits: list[RuleStatusEdit] = []
for rule_id, counts in sorted(per_rule.items()):
if counts.get("false_positives", 0) <= 0:
continue
rule = ruleset_by_id.get(rule_id)
if rule is None:
continue
current_status = str(current_ledger.get(rule_id, {}).get("status", rule.status))
if current_status != "enabled":
continue # already staged or disabled
edit = RuleStatusEdit(rule_id=rule_id, from_status="enabled", to_status="shadow", rationale="")
if edit.key() in blocked_keys:
continue # novelty gate: previously reverted without a new rationale
edits.append(edit)
return edits
def run_round(
target: Path,
manifest: BenchmarkManifest,
*,
ledger_path: Path,
journal_path: Path,
ruleset_dir: Path = DEFAULT_RULESET_DIR,
policy: dict[str, CwePolicy] | None = None,
recall_floor: float = 0.9,
fp_ceiling: float = 0.1,
) -> RoundOutcome:
policy = policy if policy is not None else load_policy()
rounds = load_journal(journal_path)
round_index = next_round_index(rounds)
blocked = reverted_edit_keys(rounds)
current_ledger = read_rule_ledger(ledger_path)
before_findings, before_rules = _scan(target, ruleset_dir, current_ledger, policy)
before = _evaluate(target, manifest, before_findings, before_rules, policy)
(ledger_path.parent / "rule_signals.json").parent.mkdir(parents=True, exist_ok=True)
_write_signals(ledger_path.parent / "rule_signals.json", build_rule_signals(before.result))
ruleset_by_id = {rule.rule_id: rule for rule in before_rules}
edits = propose_status_edits(before.result.metrics.per_rule, ruleset_by_id, current_ledger, blocked)
if not edits:
return _outcome(round_index, accepted=False, reason="no_proposals", edits=[], before=before, after=before)
validator = EvolveValidator()
try:
for edit in edits:
validator.validate(edit, ruleset_by_id, policy)
except StrictValidationError as exc:
_record(journal_path, round_index, edits, "rejected", before, before, str(exc))
return _outcome(round_index, accepted=False, reason=f"validation_failed: {exc}", edits=edits, before=before, after=before)
candidate = edits_to_ledger(edits, current_ledger)
try:
_load_with(ruleset_dir, candidate) # replay smoke gate: candidate ruleset must boot
except Exception as exc: # noqa: BLE001 — any boot failure rejects the round
_record(journal_path, round_index, edits, "rejected", before, before, f"replay_failed: {exc}")
return _outcome(round_index, accepted=False, reason="replay_failed", edits=edits, before=before, after=before)
after_findings, after_rules = _scan(target, ruleset_dir, candidate, policy)
after = _evaluate(target, manifest, after_findings, after_rules, policy)
accepted = (
after.recall >= recall_floor and after.fp_rate < fp_ceiling and after.score >= before.score and after.matched >= before.matched
)
if accepted:
write_rule_ledger(ledger_path, candidate)
reason = "accepted"
else:
reason = "reverted" # ledger_path untouched -> byte-for-byte revert
_record(journal_path, round_index, edits, reason, before, after)
return _outcome(round_index, accepted=accepted, reason=reason, edits=edits, before=before, after=after)
def run_improvement(
target: Path,
manifest: BenchmarkManifest,
*,
ledger_path: Path,
journal_path: Path,
ruleset_dir: Path = DEFAULT_RULESET_DIR,
policy: dict[str, CwePolicy] | None = None,
max_rounds: int = 5,
recall_floor: float = 0.9,
fp_ceiling: float = 0.1,
) -> list[RoundOutcome]:
"""Run improvement rounds until convergence (no new proposals) or ``max_rounds``."""
outcomes: list[RoundOutcome] = []
for _ in range(max_rounds):
outcome = run_round(
target,
manifest,
ledger_path=ledger_path,
journal_path=journal_path,
ruleset_dir=ruleset_dir,
policy=policy,
recall_floor=recall_floor,
fp_ceiling=fp_ceiling,
)
outcomes.append(outcome)
if outcome.reason == "no_proposals":
break
return outcomes
@dataclass(frozen=True)
class _Eval:
result: BenchmarkResult
recall: float
fp_rate: float
score: int
matched: int
def _scan(
target: Path, ruleset_dir: Path, ledger: Mapping[str, dict[str, object]], policy: dict[str, CwePolicy]
) -> tuple[list[StaticFinding], tuple[PatternRule, ...]]:
rules = _load_with(ruleset_dir, ledger)
_, targets = preprocess_repository(target)
targets = attach_reachability_hints(targets, analyze_entry_points(target, targets))
rankings = rank_targets(targets)
findings = quick_scan_findings(target, targets, rankings, rules, policy)
reported = [finding for finding in findings if finding.status != "shadow"]
return reported, rules
def _evaluate(
target: Path, manifest: BenchmarkManifest, findings: list[StaticFinding], rules: tuple[PatternRule, ...], policy: dict[str, CwePolicy]
) -> _Eval:
run = BenchmarkRun(benchmark_run_id="improve", root=target, manifest=manifest)
result = evaluate_benchmark(run=run, mode="quick", findings=findings, scan_id=None, scan_run_dir=None)
metrics = result.metrics
cwe_by_rule = {rule.rule_id: rule.cwe for rule in rules}
rule_cwe_by_id = {f.finding_id: cwe_by_rule.get(f.finding_id.split(":", 1)[0], "") for f in findings}
score = build_score_artifact(findings, rule_cwe_by_id, policy).project_score
recall = metrics.recall if metrics.recall is not None else 1.0
fp_rate = metrics.false_positive_findings_total / metrics.actual_findings_total if metrics.actual_findings_total else 0.0
return _Eval(result=result, recall=recall, fp_rate=fp_rate, score=score, matched=metrics.matched_findings_total)
def _load_with(ruleset_dir: Path, ledger: Mapping[str, dict[str, object]]) -> tuple[PatternRule, ...]:
import json
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
handle.write(json.dumps(ledger))
temp_path = Path(handle.name)
try:
return load_ruleset(ruleset_dir, temp_path)
finally:
temp_path.unlink(missing_ok=True)
def _write_signals(path: Path, signals: list[dict[str, object]]) -> None:
import json
path.write_text(json.dumps(signals, indent=2, sort_keys=True) + "\n")
def _outcome(round_index: int, *, accepted: bool, reason: str, edits: list[RuleStatusEdit], before: _Eval, after: _Eval) -> RoundOutcome:
return RoundOutcome(
round=round_index,
accepted=accepted,
reason=reason,
edits=edits,
recall_before=before.recall,
recall_after=after.recall,
fp_before=before.fp_rate,
fp_after=after.fp_rate,
score_before=before.score,
score_after=after.score,
matched_before=before.matched,
matched_after=after.matched,
)
def _record(
journal_path: Path,
round_index: int,
edits: list[RuleStatusEdit],
outcome: str,
before: _Eval,
after: _Eval,
reason: str = "",
) -> None:
append_round(
journal_path,
{
"round": round_index,
"outcome": outcome,
"reason": reason or outcome,
"edits": [
{"key": e.key(), "lever": e.lever, "rule_id": e.rule_id, "from": e.from_status, "to": e.to_status, "rationale": e.rationale}
for e in edits
],
"recall_before": round(before.recall, 4),
"recall_after": round(after.recall, 4),
"fp_before": round(before.fp_rate, 4),
"fp_after": round(after.fp_rate, 4),
"score_before": before.score,
"score_after": after.score,
},
)
+51
View File
@@ -0,0 +1,51 @@
"""Improvement journal: per-round audit + novelty memory (task 7.3, 7.9).
Persistent JSON record of every self-improvement round so each rule/policy edit is
attributable and reversible, and so a previously-reverted edit is not re-proposed
without a new rationale (the novelty gate).
"""
from __future__ import annotations
import json
from pathlib import Path
def load_journal(path: Path) -> list[dict[str, object]]:
if not path.exists():
return []
payload = json.loads(path.read_text())
return payload if isinstance(payload, list) else []
def write_journal(path: Path, rounds: list[dict[str, object]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(rounds, indent=2, sort_keys=True) + "\n")
def reverted_edit_keys(rounds: list[dict[str, object]]) -> set[str]:
"""Edit keys from reverted rounds — blocked from re-proposal unless a new rationale appears."""
keys: set[str] = set()
for entry in rounds:
if entry.get("outcome") != "reverted":
continue
for edit in _edits(entry):
key = edit.get("key")
if isinstance(key, str) and not edit.get("rationale"):
keys.add(key)
return keys
def next_round_index(rounds: list[dict[str, object]]) -> int:
return len(rounds) + 1
def append_round(path: Path, entry: dict[str, object]) -> None:
rounds = load_journal(path)
rounds.append(entry)
write_journal(path, rounds)
def _edits(entry: dict[str, object]) -> list[dict[str, object]]:
edits = entry.get("edits")
return [edit for edit in edits if isinstance(edit, dict)] if isinstance(edits, list) else []
+121
View File
@@ -0,0 +1,121 @@
"""Bounded-lever edit model + validator for the self-improvement loop (tasks 7.1, 7.2).
The loop may only adjust governance *data* through two bounded levers and never the
two authorities a human/upstream owns: detection **pattern text** and the 0-5 CWE
**severity**. Edits are typed so pattern/severity changes are structurally
impossible; the validator additionally enforces status/bounds/staging/resolution.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from ..policy import CwePolicy
from ..ruleset import PatternRule
from ..ruleset.store import VALID_STATUS
# The only levers the loop may pull (mirrors the design's journal._VALID_LEVERS).
VALID_LEVERS = frozenset({"rule", "policy"})
# Policy constants the loop may tune; the 0-5 severity is intentionally absent.
TUNABLE_POLICY_CONSTANTS = frozenset({"K", "MIN_SCORE"})
class StrictValidationError(ValueError):
"""Raised when a proposed loop edit violates the bounded-lever contract."""
def __init__(self, message: str, *, kind: str = "rule_change") -> None:
super().__init__(message)
self.kind = kind
@dataclass(frozen=True)
class RuleStatusEdit:
"""A `rule`-lever edit: change a rule's status / evidence floor / precision estimate."""
rule_id: str
from_status: str
to_status: str
rationale: str = ""
lever: str = "rule"
def key(self) -> str:
return f"rule:{self.rule_id}:{self.from_status}->{self.to_status}"
@dataclass(frozen=True)
class PolicyConstantEdit:
"""A `policy`-lever edit: tune a score constant (K / MIN_SCORE). Never severity."""
name: str
from_value: float
to_value: float
rationale: str = ""
lever: str = "policy"
def key(self) -> str:
return f"policy:{self.name}:{self.from_value}->{self.to_value}"
@dataclass(frozen=True)
class EvolveBounds:
k_range: tuple[float, float] = (20.0, 200.0)
min_score_range: tuple[int, int] = (0, 100)
class EvolveValidator:
"""Enforces the bounded-lever safety contract; raises StrictValidationError on any breach."""
def __init__(self, bounds: EvolveBounds | None = None) -> None:
self._bounds = bounds or EvolveBounds()
def validate(self, edit: object, ruleset_by_id: Mapping[str, PatternRule], policy: Mapping[str, CwePolicy]) -> None:
if isinstance(edit, RuleStatusEdit):
self._validate_rule(edit, ruleset_by_id, policy)
elif isinstance(edit, PolicyConstantEdit):
self._validate_policy(edit)
else:
raise StrictValidationError(f"unknown edit type {type(edit).__name__}", kind="rule_change")
def _validate_rule(self, edit: RuleStatusEdit, ruleset_by_id: Mapping[str, PatternRule], policy: Mapping[str, CwePolicy]) -> None:
if edit.to_status not in VALID_STATUS:
raise StrictValidationError(f"invalid status {edit.to_status!r} for {edit.rule_id}")
rule = ruleset_by_id.get(edit.rule_id)
if rule is None:
raise StrictValidationError(f"edit targets unknown rule_id {edit.rule_id!r} (loop cannot add rules)")
if edit.from_status == "enabled" and edit.to_status == "disabled":
raise StrictValidationError(f"{edit.rule_id}: enabled->disabled jump is forbidden; stage via shadow first")
if rule.cwe not in policy:
raise StrictValidationError(f"{edit.rule_id}: CWE {rule.cwe} does not resolve in the policy")
def _validate_policy(self, edit: PolicyConstantEdit) -> None:
if edit.name not in TUNABLE_POLICY_CONSTANTS:
raise StrictValidationError(
f"policy constant {edit.name!r} is not loop-tunable (severity is upstream-owned)", kind="policy_change"
)
if edit.name == "K" and not (self._bounds.k_range[0] <= edit.to_value <= self._bounds.k_range[1]):
raise StrictValidationError(f"K={edit.to_value} out of bounds {self._bounds.k_range}", kind="policy_change")
if edit.name == "MIN_SCORE" and not (self._bounds.min_score_range[0] <= edit.to_value <= self._bounds.min_score_range[1]):
raise StrictValidationError(f"MIN_SCORE={edit.to_value} out of bounds {self._bounds.min_score_range}", kind="policy_change")
def edits_to_ledger(edits: list[RuleStatusEdit], base: Mapping[str, dict[str, object]]) -> dict[str, dict[str, object]]:
"""Overlay rule-status edits onto a copy of the base ledger."""
ledger: dict[str, dict[str, object]] = {key: dict(value) for key, value in base.items()}
for edit in edits:
entry = dict(ledger.get(edit.rule_id, {}))
entry["status"] = edit.to_status
ledger[edit.rule_id] = entry
return ledger
__all__ = [
"EvolveBounds",
"EvolveValidator",
"PolicyConstantEdit",
"RuleStatusEdit",
"StrictValidationError",
"TUNABLE_POLICY_CONSTANTS",
"VALID_LEVERS",
"edits_to_ledger",
]
+130
View File
@@ -0,0 +1,130 @@
"""Stage-2 quality gate: split-sink ranking must beat pattern-hit-count order.
The stage-1 smoke gate (``python -m openultrasast.gate``) stays on cheat-sheet
fixtures. This module is the merge gate for the complexity map: the planted
split-sink function must sit in the high band, and map order must not collapse
to sorting files by inventory-hit count.
"""
from __future__ import annotations
import sys
import tempfile
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from .benchmark import BenchmarkManifest, ExpectedFinding, load_benchmark_manifest, resolve_benchmark_source
from .complexity.map import ComplexityMap, Hotspot, build_complexity_map
from .findings import StaticFinding, quick_scan_findings
from .mapping import analyze_entry_points, attach_reachability_hints
from .preprocess import preprocess_repository
from .rank import rank_targets
SPLIT_SINK_MANIFESTS = (
"split-sink-c",
"split-sink-java",
"split-sink-javascript",
"split-sink-python",
)
DEFAULT_MANIFEST_DIR = Path("benchmarks/manifests")
@dataclass(frozen=True)
class SplitSinkScan:
name: str
manifest: BenchmarkManifest
target: Path
findings: tuple[StaticFinding, ...]
complexity_map: ComplexityMap
@dataclass(frozen=True)
class MapGateVerdict:
passed: bool
reasons: tuple[str, ...]
def scan_split_sink(name: str, manifest_dir: Path = DEFAULT_MANIFEST_DIR) -> SplitSinkScan:
manifest_path = manifest_dir / f"{name}.toml"
manifest = load_benchmark_manifest(manifest_path)
target = resolve_benchmark_source(manifest_path, manifest)
_, targets = preprocess_repository(target)
targets = attach_reachability_hints(targets, analyze_entry_points(target, targets))
findings = tuple(quick_scan_findings(target, targets, rank_targets(targets)))
with tempfile.TemporaryDirectory() as tmp:
complexity_map = build_complexity_map(
targets,
findings,
Path(tmp) / "complexity_map.json",
repo_files=[item.path for item in targets],
)
return SplitSinkScan(name=name, manifest=manifest, target=target, findings=findings, complexity_map=complexity_map)
def planted_expected(manifest: BenchmarkManifest) -> tuple[ExpectedFinding, ...]:
return tuple(item for item in manifest.expected if item.rule_id is None)
_BAND_RANK = {"high": 3, "medium": 2, "low": 1}
def planted_in_high_band(manifest: BenchmarkManifest, complexity_map: ComplexityMap) -> bool:
"""True when the planted sink sits in the highest band the map actually emitted."""
if not complexity_map.hotspots:
return False
top = max(_BAND_RANK.get(hotspot.band, 0) for hotspot in complexity_map.hotspots)
for expected in planted_expected(manifest):
matches = [hotspot for hotspot in complexity_map.hotspots if hotspot.path == expected.path]
if expected.function:
named = [hotspot for hotspot in matches if hotspot.function_name == expected.function]
if named:
matches = named
if not matches or not any(_BAND_RANK.get(hotspot.band, 0) == top for hotspot in matches):
return False
return bool(planted_expected(manifest))
def hit_count_path_order(findings: Sequence[StaticFinding], paths: Sequence[str]) -> list[str]:
counts: dict[str, int] = {}
for finding in findings:
counts[finding.path] = counts.get(finding.path, 0) + 1
unique = list(dict.fromkeys(paths))
return sorted(unique, key=lambda path: (-counts.get(path, 0), path))
def map_beats_hit_count(hotspots: Sequence[Hotspot], findings: Sequence[StaticFinding]) -> bool:
map_order = list(dict.fromkeys(hotspot.path for hotspot in hotspots))
stub_order = hit_count_path_order(findings, map_order)
return map_order != stub_order
def split_sink_map_gate(manifest_dir: Path = DEFAULT_MANIFEST_DIR) -> MapGateVerdict:
reasons: list[str] = []
python: SplitSinkScan | None = None
for name in SPLIT_SINK_MANIFESTS:
bundle = scan_split_sink(name, manifest_dir)
if name == "split-sink-python":
python = bundle
if not planted_in_high_band(bundle.manifest, bundle.complexity_map):
reasons.append(f"{name}: planted split-sink is not in the top hotspot band")
if python is None:
reasons.append("split-sink-python corpus is missing")
elif not map_beats_hit_count(python.complexity_map.hotspots, python.findings):
reasons.append("split-sink-python: map order equals pattern-hit-count order")
return MapGateVerdict(passed=not reasons, reasons=tuple(reasons))
def main() -> int:
verdict = split_sink_map_gate()
if verdict.passed:
print("split-sink map gate: PASS")
return 0
print("split-sink map gate: FAIL", file=sys.stderr)
for reason in verdict.reasons:
print(f" - {reason}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+372
View File
@@ -0,0 +1,372 @@
"""Narrow MCP server for OpenCode integration (Phase 12).
Exposes only stable, project-level operations over MCP stdio (newline-delimited
JSON-RPC 2.0), implemented with the standard library only. No tool exposes arbitrary
shell, Docker, or internal hunter tools — those stay inside controlled harness stages.
The pure :meth:`McpServer.handle` dispatch is separated from the :func:`serve` stdio
loop so the surface is unit-testable without a live client.
"""
from __future__ import annotations
import json
import sys
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import IO, Any, cast
PROTOCOL_VERSION = "2024-11-05"
SERVER_NAME = "openultrasast"
def _server_version() -> str:
try:
from importlib.metadata import version
return version("openultrasast")
except Exception: # noqa: BLE001 — version metadata is best-effort
return "0"
@dataclass(frozen=True)
class Tool:
name: str
description: str
input_schema: dict[str, Any]
handler: Callable[[dict[str, Any]], dict[str, Any]]
def descriptor(self) -> dict[str, Any]:
return {"name": self.name, "description": self.description, "inputSchema": self.input_schema}
class McpToolError(ValueError):
"""Raised by a tool handler when its inputs are invalid or the artifact is missing."""
# ---- tool handlers (each maps to one bounded project operation) --------------
def _require(args: dict[str, Any], key: str) -> Any:
if key not in args or args[key] in (None, ""):
raise McpToolError(f"missing required argument: {key}")
return args[key]
def _read_json(path: Path) -> dict[str, Any]:
if not path.exists():
raise McpToolError(f"artifact not found: {path}")
return cast(dict[str, Any], json.loads(path.read_text()))
def _scan(args: dict[str, Any]) -> dict[str, Any]:
from .cli import _run_scan # lazy: avoids an import cycle with the CLI dispatcher
path = Path(_require(args, "path"))
mode = str(args.get("mode", "quick"))
if mode not in ("quick", "standard"):
raise McpToolError(f"unsupported mode: {mode} (use quick or standard; deep is not exposed via MCP)")
fail_on = str(args.get("fail_on", "never"))
config = Path(str(args.get("config", "openultrasast.toml")))
outcome = _run_scan(path, config, mode, fail_on)
return {
"scan_id": outcome.scan_id,
"run_dir": str(outcome.run_dir),
"mode": mode,
"finding_count": outcome.finding_count,
"exit_code": outcome.exit_code,
}
def _status(args: dict[str, Any]) -> dict[str, Any]:
manifest = _read_json(Path(_require(args, "run_dir")) / "manifest.json")
return {
"scan_id": manifest.get("scan_id"),
"target": manifest.get("target"),
"finding_count": len(manifest.get("findings", [])),
"score": (manifest.get("score") or {}).get("project_score"),
"gate_passed": (manifest.get("score") or {}).get("gate", {}).get("passed"),
"degradations": manifest.get("degradations", []),
"fusion": manifest.get("fusion", []),
}
def _findings(args: dict[str, Any]) -> dict[str, Any]:
payload = _read_json(Path(_require(args, "run_dir")) / "findings.json")
items = [
{
"finding_id": finding["finding_id"],
"path": finding["path"],
"line": finding.get("line"),
"severity": finding["severity"],
"title": finding["title"],
}
for finding in payload.get("findings", [])
]
return {"findings": items, "count": len(items)}
def _load_finding(run_dir: Path, finding_id: str) -> dict[str, Any]:
for finding in _read_json(run_dir / "findings.json").get("findings", []):
if finding["finding_id"] == finding_id:
return cast(dict[str, Any], finding)
raise McpToolError(f"finding not found: {finding_id}")
def _get_finding(args: dict[str, Any]) -> dict[str, Any]:
run_dir = Path(_require(args, "run_dir"))
return _load_finding(run_dir, str(_require(args, "finding_id")))
def _load_verification(run_dir: Path, finding_id: str) -> dict[str, Any] | None:
path = run_dir / "verification.json"
if not path.exists():
return None
for result in _read_json(path).get("verifications", []):
if result["finding_id"] == finding_id:
return cast(dict[str, Any], result)
return None
def _evidence(args: dict[str, Any]) -> dict[str, Any]:
run_dir = Path(_require(args, "run_dir"))
finding_id = str(_require(args, "finding_id"))
finding = _load_finding(run_dir, finding_id)
return {
"finding_id": finding_id,
"evidence_level": finding.get("evidence_level"),
"reachability_status": finding.get("reachability_status"),
"reachability_evidence": finding.get("reachability_evidence", []),
"reachability_conditions": finding.get("reachability_conditions", []),
"verification": _load_verification(run_dir, finding_id),
}
def _artifacts(args: dict[str, Any]) -> dict[str, Any]:
manifest = _read_json(Path(_require(args, "run_dir")) / "manifest.json")
return {"artifacts": manifest.get("artifacts", {})}
def _benchmark(args: dict[str, Any]) -> dict[str, Any]:
from .benchmark import create_benchmark_run, evaluate_benchmark, load_benchmark_manifest, load_findings, resolve_benchmark_source
from .cli import _run_scan
manifest_path = Path(_require(args, "manifest"))
if not manifest_path.is_file():
raise McpToolError(f"benchmark manifest is not a file: {manifest_path}")
mode = str(args.get("mode", "quick"))
manifest = load_benchmark_manifest(manifest_path)
target = resolve_benchmark_source(manifest_path, manifest)
run = create_benchmark_run(target, manifest)
outcome = _run_scan(target, Path(str(args.get("config", "openultrasast.toml"))), mode, "never")
result = evaluate_benchmark(
run=run, mode=mode, findings=load_findings(outcome.run_dir / "findings.json"), scan_id=outcome.scan_id, scan_run_dir=outcome.run_dir
)
metrics = result.metrics
return {
"benchmark_run_id": run.benchmark_run_id,
"expected": metrics.expected_findings_total,
"matched": metrics.matched_findings_total,
"missed": metrics.missed_findings_total,
"false_positives": metrics.false_positive_findings_total,
"recall": metrics.recall,
}
def _explain(args: dict[str, Any]) -> dict[str, Any]:
run_dir = Path(_require(args, "run_dir"))
finding_id = str(_require(args, "finding_id"))
finding = _load_finding(run_dir, finding_id)
verification = _load_verification(run_dir, finding_id)
fusion_path = run_dir / "fusion.json"
fusion = None
if fusion_path.exists():
fusion = next((decision for decision in _read_json_list(fusion_path) if decision["finding_id"] == finding_id), None)
return {
"finding_id": finding_id,
"title": finding["title"],
"severity": finding["severity"],
"rationale": finding.get("rationale"),
"reachability_status": finding.get("reachability_status"),
"verification_status": verification["status"] if verification else "not_run",
"pro_case": verification["pro_case"] if verification else None,
"counter_case": verification["counter_case"] if verification else None,
"fusion_disposition": fusion["disposition"] if fusion else None,
}
def _read_json_list(path: Path) -> list[dict[str, Any]]:
data = json.loads(path.read_text())
return data if isinstance(data, list) else []
def _propose_patch(args: dict[str, Any]) -> dict[str, Any]:
_require(args, "run_dir")
_require(args, "finding_id")
# The patch oracle (sandboxed fix authoring + validation) is a later phase and is
# intentionally not exposed over MCP yet. Degrade visibly rather than silently.
return {
"status": "not_implemented",
"reason": "the patch oracle is a later phase; patch proposal is not exposed via MCP yet",
}
def _export_report(args: dict[str, Any]) -> dict[str, Any]:
run_dir = Path(_require(args, "run_dir"))
fmt = str(args.get("format", "markdown"))
filename = {"markdown": "report.md", "sarif": "report.sarif"}.get(fmt)
if filename is None:
raise McpToolError(f"unsupported report format: {fmt} (use markdown or sarif)")
path = run_dir / filename
if not path.exists():
raise McpToolError(f"report artifact not found: {path}")
return {"format": fmt, "path": str(path), "content": path.read_text()}
def _build_tools() -> list[Tool]:
run_dir_schema = {"run_dir": {"type": "string", "description": "scan run directory (from scan.run_dir)"}}
finding_schema = {**run_dir_schema, "finding_id": {"type": "string"}}
return [
Tool(
"openultrasast.scan",
"Run a bounded scan on a local path (quick or standard mode). Never executes arbitrary shell.",
{
"type": "object",
"properties": {
"path": {"type": "string"},
"mode": {"type": "string", "enum": ["quick", "standard"]},
"fail_on": {"type": "string", "enum": ["never", "findings", "verified"]},
"config": {"type": "string"},
},
"required": ["path"],
},
_scan,
),
Tool("openultrasast.status", "Summarize a scan's status from its manifest.", _obj(run_dir_schema, ["run_dir"]), _status),
Tool("openultrasast.findings", "List the findings of a completed scan.", _obj(run_dir_schema, ["run_dir"]), _findings),
Tool("openultrasast.get_finding", "Return one finding by id.", _obj(finding_schema, ["run_dir", "finding_id"]), _get_finding),
Tool(
"openultrasast.evidence",
"Return reachability and verification evidence for a finding.",
_obj(finding_schema, ["run_dir", "finding_id"]),
_evidence,
),
Tool("openultrasast.artifacts", "List a scan's artifact paths.", _obj(run_dir_schema, ["run_dir"]), _artifacts),
Tool(
"openultrasast.benchmark",
"Run a benchmark manifest and return recall/precision metrics.",
{
"type": "object",
"properties": {"manifest": {"type": "string"}, "mode": {"type": "string"}, "config": {"type": "string"}},
"required": ["manifest"],
},
_benchmark,
),
Tool(
"openultrasast.explain",
"Explain a finding (rationale, reachability, verification, fusion).",
_obj(finding_schema, ["run_dir", "finding_id"]),
_explain,
),
Tool(
"openultrasast.propose_patch",
"Propose a patch for a finding (patch oracle; not yet implemented).",
_obj(finding_schema, ["run_dir", "finding_id"]),
_propose_patch,
),
Tool(
"openultrasast.export_report",
"Export a scan report as markdown or sarif.",
{
"type": "object",
"properties": {"run_dir": {"type": "string"}, "format": {"type": "string", "enum": ["markdown", "sarif"]}},
"required": ["run_dir"],
},
_export_report,
),
]
def _obj(properties: dict[str, Any], required: list[str]) -> dict[str, Any]:
return {"type": "object", "properties": properties, "required": required}
class McpServer:
"""Minimal MCP server: pure request dispatch over a narrow, fixed tool surface."""
def __init__(self) -> None:
self._tools = {tool.name: tool for tool in _build_tools()}
@property
def tool_names(self) -> list[str]:
return sorted(self._tools)
def handle(self, request: dict[str, Any]) -> dict[str, Any] | None:
"""Dispatch one JSON-RPC request; returns the response, or None for notifications."""
method = request.get("method")
request_id = request.get("id")
if request_id is None: # a notification (e.g. notifications/initialized) — no response
return None
try:
result = self._dispatch(method, request.get("params") or {})
except McpToolError as exc:
return _error(request_id, -32602, str(exc))
except Exception as exc: # noqa: BLE001 — surface as a JSON-RPC error, never crash the loop
return _error(request_id, -32603, f"{type(exc).__name__}: {exc}")
return {"jsonrpc": "2.0", "id": request_id, "result": result}
def _dispatch(self, method: str | None, params: dict[str, Any]) -> dict[str, Any]:
if method == "initialize":
return {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {}},
"serverInfo": {"name": SERVER_NAME, "version": _server_version()},
}
if method == "ping":
return {}
if method == "tools/list":
return {"tools": [tool.descriptor() for tool in self._tools.values()]}
if method == "tools/call":
return self._call_tool(params)
raise McpToolError(f"unknown method: {method}")
def _call_tool(self, params: dict[str, Any]) -> dict[str, Any]:
name = params.get("name")
tool = self._tools.get(str(name))
if tool is None:
raise McpToolError(f"unknown tool: {name}")
arguments = params.get("arguments") or {}
try:
result = tool.handler(arguments)
except McpToolError as exc:
return {"content": [{"type": "text", "text": str(exc)}], "isError": True}
return {"content": [{"type": "text", "text": json.dumps(result, sort_keys=True)}], "isError": False}
def _error(request_id: Any, code: int, message: str) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
def serve(stdin: IO[str] | None = None, stdout: IO[str] | None = None) -> int:
"""Run the MCP stdio loop (newline-delimited JSON-RPC) until EOF."""
stdin = stdin or sys.stdin
stdout = stdout or sys.stdout
server = McpServer()
for line in stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
except json.JSONDecodeError:
stdout.write(json.dumps(_error(None, -32700, "parse error")) + "\n")
stdout.flush()
continue
response = server.handle(request)
if response is not None:
stdout.write(json.dumps(response) + "\n")
stdout.flush()
return 0
__all__ = ["McpServer", "McpToolError", "Tool", "serve"]
+168
View File
@@ -0,0 +1,168 @@
Flaw Category CWE ID CWE Name Flaw Severity Static Dynamic
API Abuse 234 Failure to Handle Missing Parameter 3 X
API Abuse 243 Creation of Chroot Jail Without Changing Working Directory 4 X
API Abuse 245 J2EE Bad Practices: Direct Management of Connections 2 X
API Abuse 560 Use of Umask() with Chmod-Style Argument 3 X
API Abuse 628 Function Call with Incorrectly Specified Arguments 2 X
API Abuse 675 Duplicate Operations on Resource 2 X
Authentication Issues 287 Improper Authentication 4 X X
Authentication Issues 352 Cross-Site Request Forgery (CSRF) 3 X X
Authentication Issues 693 Protection Mechanism Failure 3 X X
Authorization Issues 99 Improper Control of Resource Identifiers 3 X
Authorization Issues 272 Least Privilege Violation 3 X
Authorization Issues 273 Improper Check for Dropped Privileges 3 X
Authorization Issues 274 Improper Handling of Insufficient Privileges 0 X
Authorization Issues 282 Improper Ownership Management 3 X
Authorization Issues 285 Improper Authorization 3 X X
Authorization Issues 346 Origin Validation Error 3 X
Authorization Issues 350 Reliance on Reverse DNS Resolution for a Security-Critical Action 3 X
Authorization Issues 639 Authorization Bypass Through User-Controlled Key 4 X
Authorization Issues 566 Authorization Bypass Through User-Controlled SQL Primary Key 3 X
Authorization Issues 708 Incorrect Ownership Assignment 4 X
Authorization Issues 732 Incorrect Permission Assignment for Critical Resource 3 X
Authorization Issues 942 Permissive Cross-domain Policy with Untrusted Domains 3 X X
Buffer Management Errors 118 Improper Access of Indexable Resource (Range Error) 3 X
Buffer Management Errors 125 Out-of-Bounds Read 3 X
Buffer Management Errors 129 Improper Validation of Array Index 3 X
Buffer Management Errors 135 Incorrect Calculation of Multi-Byte String Length 5 X
Buffer Management Errors 170 Improper Null Termination 3 X
Buffer Management Errors 193 Off-by-One Error 3 X
Buffer Management Errors 787 Out-of-Bounds Write 3 X
Buffer Management Errors 823 Use of Out-of-Range Pointer Offset 3 X
Buffer Management Errors 824 Access of Uninitialized Pointer 3 X
Buffer Overflow 121 Stack-Based Buffer Overflow 5 X
Code Injection 74 Improper Neutralization of Special Elements in Output Used by a Downstream Component (Injection) 4 X
Code Injection 91 XML Injection (Blind XPath Injection) 3 X X
Code Injection 94 Improper Control of Generation of Code 3 X
Code Injection 95 Improper Neutralization of Directives in Dynamically Evaluated Code (‘Eval Injection’) 5 X X
Code Injection 98 Improper Control of Filename for Include/Require Statement in PHP Program (PHP File Inclusion) 4 X X
Code Injection 185 Incorrect Regular Expression 2 X
Code Injection 830 Inclusion of Web Functionality from an Untrusted Source 2 X
Code Quality 111 Direct Use of Unsafe JNI 4 X
Code Quality 159 Failure to Sanitize Special Element 0 X
Code Quality 401 Improper Release of Memory Before Removing Last Reference (Memory Leak) 2 X
Code Quality 404 Improper Resource Shutdown or Release 0 X
Code Quality 415 Double Free 3 X
Code Quality 416 Use After Free 2 X
Code Quality 477 Use of Obsolete Functions 0 X X
Code Quality 479 Signal Handler Use of a Non-Reentrant Function 3 X
Code Quality 489 Leftover Debug Code 3 X
Code Quality 597 Use of Wrong Operator in String Comparison 2 X
Command or Argument Injection 77 Improper Neutralization of Special Elements used in a Command (Command Injection) 5 X
Command or Argument Injection 78 Improper Neutralization of Special Elements used in an OS Command (OS Command Injection) 5 X X
Command or Argument Injection 88 Argument Injection or Modification 3 X
Credentials Management 256 Plaintext Storage of a Password 3 X
Credentials Management 259 Use of Hard-coded Password 3 X X
Credentials Management 522 Insufficiently Protected Credentials 3 X X
Credentials Management 798 Use of Hard-code Credentials 3 X
CRLF Injection 93 Improper Neutralization of CRLF Sequences (CRLF Injection) 3 X
CRLF Injection 113 Improper Neutralization of CRLF Sequences in HTTP Headers (HTTP Response Splitting) 3 X X
CRLF Injection 117 Improper Output Neutralization for Logs 3 X
Cross-Site Scripting (XSS) 79 Improper Neutralization of Input During Web Page Generation (Cross-site Scripting) 3 X X
Cross-Site Scripting (XSS) 80 Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS) 3 X X
Cross-Site Scripting (XSS) 83 Improper Neutralization of Script in Attributes in a Web Page 3 X X
Cross-Site Scripting (XSS) 86 Improper Neutralization of Invalid Characters in Identifiers in Web Pages 3 X
Cryptographic Issues 261 Weak Cryptography for Passwords 3 X
Cryptographic Issues 295 Improper Certificate Validation 3 X
Cryptographic Issues 296 Improper Following of Chain of Trust for Certificate Validation 3 X
Cryptographic Issues 297 Improper Validation of Host-specific Certificate Data 3 X X
Cryptographic Issues 298 Improper Validation of Certificate Expiration 3 X
Cryptographic Issues 299 Improper Check for Certificate Revocation 3 X
Cryptographic Issues 311 Missing Encryption of Sensitive Data 3 X
Cryptographic Issues 312 Cleartext Storage of Sensitive Information 3 X
Cryptographic Issues 313 Plaintext Storage in a File or on Disk 3 X
Cryptographic Issues 316 Plaintext Storage in Memory 3 X
Cryptographic Issues 319 Cleartext Transmission of Sensitive Information 3 X
Cryptographic Issues 321 Use of Hard-coded Cryptographic Key 3 X X
Cryptographic Issues 326 Inadequate Encryption Strength 3 X X
Cryptographic Issues 327 Use of a Broken or Risky Cryptographic Algorithm 3 X X
Cryptographic Issues 328 Reversible One-Way Hash 3 X
Cryptographic Issues 329 Not Using a Random IV with CBC Mode 2 X
Cryptographic Issues 330 Use of Insufficiently Random Values 3 X
Cryptographic Issues 331 Insufficient Entropy 3 X
Cryptographic Issues 338 Use of Cryptographically Weak Pseudo-Random Number Generator 3 X
Cryptographic Issues 347 Improper Verification of Cryptographic Signature 2 X
Cryptographic Issues 354 Improper Validation of Integrity Check Value 3 X
Cryptographic Issues 547 Use of Hard-coded, Security-relevant Constants 3 X
Cryptographic Issues 614 Sensitive Cookie in HTTPS Session Without Secure Attribute 2 X X
Cryptographic Issues 760 Use of a One-Way Hash with a Predictable Salt 3 X
Cryptographic Issues 780 Use of RSA with Optimal Asymmetric Encryption Padding 3 X
Cryptographic Issues 916 Use of Password Hash With Insufficient Computational Effort 3 X
Dangerous Functions 242 Use of Inherently Dangerous Function 5 X
Dangerous Functions 676 Use of Potentially Dangerous Function 3 X
Deployment Configuration 402 Transmission of Private Resources into a New Sphere (Resource Leak) 3 X
Deployment Configuration 668 Exposure of Resource to Wrong Sphere 3 X X
Deployment Configuration 926 Improper Export of Android Application Components 3 X
Directory Traversal 22 Improper Limitation of a Pathname to a Restricted Directory (Path Traversal) 3 X X
Directory Traversal 35 Path Traversal 2 X
Directory Traversal 73 External Control of File Name or Path 3 X
Encapsulation 494 Download of Code Without Integrity Check 5 X
Encapsulation 501 Trust Boundary Violation 3 X
Encapsulation 502 Deserialization of Untrusted Data 3 X
Encapsulation 749 Exposed Dangerous Method or Function 4 X
Error Handling 248 Uncaught Exception 2 X
Error Handling 252 Unchecked Return Value 2 X
Format String 134 Use of Externally-Controlled Format String 5 X
Information Leakage 200 Information Exposure 2 X
Information Leakage 201 Insertion of Sensitive Information Into Sent Data 2 X
Information Leakage 209 Information Exposure Through an Error Message 2 X X
Information Leakage 215 Information Exposure Through Debug Information 2 X X
Information Leakage 359 Exposure of Private Information (Privacy Violation) 2 X
Information Leakage 497 Exposure of System Data to an Unauthorized Control Sphere 2 X
Information Leakage 526 Information Exposure Through Environmental Variables 2 X
Information Leakage 530 Exposure of Backup File to an Unauthorized Control Sphere 2 X
Information Leakage 532 Insertion of Sensitive Information into Log File 2 X
Information Leakage 538 File and Directory Information Exposure 0 X
Information Leakage 548 Information Exposure Through Directory Listing 2 X
Information Leakage 611 Information Exposure Through XML External Entity Reference 3 X X
Information Leakage 615 Information Exposure Through Comments 0 X X
Information Leakage 665 Improper Initialization 2 X
Information Leakage 918 Server-side Request Forgery 3 X X
Insecure Dependencies 829 Inclusion of Functionality from Untrusted Control Sphere 3 X X
Insufficient Input Validation 20 Improper Input Validation 0 X
Insufficient Input Validation 90 Improper Neutralization of Special Elements Used in an LDAP Query (LDAP Injection) 3 X
Insufficient Input Validation 103 Struts: Incomplete validate() Method Definition 3 X
Insufficient Input Validation 104 Struts: Form Bean Does Not Extend Validation Class 3 X
Insufficient Input Validation 112 Missing XML Validation 3 X
Insufficient Input Validation 183 Permissive List of Allowed Inputs 3 X
Insufficient Input Validation 345 Insufficient Verification of Data Authenticity 4 X
Insufficient Input Validation 434 Unrestricted Upload of File with Dangerous Type 4 X
Insufficient Input Validation 470 Use of Externally-Controlled Input to Select Classes or Code (Unsafe Reflection) 3 X
Insufficient Input Validation 472 External Control of Assumed-Immutable Web Parameter 3 X
Insufficient Input Validation 601 URL Redirection to Untrusted Site (Open Redirect) 3 X X
Insufficient Input Validation 618 Exposed Unsafe ActiveX Method 5 X
Insufficient Input Validation 915 Improperly Controlled Modification of Dynamically-Determined Object Attributes 3 X
Insufficient Input Validation 1174 ASP.NET Misconfiguration: Improper Model Validation 2 X
Insufficient Input Validation 1236 Improper Neutralization of Formula Elements in a CSV File 3 X
Insufficient Logging & Monitoring 223 Omission of Security-relevant Information 2 X
Numeric Errors 190 Integer Overflow or Wraparound 5 X
Numeric Errors 191 Integer Underflow (Wrap or Wraparound) 3 X
Numeric Errors 192 Integer Coercion Error 3 X
Numeric Errors 195 Signed to Unsigned Conversion Error 3 X
Numeric Errors 196 Unsigned to Signed Conversion Error 3 X
Numeric Errors 197 Numeric Truncation Error 3 X
Potential Backdoor 398 Indicator of Poor Code Quality 0 X
Potential Backdoor 506 Embedded Malicious Code 4 X
Potential Backdoor 511 Logic/Time Bomb 5 X
Potential Backdoor 514 Covert Channel 2 X
Potential Backdoor 656 Reliance on Security Through Obscurity 0 X
Race Conditions 366 Race Condition within a Thread 3 X
Race Conditions 367 Time-of-check Time-of-use (TOCTOU) Race Condition 3 X
Race Conditions 421 Race Condition During Access to Alternate Channel 3 X
Server Configuration 16 Configuration 0 X
Server Configuration 441 Unintended Proxy or Intermediary (Confused Deputy) 3 X
Server Configuration 642 External Control of Critical State Data 2 X
Server Configuration 757 Selection of Less-Secure Algorithm During Negotiation (Algorithm Downgrade) 3 X X
Session Fixation 384 Session Fixation 3 X X
SQL Injection 89 Improper Neutralization of Special Elements used in an SQL Command (SQL Injection) 4 X
SQL Injection 564 SQL Injection: Hibernate 4 X
SQL Injection 943 Improper Neutralization of Special Elements in Data Query Logic 4 X
Time and State 377 Insecure Temporary File 3 X
Time and State 382 J2EE Bad Practices: Use of System.exit() 2 X
Time and State 557 Concurrency Issues 2 X
Time and State 691 Insufficient Control Flow Management 0 X
Untrusted Initialization 15 External Control of System or Configuration Setting 4 X
Untrusted Initialization 454 External Initialization of Trusted Variables or Data Stores 0 X
Untrusted Search Path 114 Process Control 5 X
Untrusted Search Path 426 Untrusted Search Path 3 X
Untrusted Search Path 427 Uncontrolled Search Path Element 3 X
1 Flaw Category CWE ID CWE Name Flaw Severity Static Dynamic
2 API Abuse 234 Failure to Handle Missing Parameter 3 X
3 API Abuse 243 Creation of Chroot Jail Without Changing Working Directory 4 X
4 API Abuse 245 J2EE Bad Practices: Direct Management of Connections 2 X
5 API Abuse 560 Use of Umask() with Chmod-Style Argument 3 X
6 API Abuse 628 Function Call with Incorrectly Specified Arguments 2 X
7 API Abuse 675 Duplicate Operations on Resource 2 X
8 Authentication Issues 287 Improper Authentication 4 X X
9 Authentication Issues 352 Cross-Site Request Forgery (CSRF) 3 X X
10 Authentication Issues 693 Protection Mechanism Failure 3 X X
11 Authorization Issues 99 Improper Control of Resource Identifiers 3 X
12 Authorization Issues 272 Least Privilege Violation 3 X
13 Authorization Issues 273 Improper Check for Dropped Privileges 3 X
14 Authorization Issues 274 Improper Handling of Insufficient Privileges 0 X
15 Authorization Issues 282 Improper Ownership Management 3 X
16 Authorization Issues 285 Improper Authorization 3 X X
17 Authorization Issues 346 Origin Validation Error 3 X
18 Authorization Issues 350 Reliance on Reverse DNS Resolution for a Security-Critical Action 3 X
19 Authorization Issues 639 Authorization Bypass Through User-Controlled Key 4 X
20 Authorization Issues 566 Authorization Bypass Through User-Controlled SQL Primary Key 3 X
21 Authorization Issues 708 Incorrect Ownership Assignment 4 X
22 Authorization Issues 732 Incorrect Permission Assignment for Critical Resource 3 X
23 Authorization Issues 942 Permissive Cross-domain Policy with Untrusted Domains 3 X X
24 Buffer Management Errors 118 Improper Access of Indexable Resource (Range Error) 3 X
25 Buffer Management Errors 125 Out-of-Bounds Read 3 X
26 Buffer Management Errors 129 Improper Validation of Array Index 3 X
27 Buffer Management Errors 135 Incorrect Calculation of Multi-Byte String Length 5 X
28 Buffer Management Errors 170 Improper Null Termination 3 X
29 Buffer Management Errors 193 Off-by-One Error 3 X
30 Buffer Management Errors 787 Out-of-Bounds Write 3 X
31 Buffer Management Errors 823 Use of Out-of-Range Pointer Offset 3 X
32 Buffer Management Errors 824 Access of Uninitialized Pointer 3 X
33 Buffer Overflow 121 Stack-Based Buffer Overflow 5 X
34 Code Injection 74 Improper Neutralization of Special Elements in Output Used by a Downstream Component (Injection) 4 X
35 Code Injection 91 XML Injection (Blind XPath Injection) 3 X X
36 Code Injection 94 Improper Control of Generation of Code 3 X
37 Code Injection 95 Improper Neutralization of Directives in Dynamically Evaluated Code (‘Eval Injection’) 5 X X
38 Code Injection 98 Improper Control of Filename for Include/Require Statement in PHP Program (PHP File Inclusion) 4 X X
39 Code Injection 185 Incorrect Regular Expression 2 X
40 Code Injection 830 Inclusion of Web Functionality from an Untrusted Source 2 X
41 Code Quality 111 Direct Use of Unsafe JNI 4 X
42 Code Quality 159 Failure to Sanitize Special Element 0 X
43 Code Quality 401 Improper Release of Memory Before Removing Last Reference (Memory Leak) 2 X
44 Code Quality 404 Improper Resource Shutdown or Release 0 X
45 Code Quality 415 Double Free 3 X
46 Code Quality 416 Use After Free 2 X
47 Code Quality 477 Use of Obsolete Functions 0 X X
48 Code Quality 479 Signal Handler Use of a Non-Reentrant Function 3 X
49 Code Quality 489 Leftover Debug Code 3 X
50 Code Quality 597 Use of Wrong Operator in String Comparison 2 X
51 Command or Argument Injection 77 Improper Neutralization of Special Elements used in a Command (Command Injection) 5 X
52 Command or Argument Injection 78 Improper Neutralization of Special Elements used in an OS Command (OS Command Injection) 5 X X
53 Command or Argument Injection 88 Argument Injection or Modification 3 X
54 Credentials Management 256 Plaintext Storage of a Password 3 X
55 Credentials Management 259 Use of Hard-coded Password 3 X X
56 Credentials Management 522 Insufficiently Protected Credentials 3 X X
57 Credentials Management 798 Use of Hard-code Credentials 3 X
58 CRLF Injection 93 Improper Neutralization of CRLF Sequences (CRLF Injection) 3 X
59 CRLF Injection 113 Improper Neutralization of CRLF Sequences in HTTP Headers (HTTP Response Splitting) 3 X X
60 CRLF Injection 117 Improper Output Neutralization for Logs 3 X
61 Cross-Site Scripting (XSS) 79 Improper Neutralization of Input During Web Page Generation (Cross-site Scripting) 3 X X
62 Cross-Site Scripting (XSS) 80 Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS) 3 X X
63 Cross-Site Scripting (XSS) 83 Improper Neutralization of Script in Attributes in a Web Page 3 X X
64 Cross-Site Scripting (XSS) 86 Improper Neutralization of Invalid Characters in Identifiers in Web Pages 3 X
65 Cryptographic Issues 261 Weak Cryptography for Passwords 3 X
66 Cryptographic Issues 295 Improper Certificate Validation 3 X
67 Cryptographic Issues 296 Improper Following of Chain of Trust for Certificate Validation 3 X
68 Cryptographic Issues 297 Improper Validation of Host-specific Certificate Data 3 X X
69 Cryptographic Issues 298 Improper Validation of Certificate Expiration 3 X
70 Cryptographic Issues 299 Improper Check for Certificate Revocation 3 X
71 Cryptographic Issues 311 Missing Encryption of Sensitive Data 3 X
72 Cryptographic Issues 312 Cleartext Storage of Sensitive Information 3 X
73 Cryptographic Issues 313 Plaintext Storage in a File or on Disk 3 X
74 Cryptographic Issues 316 Plaintext Storage in Memory 3 X
75 Cryptographic Issues 319 Cleartext Transmission of Sensitive Information 3 X
76 Cryptographic Issues 321 Use of Hard-coded Cryptographic Key 3 X X
77 Cryptographic Issues 326 Inadequate Encryption Strength 3 X X
78 Cryptographic Issues 327 Use of a Broken or Risky Cryptographic Algorithm 3 X X
79 Cryptographic Issues 328 Reversible One-Way Hash 3 X
80 Cryptographic Issues 329 Not Using a Random IV with CBC Mode 2 X
81 Cryptographic Issues 330 Use of Insufficiently Random Values 3 X
82 Cryptographic Issues 331 Insufficient Entropy 3 X
83 Cryptographic Issues 338 Use of Cryptographically Weak Pseudo-Random Number Generator 3 X
84 Cryptographic Issues 347 Improper Verification of Cryptographic Signature 2 X
85 Cryptographic Issues 354 Improper Validation of Integrity Check Value 3 X
86 Cryptographic Issues 547 Use of Hard-coded, Security-relevant Constants 3 X
87 Cryptographic Issues 614 Sensitive Cookie in HTTPS Session Without Secure Attribute 2 X X
88 Cryptographic Issues 760 Use of a One-Way Hash with a Predictable Salt 3 X
89 Cryptographic Issues 780 Use of RSA with Optimal Asymmetric Encryption Padding 3 X
90 Cryptographic Issues 916 Use of Password Hash With Insufficient Computational Effort 3 X
91 Dangerous Functions 242 Use of Inherently Dangerous Function 5 X
92 Dangerous Functions 676 Use of Potentially Dangerous Function 3 X
93 Deployment Configuration 402 Transmission of Private Resources into a New Sphere (Resource Leak) 3 X
94 Deployment Configuration 668 Exposure of Resource to Wrong Sphere 3 X X
95 Deployment Configuration 926 Improper Export of Android Application Components 3 X
96 Directory Traversal 22 Improper Limitation of a Pathname to a Restricted Directory (Path Traversal) 3 X X
97 Directory Traversal 35 Path Traversal 2 X
98 Directory Traversal 73 External Control of File Name or Path 3 X
99 Encapsulation 494 Download of Code Without Integrity Check 5 X
100 Encapsulation 501 Trust Boundary Violation 3 X
101 Encapsulation 502 Deserialization of Untrusted Data 3 X
102 Encapsulation 749 Exposed Dangerous Method or Function 4 X
103 Error Handling 248 Uncaught Exception 2 X
104 Error Handling 252 Unchecked Return Value 2 X
105 Format String 134 Use of Externally-Controlled Format String 5 X
106 Information Leakage 200 Information Exposure 2 X
107 Information Leakage 201 Insertion of Sensitive Information Into Sent Data 2 X
108 Information Leakage 209 Information Exposure Through an Error Message 2 X X
109 Information Leakage 215 Information Exposure Through Debug Information 2 X X
110 Information Leakage 359 Exposure of Private Information (Privacy Violation) 2 X
111 Information Leakage 497 Exposure of System Data to an Unauthorized Control Sphere 2 X
112 Information Leakage 526 Information Exposure Through Environmental Variables 2 X
113 Information Leakage 530 Exposure of Backup File to an Unauthorized Control Sphere 2 X
114 Information Leakage 532 Insertion of Sensitive Information into Log File 2 X
115 Information Leakage 538 File and Directory Information Exposure 0 X
116 Information Leakage 548 Information Exposure Through Directory Listing 2 X
117 Information Leakage 611 Information Exposure Through XML External Entity Reference 3 X X
118 Information Leakage 615 Information Exposure Through Comments 0 X X
119 Information Leakage 665 Improper Initialization 2 X
120 Information Leakage 918 Server-side Request Forgery 3 X X
121 Insecure Dependencies 829 Inclusion of Functionality from Untrusted Control Sphere 3 X X
122 Insufficient Input Validation 20 Improper Input Validation 0 X
123 Insufficient Input Validation 90 Improper Neutralization of Special Elements Used in an LDAP Query (LDAP Injection) 3 X
124 Insufficient Input Validation 103 Struts: Incomplete validate() Method Definition 3 X
125 Insufficient Input Validation 104 Struts: Form Bean Does Not Extend Validation Class 3 X
126 Insufficient Input Validation 112 Missing XML Validation 3 X
127 Insufficient Input Validation 183 Permissive List of Allowed Inputs 3 X
128 Insufficient Input Validation 345 Insufficient Verification of Data Authenticity 4 X
129 Insufficient Input Validation 434 Unrestricted Upload of File with Dangerous Type 4 X
130 Insufficient Input Validation 470 Use of Externally-Controlled Input to Select Classes or Code (Unsafe Reflection) 3 X
131 Insufficient Input Validation 472 External Control of Assumed-Immutable Web Parameter 3 X
132 Insufficient Input Validation 601 URL Redirection to Untrusted Site (Open Redirect) 3 X X
133 Insufficient Input Validation 618 Exposed Unsafe ActiveX Method 5 X
134 Insufficient Input Validation 915 Improperly Controlled Modification of Dynamically-Determined Object Attributes 3 X
135 Insufficient Input Validation 1174 ASP.NET Misconfiguration: Improper Model Validation 2 X
136 Insufficient Input Validation 1236 Improper Neutralization of Formula Elements in a CSV File 3 X
137 Insufficient Logging & Monitoring 223 Omission of Security-relevant Information 2 X
138 Numeric Errors 190 Integer Overflow or Wraparound 5 X
139 Numeric Errors 191 Integer Underflow (Wrap or Wraparound) 3 X
140 Numeric Errors 192 Integer Coercion Error 3 X
141 Numeric Errors 195 Signed to Unsigned Conversion Error 3 X
142 Numeric Errors 196 Unsigned to Signed Conversion Error 3 X
143 Numeric Errors 197 Numeric Truncation Error 3 X
144 Potential Backdoor 398 Indicator of Poor Code Quality 0 X
145 Potential Backdoor 506 Embedded Malicious Code 4 X
146 Potential Backdoor 511 Logic/Time Bomb 5 X
147 Potential Backdoor 514 Covert Channel 2 X
148 Potential Backdoor 656 Reliance on Security Through Obscurity 0 X
149 Race Conditions 366 Race Condition within a Thread 3 X
150 Race Conditions 367 Time-of-check Time-of-use (TOCTOU) Race Condition 3 X
151 Race Conditions 421 Race Condition During Access to Alternate Channel 3 X
152 Server Configuration 16 Configuration 0 X
153 Server Configuration 441 Unintended Proxy or Intermediary (Confused Deputy) 3 X
154 Server Configuration 642 External Control of Critical State Data 2 X
155 Server Configuration 757 Selection of Less-Secure Algorithm During Negotiation (Algorithm Downgrade) 3 X X
156 Session Fixation 384 Session Fixation 3 X X
157 SQL Injection 89 Improper Neutralization of Special Elements used in an SQL Command (SQL Injection) 4 X
158 SQL Injection 564 SQL Injection: Hibernate 4 X
159 SQL Injection 943 Improper Neutralization of Special Elements in Data Query Logic 4 X
160 Time and State 377 Insecure Temporary File 3 X
161 Time and State 382 J2EE Bad Practices: Use of System.exit() 2 X
162 Time and State 557 Concurrency Issues 2 X
163 Time and State 691 Insufficient Control Flow Management 0 X
164 Untrusted Initialization 15 External Control of System or Configuration Setting 4 X
165 Untrusted Initialization 454 External Initialization of Trusted Variables or Data Stores 0 X
166 Untrusted Search Path 114 Process Control 5 X
167 Untrusted Search Path 426 Untrusted Search Path 3 X
168 Untrusted Search Path 427 Uncontrolled Search Path Element 3 X
+19
View File
@@ -0,0 +1,19 @@
"""Central CWE severity/scope policy (vendored verycode-policies)."""
from .verycode import (
CWE_SCORE_TSV,
CwePolicy,
PolicyError,
assert_rules_resolve,
load_policy,
resolve_severity,
)
__all__ = [
"CWE_SCORE_TSV",
"CwePolicy",
"PolicyError",
"assert_rules_resolve",
"load_policy",
"resolve_severity",
]
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import csv
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
CWE_SCORE_TSV = Path(__file__).with_name("CWE_Score.tsv")
class PolicyError(ValueError):
"""Raised when an enabled rule references a CWE the policy does not govern."""
@dataclass(frozen=True)
class CwePolicy:
"""Authoritative governance for one CWE.
``severity`` (0-5) is upstream-governed and overrides any rule-local string.
``static`` gates SAST scope; ``dynamic``-only CWEs are report-only for a
static tool and are never scored.
"""
flaw_category: str
severity: int
static: bool
dynamic: bool
def load_policy(tsv: Path = CWE_SCORE_TSV) -> dict[str, CwePolicy]:
"""Load the vendored CWE policy keyed by ``"CWE-NNN"``.
The source TSV uses CRLF line endings and a ``"Flaw Severity "`` header with a
trailing space, so columns are read positionally and every cell is stripped.
"""
policy: dict[str, CwePolicy] = {}
with tsv.open(newline="", encoding="utf-8") as handle:
reader = csv.reader(handle, delimiter="\t")
rows = list(reader)
for row in rows[1:]:
if len(row) < 5:
continue
cwe_id = row[1].strip()
if not cwe_id.isdigit():
continue
severity = _parse_severity(row[3])
policy[f"CWE-{cwe_id}"] = CwePolicy(
flaw_category=row[0].strip(),
severity=severity,
static=_flag(row[4]),
dynamic=_flag(row[5]) if len(row) > 5 else False,
)
return policy
def resolve_severity(
policy: Mapping[str, CwePolicy],
cwe: str,
target: object = None,
ranking: object = None,
) -> int:
"""Resolve a finding's severity exclusively from policy, keyed on its CWE.
Policy always wins; any legacy rule-local severity string is discarded. An
unmapped or non-static CWE resolves to 0 (report-only, not scored).
"""
pol = policy.get(cwe)
if pol is None or not pol.static:
return 0
return pol.severity
def assert_rules_resolve(rules: Iterable[object], policy: Mapping[str, CwePolicy]) -> None:
"""Fail loud if any enabled rule's CWE is unmapped in the policy.
Rules expose ``cwe`` and (optionally) ``status``; only ``enabled`` rules are
checked. Raises :class:`PolicyError` before any scan work proceeds.
"""
unmapped: list[str] = []
for rule in rules:
if getattr(rule, "status", "enabled") != "enabled":
continue
cwe = getattr(rule, "cwe", None)
if not isinstance(cwe, str) or cwe not in policy:
rule_id = getattr(rule, "rule_id", "<unknown>")
unmapped.append(f"{rule_id} -> {cwe}")
if unmapped:
raise PolicyError("enabled rules reference CWEs absent from the policy: " + ", ".join(sorted(unmapped)))
def _parse_severity(value: str) -> int:
try:
severity = int(value.strip())
except ValueError:
return 0
return min(5, max(0, severity))
def _flag(value: str) -> bool:
return value.strip().upper() == "X"
+75 -9
View File
@@ -3,19 +3,56 @@ from __future__ import annotations
import json import json
import os import os
import re import re
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import TypeVar, cast
class OpenRouterError(RuntimeError): class OpenRouterError(RuntimeError):
"""Raised when an OpenRouter request or response is invalid.""" """Raised when an OpenRouter request or response is invalid."""
_TRANSIENT_STATUS = {429, 500, 502, 503, 504}
_T = TypeVar("_T")
def _is_transient(exc: Exception) -> bool:
"""A network failure worth retrying (rate-limit, 5xx, connection error, timeout)."""
if isinstance(exc, urllib.error.HTTPError):
return exc.code in _TRANSIENT_STATUS
return isinstance(exc, urllib.error.URLError | TimeoutError)
def call_with_retry(
operation: Callable[[], _T],
*,
attempts: int = 3,
base_delay: float = 0.5,
sleep: Callable[[float], None] = time.sleep,
) -> _T:
"""Run ``operation`` with exponential backoff on transient network failures."""
last: Exception | None = None
for attempt in range(attempts):
try:
return operation()
except Exception as exc: # noqa: BLE001 — retry only transient failures; re-raise the rest
last = exc
if attempt + 1 < attempts and _is_transient(exc):
sleep(base_delay * (2**attempt))
continue
raise
raise last if last is not None else OpenRouterError("retry exhausted")
@dataclass(frozen=True) @dataclass(frozen=True)
class OpenRouterChatClient: class OpenRouterChatClient:
api_key: str api_key: str
base_url: str = "https://openrouter.ai/api/v1" base_url: str = "https://openrouter.ai/api/v1"
max_attempts: int = 3
retry_base_delay: float = 0.5
@classmethod @classmethod
def from_env(cls) -> OpenRouterChatClient: def from_env(cls) -> OpenRouterChatClient:
@@ -24,8 +61,18 @@ class OpenRouterChatClient:
raise OpenRouterError("OPENROUTER_API_KEY is required for OpenRouter chat calls") raise OpenRouterError("OPENROUTER_API_KEY is required for OpenRouter chat calls")
return cls(api_key=api_key, base_url=os.environ.get("OPENROUTER_BASE_URL", cls.base_url)) return cls(api_key=api_key, base_url=os.environ.get("OPENROUTER_BASE_URL", cls.base_url))
def complete_json(self, *, model: str, messages: list[dict[str, str]], timeout_seconds: int = 60) -> object: def complete_chat(
payload = json.dumps({"model": model, "messages": messages, "temperature": 0}).encode() self,
*,
model: str,
messages: Sequence[Mapping[str, object]],
tools: Sequence[Mapping[str, object]] | None = None,
timeout_seconds: int = 60,
) -> dict[str, object]:
body: dict[str, object] = {"model": model, "messages": list(messages), "temperature": 0}
if tools:
body["tools"] = list(tools)
payload = json.dumps(body).encode()
request = urllib.request.Request( request = urllib.request.Request(
f"{self.base_url.rstrip('/')}/chat/completions", f"{self.base_url.rstrip('/')}/chat/completions",
data=payload, data=payload,
@@ -35,13 +82,22 @@ class OpenRouterChatClient:
}, },
method="POST", method="POST",
) )
try:
def _do() -> dict[str, object]:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response: with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
response_payload = json.loads(response.read().decode()) return cast(dict[str, object], json.loads(response.read().decode()))
try:
response_payload = call_with_retry(_do, attempts=self.max_attempts, base_delay=self.retry_base_delay)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
raise OpenRouterError(f"OpenRouter chat request failed: {exc}") from exc raise OpenRouterError(f"OpenRouter chat request failed: {exc}") from exc
return _extract_message(response_payload)
content = _extract_message_content(response_payload) def complete_json(self, *, model: str, messages: list[dict[str, str]], timeout_seconds: int = 60) -> object:
message = self.complete_chat(model=model, messages=messages, timeout_seconds=timeout_seconds)
content = message.get("content")
if not isinstance(content, str) or not content.strip():
raise OpenRouterError("OpenRouter message content was empty")
return parse_json_content(content) return parse_json_content(content)
@@ -49,6 +105,8 @@ class OpenRouterChatClient:
class OpenRouterEmbeddingClient: class OpenRouterEmbeddingClient:
api_key: str api_key: str
base_url: str = "https://openrouter.ai/api/v1" base_url: str = "https://openrouter.ai/api/v1"
max_attempts: int = 3
retry_base_delay: float = 0.5
@classmethod @classmethod
def from_env(cls) -> OpenRouterEmbeddingClient: def from_env(cls) -> OpenRouterEmbeddingClient:
@@ -68,9 +126,13 @@ class OpenRouterEmbeddingClient:
}, },
method="POST", method="POST",
) )
try:
def _do() -> dict[str, object]:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response: with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
response_payload = json.loads(response.read().decode()) return cast(dict[str, object], json.loads(response.read().decode()))
try:
response_payload = call_with_retry(_do, attempts=self.max_attempts, base_delay=self.retry_base_delay)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
raise OpenRouterError(f"OpenRouter embedding request failed: {exc}") from exc raise OpenRouterError(f"OpenRouter embedding request failed: {exc}") from exc
return parse_embedding_response(response_payload) return parse_embedding_response(response_payload)
@@ -109,7 +171,7 @@ def parse_json_content(content: str) -> object:
raise OpenRouterError(f"model response was not valid JSON: {exc}") from exc raise OpenRouterError(f"model response was not valid JSON: {exc}") from exc
def _extract_message_content(payload: object) -> str: def _extract_message(payload: object) -> dict[str, object]:
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise OpenRouterError("OpenRouter response must be a JSON object") raise OpenRouterError("OpenRouter response must be a JSON object")
choices = payload.get("choices") choices = payload.get("choices")
@@ -121,7 +183,11 @@ def _extract_message_content(payload: object) -> str:
message = first.get("message") message = first.get("message")
if not isinstance(message, dict): if not isinstance(message, dict):
raise OpenRouterError("OpenRouter choice did not include a message") raise OpenRouterError("OpenRouter choice did not include a message")
content = message.get("content") return message
def _extract_message_content(payload: object) -> str:
content = _extract_message(payload).get("content")
if not isinstance(content, str) or not content.strip(): if not isinstance(content, str) or not content.strip():
raise OpenRouterError("OpenRouter message content was empty") raise OpenRouterError("OpenRouter message content was empty")
return content return content
+45
View File
@@ -0,0 +1,45 @@
"""Secret redaction for traces and reports (Phase 15 hardening).
Scan artifacts (trace events, reports) can quote source code that contains live
credentials. This module masks well-known secret shapes before they are persisted, so
a finding's evidence never leaks a key into an artifact an analyst later shares.
Conservative by design: it targets recognizable secret formats, not arbitrary text.
"""
from __future__ import annotations
import re
REDACTED = "***REDACTED***"
# (compiled pattern, replacement). Replacements keep any non-secret prefix group so the
# redaction is legible (e.g. `api_key = ***REDACTED***`, `Authorization: Bearer ***REDACTED***`).
_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
# PEM private key blocks.
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL), REDACTED),
# Authorization: Bearer <token>
(re.compile(r"(Bearer\s+)[A-Za-z0-9._\-]{12,}"), r"\1" + REDACTED),
# Provider API keys: OpenAI/Anthropic sk-..., AWS AKIA..., GitHub gh*_..., Slack xox*, Google AIza...
(re.compile(r"\bsk-[A-Za-z0-9._\-]{16,}"), REDACTED),
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), REDACTED),
(re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}"), REDACTED),
(re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"), REDACTED),
(re.compile(r"\bAIza[A-Za-z0-9_\-]{20,}"), REDACTED),
# Credentials embedded in a URL: scheme://user:secret@host
(re.compile(r"([a-zA-Z][a-zA-Z0-9+.\-]*://[^\s:/@]+:)[^\s:/@]{3,}(@)"), r"\1" + REDACTED + r"\2"),
# Generic `key = "value"` assignments for sensitive names.
(
re.compile(r"(?i)\b(api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)\b(\s*[=:]\s*)(['\"]?)[A-Za-z0-9/+_\-]{8,}\3"),
r"\1\2\3" + REDACTED + r"\3",
),
)
def redact_secrets(text: str) -> str:
"""Return ``text`` with recognizable secrets masked. Idempotent and pattern-scoped."""
for pattern, replacement in _PATTERNS:
text = pattern.sub(replacement, text)
return text
__all__ = ["REDACTED", "redact_secrets"]
+36
View File
@@ -0,0 +1,36 @@
"""Isolated regression snippets and verdicts."""
from .candidate import CandidateVerdict, RegressionRunner, run_regression, select_candidates, write_verdicts
from .recipes import recipe_for
from .safety import UnsafeSnippetError, check_snippet_safety
from .verdict import (
ALREADY_COVERED,
INCONCLUSIVE,
MISSING_RECIPE,
NOT_TRIGGERABLE,
SAFETY_REJECTED,
TRIGGERABLE,
RegressionVerdict,
is_worth_fixing,
verdict_from_result,
)
__all__ = [
"ALREADY_COVERED",
"INCONCLUSIVE",
"MISSING_RECIPE",
"NOT_TRIGGERABLE",
"SAFETY_REJECTED",
"TRIGGERABLE",
"CandidateVerdict",
"RegressionRunner",
"RegressionVerdict",
"UnsafeSnippetError",
"check_snippet_safety",
"is_worth_fixing",
"recipe_for",
"run_regression",
"select_candidates",
"verdict_from_result",
"write_verdicts",
]
+247
View File
@@ -0,0 +1,247 @@
"""Candidate selection and safety-gated regression execution."""
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from ..complexity.ledger import hotspot_key, select_forced_candidates
from ..complexity.map import Hotspot
from ..config import SandboxConfig
from ..findings import StaticFinding
from ..preprocess import LANGUAGE_BY_EXTENSION
from ..sandbox import SandboxJob, SandboxResult, SandboxRunner
from ..sandbox.runner import WORKSPACE_MOUNT
from .recipes import recipe_for
from .safety import UnsafeSnippetError, check_snippet_safety
from .verdict import (
INCONCLUSIVE,
MISSING_RECIPE,
NOT_TRIGGERABLE,
SAFETY_REJECTED,
TRIGGERABLE,
RegressionVerdict,
is_worth_fixing,
verdict_from_result,
)
DEFAULT_IMAGES = {
"python": "python:3.12-alpine",
"javascript": "node:22-alpine",
"c": "gcc:13",
}
__all__ = [
"INCONCLUSIVE",
"MISSING_RECIPE",
"NOT_TRIGGERABLE",
"SAFETY_REJECTED",
"TRIGGERABLE",
"CandidateVerdict",
"RegressionRunner",
"RegressionVerdict",
"run_regression",
"select_candidates",
"snippet_for",
"write_verdicts",
]
@dataclass(frozen=True)
class CandidateVerdict:
path: str
function_name: str | None
language: str
verdict: str
reason: str
inventory_finding_ids: tuple[str, ...]
worth_fixing: bool = False
def select_candidates(
hotspots: Sequence[Hotspot],
findings: Sequence[StaticFinding],
*,
max_candidates: int,
policy_severity_by_id: Mapping[str, object] | None = None,
rule_cwe: Mapping[str, str] | None = None,
) -> tuple[Hotspot, ...]:
"""Select min(max_candidates, ranked hotspots) ∪ sev-5 reachable inventory.
The cap applies to the ranked hotspot slice. Forced policy-severity-5
reachable (or inferred-file-surface) inventory stays in the set even when a
ledger overlay demoted its hotspot out of that slice.
"""
ranked = tuple(sorted(hotspots, key=lambda item: (-item.score, item.path, item.function_name or "")))
selected = list(ranked[: max(max_candidates, 0)])
seen = {hotspot_key(item.path, item.function_name) for item in selected}
extras: list[Hotspot] = []
for finding in select_forced_candidates(findings, policy_severity_by_id or {}, rule_cwe=rule_cwe):
hotspot = _hotspot_for_finding(finding, ranked) or _hotspot_from_forced_finding(finding)
key = hotspot_key(hotspot.path, hotspot.function_name)
if key in seen:
continue
seen.add(key)
extras.append(hotspot)
return tuple(selected + extras)
def _hotspot_for_finding(finding: StaticFinding, hotspots: Sequence[Hotspot]) -> Hotspot | None:
for hotspot in hotspots:
if finding.finding_id in hotspot.inventory_finding_ids:
return hotspot
key = hotspot_key(finding.path, finding.function_name)
for hotspot in hotspots:
if hotspot_key(hotspot.path, hotspot.function_name) == key:
return hotspot
return None
def _hotspot_from_forced_finding(finding: StaticFinding) -> Hotspot:
return Hotspot(
path=finding.path,
function_name=finding.function_name,
score=0.0,
band="low",
signals={},
rationale="forced reachable severity-5 inventory",
test_hint=None,
inventory_finding_ids=(finding.finding_id,),
)
class RegressionRunner:
"""Run a snippet in the sandbox only after the structural safety check passes."""
def __init__(self, sandbox: SandboxRunner) -> None:
self._sandbox = sandbox
def run_recipe(
self,
language: str,
snippet: str,
image: str,
sandbox_limits: SandboxConfig,
*,
repo_root: Path,
) -> RegressionVerdict:
job = recipe_for(language, snippet, image, sandbox_limits, repo_root=repo_root)
if job is None:
return verdict_from_result(recipe_missing=True)
return self.run_snippet(snippet, job)
def run_snippet(self, snippet: str, job: SandboxJob | None) -> RegressionVerdict:
if job is None:
return verdict_from_result(recipe_missing=True)
try:
check_snippet_safety(snippet)
except UnsafeSnippetError:
return verdict_from_result(safety_rejected=True)
return _verdict_from_result(self._sandbox.run(job))
def _verdict_from_result(result: SandboxResult) -> RegressionVerdict:
return verdict_from_result(result)
def snippet_for(language: str, path: str, function_name: str | None) -> str:
"""Render a scratch snippet that loads the candidate source inside the sandbox."""
relative = path.replace("\\", "/")
workspace_path = f"{WORKSPACE_MOUNT}/{relative}"
identity = function_name or relative
if language == "python":
return (
f"# candidate {identity}\n"
"from pathlib import Path\n"
f"source = Path({workspace_path!r}).read_text()\n"
f"compile(source, {relative!r}, 'exec')\n"
)
if language == "javascript":
return (
f"// candidate {identity}\n"
"const fs = require('fs');\n"
f"const source = fs.readFileSync({workspace_path!r}, 'utf8');\n"
"if (!source.length) { process.exit(1); }\n"
)
if language == "c":
return f"/* candidate {identity} */\nint main(void) {{ return 0; }}\n"
return ""
def run_regression(
hotspots: Sequence[Hotspot],
findings: Sequence[StaticFinding],
*,
max_candidates: int,
policy: Mapping[str, object],
rule_cwe: Mapping[str, str],
languages_by_path: Mapping[str, str],
repo_root: Path,
sandbox: SandboxRunner,
sandbox_limits: SandboxConfig,
images: Mapping[str, str],
) -> tuple[CandidateVerdict, ...]:
"""Select candidates under the cap, run recipes in the sandbox, and return verdicts."""
selected = select_candidates(
hotspots,
findings,
max_candidates=max_candidates,
policy_severity_by_id=policy,
rule_cwe=rule_cwe,
)
runner = RegressionRunner(sandbox)
findings_by_id = {finding.finding_id: finding for finding in findings}
records: list[CandidateVerdict] = []
for hotspot in selected:
language = _language_for(hotspot.path, languages_by_path)
snippet = _proposed_snippet(hotspot, findings) or snippet_for(language, hotspot.path, hotspot.function_name)
image = images.get(language) or DEFAULT_IMAGES.get(language, "ousast-missing-image")
mapped = runner.run_recipe(language, snippet, image, sandbox_limits, repo_root=repo_root)
reachability = _reachability_for(hotspot, findings_by_id)
mapped = replace(mapped, worth_fixing=is_worth_fixing(mapped.verdict, reachability))
records.append(
CandidateVerdict(
path=hotspot.path,
function_name=hotspot.function_name,
language=language,
verdict=mapped.verdict,
reason=mapped.reason,
inventory_finding_ids=hotspot.inventory_finding_ids,
worth_fixing=mapped.worth_fixing,
)
)
return tuple(records)
def write_verdicts(verdicts: Sequence[CandidateVerdict], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {"verdicts": [asdict(item) for item in verdicts]}
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
def _proposed_snippet(hotspot: Hotspot, findings: Sequence[StaticFinding]) -> str | None:
for finding in findings:
snippet = finding.proposed_snippet
if not snippet:
continue
if finding.path == hotspot.path or finding.finding_id in hotspot.inventory_finding_ids:
return snippet
return None
def _language_for(path: str, languages_by_path: Mapping[str, str]) -> str:
if path in languages_by_path:
return languages_by_path[path]
return LANGUAGE_BY_EXTENSION.get(Path(path).suffix.lower(), "unknown")
def _reachability_for(hotspot: Hotspot, findings_by_id: Mapping[str, StaticFinding]) -> str:
statuses = [
findings_by_id[finding_id].reachability_status for finding_id in hotspot.inventory_finding_ids if finding_id in findings_by_id
]
for status in statuses:
if is_worth_fixing(TRIGGERABLE, status):
return status
return statuses[0] if statuses else "unknown"
+71
View File
@@ -0,0 +1,71 @@
"""Language command recipes for isolated regression snippets."""
from __future__ import annotations
from pathlib import Path
from ..config import SandboxConfig
from ..sandbox.runner import SCRATCH_MOUNT, WORKSPACE_MOUNT, SandboxJob
_PYTHON_FILE = "case.py"
_JS_FILE = "case.js"
_C_FILE = "case.c"
_C_BIN = "case"
def recipe_for(
language: str,
snippet: str,
image: str,
sandbox_limits: SandboxConfig,
*,
repo_root: Path,
) -> SandboxJob | None:
"""Return a sandbox job for `language`, or None when no recipe exists."""
key = language.strip().lower()
if key == "python":
return _job(
image,
sandbox_limits,
repo_root,
command=("env", f"PYTHONPATH={WORKSPACE_MOUNT}", "python", f"{SCRATCH_MOUNT}/{_PYTHON_FILE}"),
scratch_files={_PYTHON_FILE: snippet},
)
if key == "javascript":
return _job(
image,
sandbox_limits,
repo_root,
command=("env", f"NODE_PATH={WORKSPACE_MOUNT}", "node", f"{SCRATCH_MOUNT}/{_JS_FILE}"),
scratch_files={_JS_FILE: snippet},
)
if key == "c":
source = f"{SCRATCH_MOUNT}/{_C_FILE}"
binary = f"{SCRATCH_MOUNT}/{_C_BIN}"
return _job(
image,
sandbox_limits,
repo_root,
command=("sh", "-c", f"cc {source} -o {binary} && {binary}"),
scratch_files={_C_FILE: snippet},
)
return None
def _job(
image: str,
sandbox_limits: SandboxConfig,
repo_root: Path,
*,
command: tuple[str, ...],
scratch_files: dict[str, str],
) -> SandboxJob:
return SandboxJob(
image=image,
command=command,
repo_root=repo_root,
scratch_files=scratch_files,
timeout_seconds=sandbox_limits.timeout_seconds,
memory_mb=sandbox_limits.memory_mb,
pids_limit=sandbox_limits.pids_limit,
)
+81
View File
@@ -0,0 +1,81 @@
"""Structural deny-list for untrusted regression snippets.
Snippets are data for `/scratch` inside the sandbox. Mentions of sockets, curl,
host networking, or writes under the source mount (`/workspace`) are rejected
before any `SandboxRunner.run` call.
"""
from __future__ import annotations
import re
from ..sandbox.runner import WORKSPACE_MOUNT
_WORKSPACE = re.escape(WORKSPACE_MOUNT)
class UnsafeSnippetError(ValueError):
"""Raised when a snippet fails the structural sandbox safety check."""
_HOST_NETWORK = re.compile(
r"--net(?:work)?(?:\s*=\s*|\s+|['\"]\s*,\s*['\"])host\b",
re.IGNORECASE,
)
_SOCKET_WORD = re.compile(r"\bsocket\b", re.IGNORECASE)
_CURL_WORD = re.compile(r"\bcurl\b", re.IGNORECASE)
_OPEN_WORKSPACE_WRITE = re.compile(
rf"open\s*\(\s*[fFrRbBuU]*['\"]{_WORKSPACE}[^'\"]*['\"]\s*,\s*(?:mode\s*=\s*)?[fFrRbBuU]*['\"][^'\"]*[wax+]",
re.IGNORECASE | re.DOTALL,
)
_PATH_WORKSPACE_WRITE = re.compile(
rf"Path\s*\(\s*['\"]{_WORKSPACE}[^'\"]*['\"]\s*\)\s*\.\s*"
r"(?:write_text|write_bytes|touch|mkdir|unlink|rmdir|replace|chmod|rename|open)\b",
re.IGNORECASE | re.DOTALL,
)
_FS_WORKSPACE_WRITE = re.compile(
r"(?:writeFile(?:Sync)?|appendFile(?:Sync)?|mkdir(?:Sync)?|rm(?:dir)?(?:Sync)?|"
rf"unlink(?:Sync)?|rename(?:Sync)?)\s*\(\s*['\"]{_WORKSPACE}",
re.IGNORECASE,
)
_FOPEN_WORKSPACE_WRITE = re.compile(
rf"fopen\s*\(\s*['\"]{_WORKSPACE}[^'\"]*['\"]\s*,\s*['\"][^'\"]*[wax+]",
re.IGNORECASE | re.DOTALL,
)
_SHUTIL_OS_WORKSPACE_WRITE = re.compile(
r"(?:shutil\.(?:copy(?:2)?|copyfile|copytree|move|rmtree)|"
r"os\.(?:remove|unlink|replace|rename|mkdir|makedirs|rmdir|chmod))"
rf"\s*\([^;]{{0,240}}{_WORKSPACE}",
re.IGNORECASE | re.DOTALL,
)
_REDIRECT_WORKSPACE = re.compile(rf"(?:>>|>)\s*['\"]?{_WORKSPACE}\b", re.IGNORECASE)
_WORKSPACE_WRITES = (
_OPEN_WORKSPACE_WRITE,
_PATH_WORKSPACE_WRITE,
_FS_WORKSPACE_WRITE,
_FOPEN_WORKSPACE_WRITE,
_SHUTIL_OS_WORKSPACE_WRITE,
_REDIRECT_WORKSPACE,
)
def check_snippet_safety(snippet: str) -> None:
"""Return None when `snippet` is structurally safe; raise otherwise."""
lowered = snippet.lower()
if "docker.sock" in lowered:
raise UnsafeSnippetError("snippet mentions the Docker socket")
if _SOCKET_WORD.search(snippet) is not None:
raise UnsafeSnippetError("snippet mentions socket")
if _CURL_WORD.search(snippet) is not None:
raise UnsafeSnippetError("snippet mentions curl")
if _HOST_NETWORK.search(snippet) is not None:
raise UnsafeSnippetError("snippet requests host network")
if _writes_under_workspace(snippet):
raise UnsafeSnippetError(f"snippet writes under {WORKSPACE_MOUNT}")
def _writes_under_workspace(snippet: str) -> bool:
if WORKSPACE_MOUNT not in snippet.lower():
return False
return any(pattern.search(snippet) is not None for pattern in _WORKSPACE_WRITES)
+95
View File
@@ -0,0 +1,95 @@
"""Map sandbox outcomes to verdicts and the worth-fixing gate."""
from __future__ import annotations
from dataclasses import dataclass
from ..sandbox import SandboxResult
TRIGGERABLE = "triggerable"
NOT_TRIGGERABLE = "not_triggerable"
ALREADY_COVERED = "already_covered"
INCONCLUSIVE = "inconclusive"
MISSING_RECIPE = "missing_recipe"
SAFETY_REJECTED = "safety_rejected"
SANDBOX_UNAVAILABLE = "sandbox_unavailable"
TIMEOUT = "timeout"
COVERED_TEST_PASSED = "covered_test_passed"
ASSERTION = "assertion"
SANITIZER_ABORT = "sanitizer_abort"
CRASH = "crash"
NONZERO_EXIT = "nonzero_exit"
EXIT_ZERO = "exit_zero"
WORTH_FIXING_REACHABILITY = frozenset({"reachable", "inferred-file-surface"})
_SANITIZER_MARKERS = (
"AddressSanitizer",
"UndefinedBehaviorSanitizer",
"ThreadSanitizer",
"MemorySanitizer",
"LeakSanitizer",
)
_ASSERTION_MARKERS = (
"AssertionError",
"Assertion failed",
"assertion failed",
"assert failed",
)
_CRASH_MARKERS = (
"Fatal Python error",
"Segmentation fault",
"SIGSEGV",
"SIGABRT",
"Aborted",
)
@dataclass(frozen=True)
class RegressionVerdict:
verdict: str
reason: str = ""
worth_fixing: bool = False
def is_worth_fixing(verdict: str, reachability: str) -> bool:
"""True iff the candidate is triggerable on a reachable or inferred-file-surface finding."""
return verdict == TRIGGERABLE and reachability in WORTH_FIXING_REACHABILITY
def verdict_from_result(
result: SandboxResult | None = None,
*,
recipe_missing: bool = False,
sandbox_missing: bool = False,
safety_rejected: bool = False,
covering_test: bool = False,
) -> RegressionVerdict:
"""Map a sandbox observation onto one of the four regression verdicts."""
if recipe_missing:
return RegressionVerdict(verdict=INCONCLUSIVE, reason=MISSING_RECIPE)
if sandbox_missing:
return RegressionVerdict(verdict=INCONCLUSIVE, reason=SANDBOX_UNAVAILABLE)
if safety_rejected:
return RegressionVerdict(verdict=INCONCLUSIVE, reason=SAFETY_REJECTED)
if result is None:
return RegressionVerdict(verdict=INCONCLUSIVE, reason=SANDBOX_UNAVAILABLE)
if result.timed_out:
return RegressionVerdict(verdict=INCONCLUSIVE, reason=TIMEOUT)
if covering_test and result.exit_code == 0:
return RegressionVerdict(verdict=ALREADY_COVERED, reason=COVERED_TEST_PASSED)
if result.exit_code != 0:
return RegressionVerdict(verdict=TRIGGERABLE, reason=_nonzero_reason(result))
return RegressionVerdict(verdict=NOT_TRIGGERABLE, reason=EXIT_ZERO)
def _nonzero_reason(result: SandboxResult) -> str:
text = f"{result.stdout}\n{result.stderr}".lower()
if any(marker.lower() in text for marker in _SANITIZER_MARKERS):
return SANITIZER_ABORT
if any(marker.lower() in text for marker in _ASSERTION_MARKERS):
return ASSERTION
if any(marker.lower() in text for marker in _CRASH_MARKERS):
return CRASH
return NONZERO_EXIT
+111 -5
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from collections.abc import Sequence
from dataclasses import asdict from dataclasses import asdict
from pathlib import Path from pathlib import Path
@@ -9,12 +10,21 @@ from .run import ScanRun
from .verification import VerificationResult from .verification import VerificationResult
def write_markdown_report(findings: list[StaticFinding], path: Path, verifications: list[VerificationResult] | None = None) -> None: def write_markdown_report(
findings: list[StaticFinding],
path: Path,
verifications: list[VerificationResult] | None = None,
*,
redact: bool = True,
complexity_map: object | None = None,
verdicts: Sequence[object] | None = None,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
verification_by_id = _verification_by_id(verifications or []) verification_by_id = _verification_by_id(verifications or [])
lines = ["# OpenUltraSAST Report", "", f"Findings: {len(findings)}", ""] lines = ["# OpenUltraSAST Report", "", f"Findings: {len(findings)}", "", "## Inventory", ""]
if not findings: if not findings:
lines.append("No quick-mode findings were emitted.") lines.append("No quick-mode findings were emitted.")
lines.append("")
for finding in findings: for finding in findings:
verification = verification_by_id.get(finding.finding_id) verification = verification_by_id.get(finding.finding_id)
lines.extend( lines.extend(
@@ -38,7 +48,78 @@ def write_markdown_report(findings: list[StaticFinding], path: Path, verificatio
"", "",
] ]
) )
path.write_text("\n".join(lines).rstrip() + "\n") if complexity_map is not None:
_append_complexity_map(lines, complexity_map)
if verdicts is not None:
_append_worth_fixing(lines, verdicts)
report = "\n".join(lines).rstrip() + "\n"
if redact:
from .redaction import redact_secrets
report = redact_secrets(report)
path.write_text(report)
def _append_complexity_map(lines: list[str], complexity_map: object) -> None:
hotspots = tuple(getattr(complexity_map, "hotspots", ()) or ())
heuristic_only = getattr(complexity_map, "heuristic_only", None)
summary = f"Hotspots: {len(hotspots)}"
if heuristic_only is not None:
summary += f" (heuristic_only={str(bool(heuristic_only)).lower()})"
lines.extend(["## Complexity map", "", summary, ""])
if not hotspots:
lines.extend(["No complexity hotspots were emitted.", ""])
return
for hotspot in hotspots:
identity = _report_identity(getattr(hotspot, "path", ""), getattr(hotspot, "function_name", None))
lines.extend(
[
f"### `{identity}`",
"",
f"- Score: `{getattr(hotspot, 'score', '')}`",
f"- Band: `{getattr(hotspot, 'band', '')}`",
]
)
hint = getattr(hotspot, "test_hint", None)
if hint is not None:
lines.append(f"- Gap: `{getattr(hint, 'gap', '')}`")
kind = getattr(hint, "test_kind", None)
if kind:
lines.append(f"- Recommended test: `{kind}`")
reason = getattr(hint, "reason", "")
if reason:
lines.append(f"- Hint: {reason}")
inventory_ids = tuple(getattr(hotspot, "inventory_finding_ids", ()) or ())
if inventory_ids:
lines.append(f"- Inventory: `{', '.join(str(item) for item in inventory_ids)}`")
rationale = str(getattr(hotspot, "rationale", "") or "")
lines.extend(["", rationale, ""] if rationale else [""])
def _append_worth_fixing(lines: list[str], verdicts: Sequence[object]) -> None:
worth = [item for item in verdicts if getattr(item, "worth_fixing", False)]
lines.extend(["## Worth fixing", "", f"Count: {len(worth)}", ""])
if not worth:
lines.extend(["No worth-fixing verdicts were produced.", ""])
return
for item in worth:
identity = _report_identity(getattr(item, "path", ""), getattr(item, "function_name", None))
lines.extend(
[
f"### `{identity}`",
"",
f"- Verdict: `{getattr(item, 'verdict', '')}`",
f"- Reason: `{getattr(item, 'reason', '')}`",
"",
]
)
def _report_identity(path: object, function_name: object) -> str:
path_text = str(path or "")
if function_name:
return f"{path_text}::{function_name}"
return path_text
def write_sarif_report(findings: list[StaticFinding], verifications: list[VerificationResult], path: Path) -> None: def write_sarif_report(findings: list[StaticFinding], verifications: list[VerificationResult], path: Path) -> None:
@@ -71,10 +152,16 @@ def write_manifest(
verifications: list[VerificationResult], verifications: list[VerificationResult],
artifact_paths: dict[str, Path], artifact_paths: dict[str, Path],
path: Path, path: Path,
score: dict[str, object] | None = None,
degradations: list[dict[str, object]] | None = None,
fusion: list[dict[str, object]] | None = None,
stages: dict[str, object] | None = None,
complexity: dict[str, object] | None = None,
worth_fixing: dict[str, object] | None = None,
) -> None: ) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
verification_by_id = _verification_by_id(verifications) verification_by_id = _verification_by_id(verifications)
payload = { payload: dict[str, object] = {
"scan_id": run.scan_id, "scan_id": run.scan_id,
"target": str(run.target), "target": str(run.target),
"artifacts": {name: _relative_artifact(run.root, artifact) for name, artifact in sorted(artifact_paths.items())}, "artifacts": {name: _relative_artifact(run.root, artifact) for name, artifact in sorted(artifact_paths.items())},
@@ -93,16 +180,35 @@ def write_manifest(
for finding in findings for finding in findings
], ],
} }
if score is not None:
payload["score"] = score
if degradations is not None:
payload["degradations"] = degradations
if fusion is not None:
payload["fusion"] = fusion
if stages is not None:
payload["stages"] = stages
if complexity is not None:
payload["complexity"] = complexity
if worth_fixing is not None:
payload["worth_fixing"] = worth_fixing
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
def scan_exit_code(findings: list[StaticFinding], verifications: list[VerificationResult], policy: str) -> int: def scan_exit_code(
findings: list[StaticFinding],
verifications: list[VerificationResult],
policy: str,
worth_fixing_verdicts: Sequence[object] | None = None,
) -> int:
if policy == "never": if policy == "never":
return 0 return 0
if policy == "findings": if policy == "findings":
return 1 if findings else 0 return 1 if findings else 0
if policy == "verified": if policy == "verified":
return 1 if any(result.verified for result in verifications) else 0 return 1 if any(result.verified for result in verifications) else 0
if policy == "worth-fixing":
return 1 if any(getattr(verdict, "worth_fixing", False) for verdict in worth_fixing_verdicts or ()) else 0
raise ValueError(f"unknown fail policy: {policy}") raise ValueError(f"unknown fail policy: {policy}")
+21
View File
@@ -0,0 +1,21 @@
"""Central, versioned detection ruleset (rules as governed data)."""
from .store import (
DEFAULT_RULESET_DIR,
PatternRule,
RulesetError,
load_ruleset,
read_rule_ledger,
write_rule_ledger,
write_ruleset,
)
__all__ = [
"DEFAULT_RULESET_DIR",
"PatternRule",
"RulesetError",
"load_ruleset",
"read_rule_ledger",
"write_rule_ledger",
"write_ruleset",
]
+71
View File
@@ -0,0 +1,71 @@
[[rule]]
rule_id = "c-exec-family"
title = "C exec command execution needs review"
languages = ["c", "cpp"]
cwe = "CWE-78"
tags = ["syscall_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(execl|execlp|execle|execv|execvp|execvpe)\s*\('''
[[rule]]
rule_id = "c-shell-exec"
title = "C shell execution needs review"
languages = ["c", "cpp"]
cwe = "CWE-78"
tags = ["syscall_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(system|popen)\s*\('''
[[rule]]
rule_id = "c-unsafe-copy"
title = "Unsafe C string copy needs review"
languages = ["c", "cpp"]
cwe = "CWE-121"
tags = ["memory_unsafe"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(gets|strcpy|strcat|sprintf)\s*\('''
[[rule]]
rule_id = "c-unsafe-format"
title = "Unsafe C format string needs review"
languages = ["c", "cpp"]
cwe = "CWE-134"
tags = ["memory_unsafe"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(printf|vprintf|vsprintf|vsnprintf|vfprintf|syslog)\s*\(\s*[A-Za-z_]'''
[[rule]]
rule_id = "c-unsafe-memory-copy"
title = "Unsafe C memory copy needs review"
languages = ["c", "cpp"]
cwe = "CWE-121"
tags = ["memory_unsafe"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(memcpy|memmove|bcopy)\s*\('''
[[rule]]
rule_id = "c-unsafe-scanf"
title = "Unbounded C scanf needs review"
languages = ["c", "cpp"]
cwe = "CWE-121"
tags = ["memory_unsafe"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(scanf|fscanf|sscanf)\s*\([^\n]*%s'''
@@ -0,0 +1,11 @@
[[rule]]
rule_id = "groovy-xss-unescaped"
title = "Groovy template unescaped output needs review"
languages = ["groovy"]
cwe = "CWE-79"
tags = ["injection"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''(yieldUnescaped)\s+[A-Za-z_]'''
+47
View File
@@ -0,0 +1,47 @@
[[rule]]
rule_id = "java-command-exec"
title = "Java runtime command execution needs review"
languages = ["java"]
cwe = "CWE-78"
tags = ["syscall_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(exec)\s*\('''
[[rule]]
rule_id = "java-deserialization"
title = "Java unsafe deserialization needs review"
languages = ["java"]
cwe = "CWE-502"
tags = ["deserialization"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\.(readObject)\s*\('''
[[rule]]
rule_id = "java-sql-injection"
title = "Java SQL string composition needs review"
languages = ["java"]
cwe = "CWE-89"
tags = ["injection"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''(?i)\"\s*(select|insert|update|delete)\b[^\"]*\"\s*\+'''
[[rule]]
rule_id = "java-weak-hash"
title = "Java weak hash needs review"
languages = ["java"]
cwe = "CWE-327"
tags = ["crypto"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''MessageDigest\.getInstance\s*\(\s*\"(MD5|SHA-1)\"'''
@@ -0,0 +1,131 @@
[[rule]]
rule_id = "js-command-exec"
title = "Node child_process command execution needs review"
languages = ["javascript", "typescript"]
cwe = "CWE-78"
tags = ["syscall_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(exec|execSync)\s*\('''
[[rule]]
rule_id = "js-deserialize"
title = "JavaScript unsafe deserialization needs review"
languages = ["javascript", "typescript"]
cwe = "CWE-502"
tags = ["deserialization"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(unserialize)\s*\('''
[[rule]]
rule_id = "js-dom-xss"
title = "DOM sink with untrusted input needs review"
languages = ["javascript", "typescript"]
cwe = "CWE-79"
tags = ["injection"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\.(innerHTML|outerHTML)\s*=\s*[^\n]*\b(?:req|request|location|document\.location)'''
[[rule]]
rule_id = "js-eval"
title = "Dynamic JavaScript evaluation needs review"
languages = ["javascript", "typescript"]
cwe = "CWE-95"
tags = ["syscall_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(eval)\s*\('''
[[rule]]
rule_id = "js-function-constructor"
title = "JavaScript Function constructor needs review"
languages = ["javascript", "typescript"]
cwe = "CWE-95"
tags = ["syscall_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''new\s+(Function)\s*\('''
[[rule]]
rule_id = "js-path-traversal"
title = "Node filesystem access with external input needs review"
languages = ["javascript", "typescript"]
cwe = "CWE-22"
tags = ["filesystem_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\bfs\.(readFile|readFileSync|writeFile|writeFileSync|createReadStream|unlink|unlinkSync)\s*\(\s*[^\n]*\b(?:req|request)\.'''
[[rule]]
rule_id = "js-spawn-shell"
title = "Node spawn with shell needs review"
languages = ["javascript", "typescript"]
cwe = "CWE-78"
tags = ["syscall_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(spawn|spawnSync)\s*\([^\n]*shell\s*:\s*true'''
[[rule]]
rule_id = "js-sql-injection"
title = "JavaScript SQL string composition needs review"
languages = ["javascript", "typescript"]
cwe = "CWE-89"
tags = ["injection"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\.(query|execute)\s*\(\s*[^\n]*(?:\$\{|\+\s*[A-Za-z_$])'''
[[rule]]
rule_id = "js-ssrf"
title = "JavaScript outbound request with external input needs review"
languages = ["javascript", "typescript"]
cwe = "CWE-918"
tags = ["network_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(axios|fetch|got)\s*[.(][^\n]*\b(?:req|request)\.'''
[[rule]]
rule_id = "js-weak-hash"
title = "JavaScript weak hash needs review"
languages = ["javascript", "typescript"]
cwe = "CWE-327"
tags = ["crypto"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''createHash\s*\(\s*[\"'](md5|sha1)[\"']'''
[[rule]]
rule_id = "js-xss-response"
title = "Unsanitized HTTP response reflection needs review"
languages = ["javascript", "typescript"]
cwe = "CWE-79"
tags = ["injection"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\.(send|write|end)\s*\(\s*[^\n]*\b(?:req|request)\.(?:query|params|body)'''
+119
View File
@@ -0,0 +1,119 @@
[[rule]]
rule_id = "python-flask-debug"
title = "Flask debug mode enabled needs review"
languages = ["python"]
cwe = "CWE-489"
tags = ["insecure_default"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\.run\s*\([^\n]*(debug)\s*=\s*True'''
[[rule]]
rule_id = "python-os-command"
title = "Python OS command execution needs review"
languages = ["python"]
cwe = "CWE-78"
tags = ["syscall_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(os\.system|os\.popen|os\.execl|os\.execlp|os\.execv|os\.execvp)\s*\('''
[[rule]]
rule_id = "python-path-traversal"
title = "Python file open with external input needs review"
languages = ["python"]
cwe = "CWE-22"
tags = ["filesystem_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(open)\s*\(\s*[^\n]*(?:request\.|req\.|argv|input\s*\(|params\[|GET\[|POST\[)'''
[[rule]]
rule_id = "python-shell-true"
title = "Subprocess shell execution needs review"
languages = ["python"]
cwe = "CWE-78"
tags = ["syscall_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''subprocess\.[a-zA-Z_]+\s*\([^\n]*shell\s*=\s*True'''
[[rule]]
rule_id = "python-sql-injection"
title = "Python SQL string composition needs review"
languages = ["python"]
cwe = "CWE-89"
tags = ["injection"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''(\.execute)\s*\(\s*[^\n]*(?:[\"']\s*%\s*[(A-Za-z_]|\.format\s*\(|[\"']\s*\+|\bf[\"'])'''
[[rule]]
rule_id = "python-ssrf"
title = "Python outbound request with external input needs review"
languages = ["python"]
cwe = "CWE-918"
tags = ["network_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(requests\.(?:get|post|put|delete|head|patch)|urllib\.request\.urlopen|urlopen)\s*\(\s*[^\n]*(?:request\.|req\.|argv|input\s*\()'''
[[rule]]
rule_id = "python-ssti"
title = "Python server-side template injection needs review"
languages = ["python"]
cwe = "CWE-94"
tags = ["injection"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(render_template_string)\s*\('''
[[rule]]
rule_id = "python-unsafe-deserialization"
title = "Unsafe Python deserialization needs review"
languages = ["python"]
cwe = "CWE-502"
tags = ["deserialization"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(pickle\.loads|pickle\.load|cPickle\.loads|yaml\.load|marshal\.loads)\s*\('''
[[rule]]
rule_id = "python-unsafe-eval"
title = "Dynamic Python execution needs review"
languages = ["python"]
cwe = "CWE-95"
tags = ["syscall_entry"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\b(eval|exec)\s*\('''
[[rule]]
rule_id = "python-weak-hash"
title = "Python weak hash needs review"
languages = ["python"]
cwe = "CWE-327"
tags = ["crypto"]
status = "enabled"
min_evidence_level = "static_corroboration"
precision_estimate = 0.0
version = "1"
pattern = '''\bhashlib\.(md5|sha1)\s*\('''
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import json
import tomllib
from collections.abc import Iterable
from dataclasses import dataclass, replace
from pathlib import Path
DEFAULT_RULESET_DIR = Path(__file__).parent
VALID_STATUS = ("enabled", "shadow", "disabled")
class RulesetError(ValueError):
"""Raised when a ruleset data file is malformed."""
@dataclass(frozen=True)
class PatternRule:
"""A detection rule expressed as governed data (no rule-local severity).
``pattern`` is human-PR-only; the self-improvement loop may only change
``status``/``min_evidence_level``/``precision_estimate`` via the ledger.
"""
rule_id: str
title: str
languages: tuple[str, ...]
cwe: str
tags: tuple[str, ...]
pattern: str
status: str = "enabled"
min_evidence_level: str = "static_corroboration"
precision_estimate: float = 0.0
version: str = "1"
def load_ruleset(directory: Path = DEFAULT_RULESET_DIR, ledger: Path | None = None) -> tuple[PatternRule, ...]:
"""Load every ``*.toml`` rule file under ``directory`` and apply the loop ledger.
Rules are returned sorted by ``rule_id`` for determinism. The optional ledger
(``rule_policy.json``) overlays loop-owned ``status``/``min_evidence_level``/
``precision_estimate`` per ``rule_id``.
"""
rules: dict[str, PatternRule] = {}
for path in sorted(directory.rglob("*.toml")):
payload = tomllib.loads(path.read_text())
for item in payload.get("rule", []):
rule = _rule_from_dict(item, path)
if rule.rule_id in rules:
raise RulesetError(f"duplicate rule_id {rule.rule_id!r} in {path}")
rules[rule.rule_id] = rule
overlay = _load_ledger(ledger)
resolved = [_apply_overlay(rule, overlay.get(rule.rule_id)) for rule in rules.values()]
return tuple(sorted(resolved, key=lambda rule: rule.rule_id))
def write_ruleset(path: Path, rules: Iterable[PatternRule]) -> None:
"""Write rules as TOML. Patterns use literal multi-line strings (no escaping)."""
path.parent.mkdir(parents=True, exist_ok=True)
blocks: list[str] = []
for rule in rules:
if "'''" in rule.pattern:
raise RulesetError(f"rule {rule.rule_id} pattern cannot contain a triple single-quote")
blocks.append(
"\n".join(
[
"[[rule]]",
f"rule_id = {_s(rule.rule_id)}",
f"title = {_s(rule.title)}",
f"languages = {_arr(rule.languages)}",
f"cwe = {_s(rule.cwe)}",
f"tags = {_arr(rule.tags)}",
f"status = {_s(rule.status)}",
f"min_evidence_level = {_s(rule.min_evidence_level)}",
f"precision_estimate = {float(rule.precision_estimate)}",
f"version = {_s(rule.version)}",
f"pattern = '''{rule.pattern}'''",
]
)
)
path.write_text("\n\n".join(blocks) + "\n")
def read_rule_ledger(path: Path) -> dict[str, dict[str, object]]:
"""Read the loop-owned ``rule_policy.json`` overlay (status/floors per rule_id)."""
return _load_ledger(path)
def write_rule_ledger(path: Path, entries: dict[str, dict[str, object]]) -> None:
"""Write the loop-owned ``rule_policy.json`` overlay (used by the evolve loop)."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(entries, indent=2, sort_keys=True) + "\n")
def _rule_from_dict(item: dict[str, object], path: Path) -> PatternRule:
try:
rule_id = str(item["rule_id"])
cwe = str(item["cwe"])
pattern = str(item["pattern"])
except KeyError as exc:
raise RulesetError(f"rule in {path} missing required field {exc}") from exc
status = str(item.get("status", "enabled"))
if status not in VALID_STATUS:
raise RulesetError(f"rule {rule_id} has invalid status {status!r}")
if not pattern:
raise RulesetError(f"rule {rule_id} has an empty pattern")
return PatternRule(
rule_id=rule_id,
title=str(item.get("title", rule_id)),
languages=_str_tuple(item.get("languages")),
cwe=cwe,
tags=_str_tuple(item.get("tags")),
pattern=pattern,
status=status,
min_evidence_level=str(item.get("min_evidence_level", "static_corroboration")),
precision_estimate=float(item.get("precision_estimate", 0.0)), # type: ignore[arg-type]
version=str(item.get("version", "1")),
)
def _load_ledger(ledger: Path | None) -> dict[str, dict[str, object]]:
if ledger is None or not ledger.exists():
return {}
payload = json.loads(ledger.read_text())
return {str(key): value for key, value in payload.items() if isinstance(value, dict)}
def _apply_overlay(rule: PatternRule, overlay: dict[str, object] | None) -> PatternRule:
if not overlay:
return rule
status = str(overlay.get("status", rule.status))
if status not in VALID_STATUS:
status = rule.status
return replace(
rule,
status=status,
min_evidence_level=str(overlay.get("min_evidence_level", rule.min_evidence_level)),
precision_estimate=float(overlay.get("precision_estimate", rule.precision_estimate)), # type: ignore[arg-type]
)
def _str_tuple(value: object) -> tuple[str, ...]:
if isinstance(value, list):
return tuple(str(item) for item in value)
return ()
def _s(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def _arr(values: tuple[str, ...]) -> str:
return "[" + ", ".join(_s(value) for value in values) + "]"
+25
View File
@@ -0,0 +1,25 @@
"""Isolated Docker CLI sandbox."""
from .probe import FakeSandboxProbe, SandboxProbe, resolve_sandbox_probe
from .runner import (
DockerCliRunner,
FakeSandboxRunner,
SandboxJob,
SandboxResult,
SandboxRunner,
build_docker_argv,
resolve_sandbox_runner,
)
__all__ = [
"DockerCliRunner",
"FakeSandboxProbe",
"FakeSandboxRunner",
"SandboxJob",
"SandboxProbe",
"SandboxResult",
"SandboxRunner",
"build_docker_argv",
"resolve_sandbox_probe",
"resolve_sandbox_runner",
]
+56
View File
@@ -0,0 +1,56 @@
"""Docker CLI presence and usability probe."""
from __future__ import annotations
import os
import subprocess
from collections.abc import Callable
from subprocess import CompletedProcess
PROBE_TIMEOUT_SECONDS = 5
PROBE_ENV = "OPENULTRASAST_SANDBOX_PROBE"
_DOCKER_INFO = ("docker", "info")
_PROBE_OFF = frozenset({"0", "off", "false", "unavailable", "no"})
_PROBE_ON = frozenset({"1", "on", "true", "available", "yes"})
class SandboxProbe:
"""Report whether `docker info` succeeds without raising into the scan."""
def __init__(self, *, runner: Callable[..., CompletedProcess[str]] | None = None) -> None:
self._runner = runner
def available(self) -> bool:
run = self._runner if self._runner is not None else subprocess.run
try:
result = run(
list(_DOCKER_INFO),
capture_output=True,
text=True,
timeout=PROBE_TIMEOUT_SECONDS,
check=False,
)
return result.returncode == 0
except Exception:
return False
class FakeSandboxProbe:
"""Test double that reports a programmed availability without touching Docker."""
def __init__(self, available: bool) -> None:
self._available = available
def available(self) -> bool:
return self._available
def resolve_sandbox_probe() -> SandboxProbe | FakeSandboxProbe:
"""Return a real probe, or a fake when OPENULTRASAST_SANDBOX_PROBE is set."""
flag = os.environ.get(PROBE_ENV, "").strip().lower()
if flag in _PROBE_OFF:
return FakeSandboxProbe(False)
if flag in _PROBE_ON:
return FakeSandboxProbe(True)
return SandboxProbe()
+200
View File
@@ -0,0 +1,200 @@
"""Docker CLI sandbox runner that never mounts the host Docker socket."""
from __future__ import annotations
import os
import subprocess
import tempfile
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from subprocess import CompletedProcess
from typing import Protocol
NONROOT_USER = "65534:65534"
WORKSPACE_MOUNT = "/workspace"
SCRATCH_MOUNT = "/scratch"
SCRATCH_SEED_MOUNT = "/scratch-in"
RUNNER_ENV = "OPENULTRASAST_SANDBOX_RUNNER"
_COPY_SEED_THEN_EXEC = 'cp -r /scratch-in/. /scratch/ && exec "$@"'
_SEED_ARGV0 = "ousast-sandbox"
_FAKE_RUNNER = frozenset({"fake"})
class SandboxRunner(Protocol):
def run(self, spec: SandboxJob) -> SandboxResult: ...
@dataclass(frozen=True)
class SandboxJob:
image: str
command: tuple[str, ...]
repo_root: Path
scratch_files: dict[str, str]
timeout_seconds: int
memory_mb: int
pids_limit: int
@dataclass(frozen=True)
class SandboxResult:
exit_code: int
stdout: str
stderr: str
timed_out: bool
class FakeSandboxRunner:
"""Record jobs and return a programmed result without a Docker daemon."""
def __init__(self, result: SandboxResult | None = None) -> None:
self.jobs: list[SandboxJob] = []
self.result = result if result is not None else SandboxResult(exit_code=0, stdout="", stderr="", timed_out=False)
def run(self, spec: SandboxJob) -> SandboxResult:
self.jobs.append(spec)
return self.result
def resolve_sandbox_runner() -> SandboxRunner:
"""Return the Docker CLI runner, or a fake when OPENULTRASAST_SANDBOX_RUNNER=fake."""
flag = os.environ.get(RUNNER_ENV, "").strip().lower()
if flag in _FAKE_RUNNER:
return FakeSandboxRunner()
return DockerCliRunner()
class DockerCliRunner:
"""Run a job with Docker CLI flags that match the threat model."""
def __init__(self, *, runner: Callable[..., CompletedProcess[str]] | None = None) -> None:
self._runner = runner
def run(self, spec: SandboxJob) -> SandboxResult:
if spec.scratch_files:
with tempfile.TemporaryDirectory(prefix="ousast-scratch-") as tmp:
seed = Path(tmp)
_write_scratch_files(seed, spec.scratch_files)
return self._invoke(_argv_with_scratch_seed(spec, seed), spec)
return self._invoke(build_docker_argv(spec), spec)
def _invoke(self, argv: list[str], spec: SandboxJob) -> SandboxResult:
_reject_unsafe_argv(argv)
run = self._runner if self._runner is not None else subprocess.run
try:
completed = run(
argv,
capture_output=True,
text=True,
timeout=spec.timeout_seconds,
check=False,
)
except subprocess.TimeoutExpired as exc:
return SandboxResult(
exit_code=-1,
stdout=_as_text(exc.stdout),
stderr=_as_text(exc.stderr),
timed_out=True,
)
return SandboxResult(
exit_code=completed.returncode,
stdout=completed.stdout or "",
stderr=completed.stderr or "",
timed_out=False,
)
def build_docker_argv(job: SandboxJob) -> list[str]:
argv = [*_isolation_argv(job), job.image, *job.command]
_reject_unsafe_argv(argv)
return argv
def _isolation_argv(job: SandboxJob) -> list[str]:
repo = str(job.repo_root.resolve())
return [
"docker",
"run",
"--rm",
"--init",
"--network",
"none",
"--read-only",
"--tmpfs",
f"{SCRATCH_MOUNT}:rw,exec",
"--memory",
f"{job.memory_mb}m",
"--pids-limit",
str(job.pids_limit),
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--user",
NONROOT_USER,
"--mount",
f"type=bind,src={repo},dst={WORKSPACE_MOUNT},ro",
]
def _argv_with_scratch_seed(job: SandboxJob, seed: Path) -> list[str]:
argv = [
*_isolation_argv(job),
"--mount",
f"type=bind,src={seed.resolve()},dst={SCRATCH_SEED_MOUNT},ro",
job.image,
"/bin/sh",
"-c",
_COPY_SEED_THEN_EXEC,
_SEED_ARGV0,
*job.command,
]
_reject_unsafe_argv(argv)
return argv
def _write_scratch_files(root: Path, files: Mapping[str, str]) -> None:
resolved_root = root.resolve()
for relative, content in files.items():
candidate = Path(relative)
if candidate.is_absolute():
raise ValueError(f"scratch path {relative!r} must be relative to {SCRATCH_MOUNT}")
destination = (resolved_root / candidate).resolve()
try:
common = os.path.commonpath([str(resolved_root), str(destination)])
except ValueError as exc:
raise ValueError(f"scratch path {relative!r} escapes {SCRATCH_MOUNT}") from exc
if common != str(resolved_root):
raise ValueError(f"scratch path {relative!r} escapes {SCRATCH_MOUNT}")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content)
_chmod_seed_tree(resolved_root)
def _chmod_seed_tree(root: Path) -> None:
# Container user 65534 must be able to traverse and read the seed bind.
for dirpath, _dirnames, filenames in os.walk(root):
Path(dirpath).chmod(0o755)
for name in filenames:
(Path(dirpath) / name).chmod(0o644)
def _reject_unsafe_argv(argv: list[str]) -> None:
joined = " ".join(argv)
if "/var/run/docker.sock" in joined:
raise ValueError("sandbox argv must not mount the host Docker socket")
if "--privileged" in argv:
raise ValueError("sandbox argv must not be privileged")
for index, item in enumerate(argv):
if item in {"--network", "--net"} and index + 1 < len(argv) and argv[index + 1] == "host":
raise ValueError("sandbox argv must not use host network")
if item in {"--network=host", "--net=host"}:
raise ValueError("sandbox argv must not use host network")
def _as_text(value: str | bytes | None) -> str:
if value is None:
return ""
if isinstance(value, bytes):
return value.decode(errors="replace")
return value
+21
View File
@@ -0,0 +1,21 @@
"""Policy-weighted project scoring (verycode-style AppSec score)."""
from .project_score import (
REACH_MULT,
SEV_WEIGHT,
ScoreArtifact,
build_score_artifact,
finding_penalty,
gate,
project_score,
)
__all__ = [
"REACH_MULT",
"SEV_WEIGHT",
"ScoreArtifact",
"build_score_artifact",
"finding_penalty",
"gate",
"project_score",
]
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
import math
from collections.abc import Mapping
from dataclasses import asdict, dataclass
from ..findings import StaticFinding
from ..policy import CwePolicy
# Penalty weight per policy severity (0-5). Higher severity costs the score more.
SEV_WEIGHT = {5: 50, 4: 25, 3: 10, 2: 2, 1: 1, 0: 0}
# Reachability multiplier — the false-positive calibration knob. A confirmed FP
# lowers a finding's effective reachability rather than deleting the rule.
REACH_MULT = {"reachable": 1.0, "inferred-file-surface": 0.6, "unknown": 0.4}
@dataclass(frozen=True)
class ScoreArtifact:
project_score: int
max_severity: int
penalty_total: float
by_category: dict[str, int]
out_of_scope_dynamic_only: int
unmapped_cwe: list[str]
gate: dict[str, object]
def to_dict(self) -> dict[str, object]:
return asdict(self)
def finding_penalty(
finding: StaticFinding,
rule_cwe: str | None,
policy: Mapping[str, CwePolicy],
reachability_override: Mapping[str, float] | None = None,
) -> float:
"""Penalty contributed by one finding: severity weight × reachability multiplier.
Dynamic-only or unmapped CWEs contribute zero (report-only, never scored). A
confirmed false positive can supply a lowered effective reachability multiplier
via ``reachability_override`` (keyed by ``finding_id``) instead of deleting the rule.
"""
pol = policy.get(rule_cwe) if rule_cwe else None
if pol is None or not pol.static:
return 0.0
if reachability_override and finding.finding_id in reachability_override:
multiplier = reachability_override[finding.finding_id]
else:
multiplier = REACH_MULT.get(finding.reachability_status, REACH_MULT["unknown"])
return SEV_WEIGHT[pol.severity] * multiplier
def project_score(
findings: list[StaticFinding],
rule_cwe_by_id: Mapping[str, str],
policy: Mapping[str, CwePolicy],
k: float = 60.0,
reachability_override: Mapping[str, float] | None = None,
) -> int:
"""Compute the 0-100 project score (shadow findings excluded by the caller).
Exponential decay of total penalty: no penalty → 100; one severity-5 reachable
finding → ~43.
"""
total = sum(finding_penalty(finding, rule_cwe_by_id.get(finding.finding_id), policy, reachability_override) for finding in findings)
return round(100 * math.exp(-total / k))
def gate(
findings: list[StaticFinding],
score: int,
rule_cwe_by_id: Mapping[str, str],
policy: Mapping[str, CwePolicy],
*,
min_score: int,
block_severity_reachable: int = 5,
blocking: bool,
) -> dict[str, object]:
"""Two-condition gate verdict.
A finding at ``block_severity_reachable`` severity that is ``reachable`` always
fails. The score threshold fails only when ``blocking`` is enabled.
"""
hard_fail = any(
_severity(rule_cwe_by_id.get(finding.finding_id), policy) >= block_severity_reachable and finding.reachability_status == "reachable"
for finding in findings
)
score_fail = blocking and score < min_score
return {
"min_score": min_score,
"block_severity_reachable": block_severity_reachable,
"blocking": blocking,
"passed": not hard_fail and not score_fail,
}
def build_score_artifact(
findings: list[StaticFinding],
rule_cwe_by_id: Mapping[str, str],
policy: Mapping[str, CwePolicy],
*,
k: float = 60.0,
min_score: int = 80,
block_severity_reachable: int = 5,
blocking: bool = False,
reachability_override: Mapping[str, float] | None = None,
) -> ScoreArtifact:
score = project_score(findings, rule_cwe_by_id, policy, k=k, reachability_override=reachability_override)
penalty_total = 0.0
max_severity = 0
by_category: dict[str, int] = {}
out_of_scope_dynamic_only = 0
unmapped: set[str] = set()
for finding in findings:
cwe = rule_cwe_by_id.get(finding.finding_id)
pol = policy.get(cwe) if cwe else None
if pol is None:
unmapped.add(cwe or "unknown")
continue
if not pol.static:
if pol.dynamic:
out_of_scope_dynamic_only += 1
continue
penalty_total += finding_penalty(finding, cwe, policy, reachability_override)
max_severity = max(max_severity, pol.severity)
by_category[pol.flaw_category] = by_category.get(pol.flaw_category, 0) + 1
return ScoreArtifact(
project_score=score,
max_severity=max_severity,
penalty_total=round(penalty_total, 2),
by_category=dict(sorted(by_category.items())),
out_of_scope_dynamic_only=out_of_scope_dynamic_only,
unmapped_cwe=sorted(unmapped),
gate=gate(
findings,
score,
rule_cwe_by_id,
policy,
min_score=min_score,
block_severity_reachable=block_severity_reachable,
blocking=blocking,
),
)
def _severity(cwe: str | None, policy: Mapping[str, CwePolicy]) -> int:
pol = policy.get(cwe) if cwe else None
if pol is None or not pol.static:
return 0
return pol.severity
+372
View File
@@ -0,0 +1,372 @@
"""Trail of Bits skill index + router (Phase 14).
Security skills are scoped expertise, not global prompt bloat. Each skill is a small
descriptor with routing metadata (languages, tags, vulnerability classes, stages) and
a short operating checklist. The router selects the most relevant skills for a target
and stage; ``select_skill_context`` packs their checklists into a char budget; and
``skill_chunks`` chunks the guidance into the ``skills`` retrieval namespace. Nothing
here imports a model or the optional extra — it is pure routing data.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .index import CodeChunk, chunk_text_namespace
from .preprocess import FileTarget
# Pipeline stages a skill can apply to.
STAGES = ("map", "hunt", "verify", "fix")
# FileTarget tags → vulnerability classes the router reasons over.
TAG_TO_VULN = {
"memory_unsafe": "memory-safety",
"parser": "parsing",
"fuzzable": "parsing",
"crypto": "crypto",
"deserialization": "deserialization",
"auth_boundary": "access-control",
"network_entry": "access-control",
"privileged": "access-control",
}
@dataclass(frozen=True)
class SkillDescriptor:
id: str
title: str
summary: str
checklist: str
languages: tuple[str, ...] = () # empty => language-agnostic
tags: tuple[str, ...] = ()
vulnerability_classes: tuple[str, ...] = ()
stages: tuple[str, ...] = () # empty => any stage
def descriptor(self) -> dict[str, object]:
return {
"id": self.id,
"title": self.title,
"summary": self.summary,
"languages": list(self.languages),
"tags": list(self.tags),
"vulnerability_classes": list(self.vulnerability_classes),
"stages": list(self.stages),
}
# Curated inventory aligned with the spec's Trail of Bits skill routing examples.
SKILL_INVENTORY: tuple[SkillDescriptor, ...] = (
SkillDescriptor(
"c-review",
"C/C++ security review",
"Manual review discipline for memory-unsafe code.",
"Audit buffer indexing, object lifetime, integer overflow before length math, and unchecked copies.",
languages=("c", "cpp", "c++"),
tags=("memory_unsafe",),
vulnerability_classes=("memory-safety",),
stages=("map", "hunt"),
),
SkillDescriptor(
"address-sanitizer",
"AddressSanitizer",
"Detect memory-safety violations at runtime.",
"Build with -fsanitize=address; reproduce the overflow/UAF; capture the ASan trace as crash evidence.",
languages=("c", "cpp", "c++"),
tags=("memory_unsafe",),
vulnerability_classes=("memory-safety",),
stages=("hunt", "verify"),
),
SkillDescriptor(
"libfuzzer",
"libFuzzer harnessing",
"In-process coverage-guided fuzzing of C/C++ entries.",
"Write a LLVMFuzzerTestOneInput harness over the parser entry; minimize crashing inputs.",
languages=("c", "cpp", "c++"),
tags=("memory_unsafe", "fuzzable", "parser"),
vulnerability_classes=("memory-safety", "parsing"),
stages=("hunt", "verify"),
),
SkillDescriptor(
"harness-writing",
"Fuzz harness writing",
"Build deterministic harnesses around attacker-controlled entries.",
"Identify the smallest attacker-controlled entry; build a deterministic harness; seed a corpus.",
languages=("c", "cpp", "c++"),
tags=("memory_unsafe", "fuzzable", "parser"),
vulnerability_classes=("memory-safety", "parsing"),
stages=("hunt", "verify"),
),
SkillDescriptor(
"fuzzing-dictionary",
"Fuzzing dictionary",
"Reach deep parser states with a format dictionary.",
"Extract format tokens/magic bytes into a fuzzing dictionary to reach deep parser states.",
tags=("parser", "fuzzable"),
vulnerability_classes=("parsing",),
stages=("verify",),
),
SkillDescriptor(
"fuzzing-obstacles",
"Fuzzing obstacles",
"Remove coverage barriers that block the fuzzer.",
"Remove checksums/magic gates that block coverage; document each bypass.",
tags=("parser", "fuzzable"),
vulnerability_classes=("parsing",),
stages=("verify",),
),
SkillDescriptor(
"atheris",
"Atheris (Python fuzzing)",
"Coverage-guided fuzzing for Python parsers.",
"Fuzz the Python parser entry with Atheris; assert on uncaught exceptions and resource blowups.",
languages=("python",),
tags=("parser", "fuzzable"),
vulnerability_classes=("parsing",),
stages=("hunt", "verify"),
),
SkillDescriptor(
"property-based-testing",
"Property-based testing",
"Encode parser invariants as properties.",
"Encode parser invariants as Hypothesis properties; shrink counterexamples.",
languages=("python",),
tags=("parser",),
vulnerability_classes=("parsing",),
stages=("verify",),
),
SkillDescriptor(
"constant-time-analysis",
"Constant-time analysis",
"Find secret-dependent timing in crypto code.",
"Find secret-dependent branches/indexing/early-exit comparisons; require constant-time primitives.",
tags=("crypto",),
vulnerability_classes=("crypto",),
stages=("hunt", "verify"),
),
SkillDescriptor(
"wycheproof",
"Wycheproof vectors",
"Stress crypto usage with known-edge test vectors.",
"Run Wycheproof test vectors against the crypto usage; flag edge-case acceptance.",
tags=("crypto",),
vulnerability_classes=("crypto",),
stages=("verify",),
),
SkillDescriptor(
"vector-forge",
"Crypto vector forging",
"Forge boundary vectors to probe crypto APIs.",
"Forge boundary test vectors (zero/identity/malleable signatures) to probe the crypto API.",
tags=("crypto",),
vulnerability_classes=("crypto",),
stages=("verify",),
),
SkillDescriptor(
"zeroize-audit",
"Secret zeroization audit",
"Verify secrets are wiped after use.",
"Verify secrets are zeroized on drop; check for lingering copies.",
languages=("rust",),
tags=("crypto",),
vulnerability_classes=("crypto",),
stages=("verify",),
),
SkillDescriptor(
"cargo-fuzz",
"cargo-fuzz",
"Coverage-guided fuzzing for Rust entries.",
"Add a cargo-fuzz target over the entry; run under ASan.",
languages=("rust",),
tags=("fuzzable", "parser"),
vulnerability_classes=("memory-safety", "parsing"),
stages=("hunt", "verify"),
),
SkillDescriptor(
"sarif-parsing",
"SARIF parsing",
"Normalize external tool output into the map.",
"Normalize SARIF locations/rules/fingerprints into FileTarget static hints.",
vulnerability_classes=(),
stages=("map",),
),
SkillDescriptor(
"semgrep",
"Semgrep mapping",
"Pattern sinks and iterate variants.",
"Pattern the sink shape; iterate variants; export normalized findings.",
vulnerability_classes=("injection", "deserialization"),
stages=("map",),
),
SkillDescriptor(
"semgrep-rule-creator",
"Semgrep rule creator",
"Author a Semgrep rule for a sink.",
"Author a Semgrep rule for the sink; validate against the fixture.",
tags=("deserialization",),
vulnerability_classes=("injection", "deserialization"),
stages=("map",),
),
SkillDescriptor(
"semgrep-rule-variant-creator",
"Semgrep variant creator",
"Widen sink coverage with variants.",
"Generate sink variants (aliases, wrappers) to widen coverage without false positives.",
vulnerability_classes=("injection",),
stages=("map",),
),
SkillDescriptor(
"codeql",
"CodeQL dataflow",
"Model source→sink taint paths.",
"Model source→sink taint with a CodeQL query; inspect the dataflow path.",
vulnerability_classes=("injection",),
stages=("map",),
),
SkillDescriptor(
"differential-review",
"Differential review",
"Scope the changed attack surface in a diff.",
"Scope the changed attack surface and new trust-boundary crossings in the diff.",
stages=("map", "fix"),
),
SkillDescriptor(
"graph-evolution",
"Graph evolution",
"Bound a patch's blast radius.",
"Trace how the call/dataflow graph changed; bound the patch blast radius.",
stages=("fix",),
),
SkillDescriptor(
"sharp-edges",
"Sharp edges review",
"Map misuse-prone APIs at the boundary.",
"Map misuse-prone APIs, insecure defaults, and confusing configuration at the boundary.",
tags=("auth_boundary", "network_entry"),
vulnerability_classes=("access-control", "api-misuse"),
stages=("map", "hunt"),
),
SkillDescriptor(
"insecure-defaults",
"Insecure defaults review",
"Flag unsafe defaults exposed at entries.",
"Flag unsafe default flags/permissions/timeouts exposed at the entry.",
tags=("auth_boundary", "network_entry"),
vulnerability_classes=("access-control", "api-misuse"),
stages=("map", "hunt"),
),
)
_BY_ID = {skill.id: skill for skill in SKILL_INVENTORY}
@dataclass(frozen=True)
class SkillMatch:
skill: SkillDescriptor
score: int
reasons: list[str] = field(default_factory=list)
def route_skills(
*,
language: str | None = None,
tags: tuple[str, ...] = (),
vulnerability_classes: tuple[str, ...] = (),
stage: str | None = None,
max_snippets: int = 4,
) -> list[SkillDescriptor]:
"""Select the most relevant skills for the given routing signals.
A skill is excluded when its language list is non-empty and ``language`` is not in
it, or its stage list is non-empty and ``stage`` is not in it. Eligibility requires
at least one topical signal (tag/vulnerability-class/stage) — a pure language match
never selects a skill. Ranked by score, then id, for determinism.
"""
tag_set, vuln_set = set(tags), set(vulnerability_classes)
matches: list[SkillMatch] = []
for skill in SKILL_INVENTORY:
if skill.languages and language is not None and language not in skill.languages:
continue
if skill.stages and stage is not None and stage not in skill.stages:
continue
signal = 2 * len(vuln_set & set(skill.vulnerability_classes)) + len(tag_set & set(skill.tags))
if stage is not None and stage in skill.stages:
signal += 1
if signal == 0:
continue # no topical match — do not select on language alone
rank = signal + (3 if skill.languages and language in skill.languages else 0)
matches.append(SkillMatch(skill=skill, score=rank))
matches.sort(key=lambda match: (-match.score, match.skill.id))
return [match.skill for match in matches[:max_snippets]]
def _vuln_classes_for_tags(tags: tuple[str, ...]) -> tuple[str, ...]:
return tuple(sorted({TAG_TO_VULN[tag] for tag in tags if tag in TAG_TO_VULN}))
def route_skills_for_target(target: FileTarget, *, stage: str | None = None, max_snippets: int = 4) -> list[SkillDescriptor]:
"""Route skills for a scan target (language + tags + derived vulnerability classes)."""
tags = tuple(target.tags)
return route_skills(
language=target.language,
tags=tags,
vulnerability_classes=_vuln_classes_for_tags(tags),
stage=stage,
max_snippets=max_snippets,
)
def route_skill_ids(target: FileTarget, *, stage: str | None = None, max_snippets: int = 4) -> list[str]:
return [skill.id for skill in route_skills_for_target(target, stage=stage, max_snippets=max_snippets)]
def select_skill_context(target: FileTarget, *, stage: str | None = None, budget_chars: int, max_snippets: int = 4) -> str:
"""Pack routed skill checklists into a prompt context capped at ``budget_chars``."""
if budget_chars <= 0:
return ""
parts: list[str] = []
used = 0
for skill in route_skills_for_target(target, stage=stage, max_snippets=max_snippets):
snippet = f"### {skill.title}\n{skill.checklist}"
if not parts and len(snippet) > budget_chars:
return snippet[:budget_chars] # first snippet alone exceeds the cap → truncate
if used + len(snippet) + (1 if parts else 0) > budget_chars:
break
used += len(snippet) + (1 if parts else 0)
parts.append(snippet)
return "\n".join(parts)
def skill_chunks(skills: tuple[SkillDescriptor, ...] | None = None, *, max_lines: int = 40) -> list[CodeChunk]:
"""Chunk skill operating guidance into the ``skills`` retrieval namespace."""
chunks: list[CodeChunk] = []
for skill in skills or SKILL_INVENTORY:
text = f"# {skill.title}\n{skill.summary}\n\nChecklist:\n{skill.checklist}"
chunks.extend(
chunk_text_namespace(
namespace="skills",
path=f"skills/{skill.id}.md",
text=text,
metadata={
"skill_id": skill.id,
"languages": ",".join(skill.languages),
"tags": ",".join(skill.tags),
"vulnerability_classes": ",".join(skill.vulnerability_classes),
"stages": ",".join(skill.stages),
},
max_lines=max_lines,
)
)
return chunks
__all__ = [
"SKILL_INVENTORY",
"STAGES",
"TAG_TO_VULN",
"SkillDescriptor",
"route_skill_ids",
"route_skills",
"route_skills_for_target",
"select_skill_context",
"skill_chunks",
]
+93
View File
@@ -0,0 +1,93 @@
"""Slot-contract discipline for model-disabled deterministic stages (task 6.3).
The deterministic scan stages carry their work in named *slots* and run with model
invocation disabled (``skip_model = True``). Each stage declares a read/write slot
allow-list that is validated on every lifecycle hook, preserving the
``ProcessorSpec.reads/writes`` provenance discipline of the sync ``HarnessRuntime``
(the top-level driver) — without forcing the deterministic stages through HarnessX's
agent loop ("bridge, do not map", per the spec design).
This is the zero-dependency substrate. When the optional ``openultrasast[harnessx]``
extra is present, the same model-disabled processors can be hosted under a HarnessX
``MultiHookProcessor`` (see ``stage_processors.host_under_harnessx``); the
``skip_model`` marker maps onto HarnessX's ``BeforeModelEvent.skip_model`` so no
deterministic stage incurs a model call.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, Protocol
from .harness import HarnessContractError
class SlotContractError(HarnessContractError):
"""Raised when a stage reads or writes a slot outside its declared allow-list."""
class SlotContractMixin:
"""A model-disabled stage that carries work in slots under a read/write allow-list."""
name: str = "slot-stage"
version: str = "1"
reads_slots: tuple[str, ...] = ()
writes_slots: tuple[str, ...] = ()
skip_model: bool = True # deterministic: never invokes a model
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
raise NotImplementedError
def validate_reads(self, slots: Mapping[str, Any]) -> None:
missing = sorted(slot for slot in self.reads_slots if slot not in slots)
if missing:
raise SlotContractError(f"{self.name}: missing read slots: {', '.join(missing)}")
def validate_writes(self, updates: Mapping[str, Any]) -> None:
undeclared = sorted(set(updates) - set(self.writes_slots))
if undeclared:
raise SlotContractError(f"{self.name}: undeclared write slots: {', '.join(undeclared)}")
def execute(self, slots: Mapping[str, Any]) -> dict[str, Any]:
"""Validate the read allow-list, run the stage, validate the write allow-list."""
self.validate_reads(slots) # pre-hook
updates = self.run(slots) or {}
if not isinstance(updates, dict):
raise SlotContractError(f"{self.name}: stage returned a non-dict update")
self.validate_writes(updates) # post-hook
return updates
class _Emitter(Protocol):
def emit(self, event_type: Any, stage: str | None, payload: dict[str, Any]) -> None: ...
class SlotPipeline:
"""Runs slot processors in order, validating contracts and enforcing model-disable."""
def __init__(self, processors: list[SlotContractMixin]) -> None:
for proc in processors:
if not proc.skip_model:
raise SlotContractError(f"{proc.name}: deterministic stage must be model-disabled (skip_model=True)")
self._processors = processors
@property
def processors(self) -> list[SlotContractMixin]:
return list(self._processors)
def run(self, initial_slots: Mapping[str, Any], *, runtime: _Emitter | None = None) -> dict[str, Any]:
"""Execute every stage; with ``runtime`` set, emit stage_start/stage_end lifecycle hooks."""
slots: dict[str, Any] = dict(initial_slots)
for proc in self._processors:
if runtime is not None:
runtime.emit(
"stage_start", proc.name, {"reads": list(proc.reads_slots), "writes": list(proc.writes_slots), "skip_model": True}
)
updates = proc.execute(slots)
slots.update(updates)
if runtime is not None:
runtime.emit("stage_end", proc.name, {"status": "succeeded", "wrote": sorted(updates)})
return slots
__all__ = ["SlotContractError", "SlotContractMixin", "SlotPipeline"]
+170
View File
@@ -0,0 +1,170 @@
"""Deterministic scan stages as model-disabled, slot-contracted processors (task 6.3).
Each stage is a :class:`slot_contract.SlotContractMixin` with ``skip_model = True`` and
a declared read/write slot allow-list. Running them through a :class:`SlotPipeline`
reproduces the deterministic quick-scan path byte-for-byte while enforcing the slot
contract on every lifecycle hook. The hunter and verifier stages are *not* here —
they are the agentic plane (``hunter_harness``/``verify_judge``); this module is the
deterministic, model-free remainder.
``host_under_harnessx`` is the optional, lazy bridge: it wraps a model-disabled stage
as a HarnessX ``MultiHookProcessor`` that disables the model call on ``before_model``.
"""
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from .findings import quick_scan_findings
from .mapping import analyze_entry_points, attach_reachability_hints
from .policy import load_policy
from .preprocess import preprocess_repository
from .rank import rank_targets
from .ruleset import DEFAULT_RULESET_DIR, load_ruleset
from .scoring import build_score_artifact
from .slot_contract import SlotContractMixin, SlotPipeline
class PreprocessProcessor(SlotContractMixin):
name = "preprocess"
reads_slots = ("root",)
writes_slots = ("snapshot", "targets")
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
snapshot, targets = preprocess_repository(slots["root"])
return {"snapshot": snapshot, "targets": targets}
class EntryPointProcessor(SlotContractMixin):
name = "entry_point"
reads_slots = ("root", "targets")
writes_slots = ("targets", "entry_points")
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
entry_points = analyze_entry_points(slots["root"], slots["targets"])
targets = attach_reachability_hints(slots["targets"], entry_points)
return {"targets": targets, "entry_points": entry_points}
class RankProcessor(SlotContractMixin):
name = "rank"
reads_slots = ("targets",)
writes_slots = ("rankings",)
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
return {"rankings": rank_targets(slots["targets"])}
class QuickFindingsProcessor(SlotContractMixin):
name = "quick_findings"
reads_slots = ("root", "targets", "rankings", "ruleset", "policy")
writes_slots = ("findings",)
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
findings = quick_scan_findings(slots["root"], slots["targets"], slots["rankings"], slots["ruleset"], slots["policy"])
return {"findings": findings}
class PolicyScoringProcessor(SlotContractMixin):
name = "policy_scoring"
reads_slots = ("findings",)
writes_slots = ("reported_findings", "shadow_findings")
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
findings = slots["findings"]
shadow = [finding for finding in findings if finding.status == "shadow"]
reported = [finding for finding in findings if finding.status != "shadow"]
return {"reported_findings": reported, "shadow_findings": shadow}
class ScoreProcessor(SlotContractMixin):
name = "score"
reads_slots = ("reported_findings", "ruleset", "policy")
writes_slots = ("score",)
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
cwe_by_rule = {rule.rule_id: rule.cwe for rule in slots["ruleset"]}
reported = slots["reported_findings"]
rule_cwe_by_id = {f.finding_id: cwe_by_rule.get(f.finding_id.split(":", 1)[0], "") for f in reported}
return {"score": build_score_artifact(reported, rule_cwe_by_id, slots["policy"])}
def deterministic_processors() -> list[SlotContractMixin]:
"""The ordered deterministic stages (preprocess → entry-point → rank → findings → policy → score)."""
return [
PreprocessProcessor(),
EntryPointProcessor(),
RankProcessor(),
QuickFindingsProcessor(),
PolicyScoringProcessor(),
ScoreProcessor(),
]
def build_deterministic_pipeline() -> SlotPipeline:
return SlotPipeline(deterministic_processors())
def run_quick_pipeline(
root: Path | str,
*,
ruleset: Any = None,
policy: Any = None,
runtime: Any = None,
) -> dict[str, Any]:
"""Run the deterministic quick-scan pipeline and return the final slot map.
Defaults to the bundled ruleset and the vendored policy, which makes the
``reported_findings`` slot byte-identical to ``findings.quick_scan_findings`` on
the same target.
"""
resolved_ruleset = ruleset if ruleset is not None else load_ruleset(DEFAULT_RULESET_DIR)
resolved_policy = policy if policy is not None else load_policy()
initial = {"root": Path(root), "ruleset": resolved_ruleset, "policy": resolved_policy}
return build_deterministic_pipeline().run(initial, runtime=runtime)
def host_under_harnessx(processor: SlotContractMixin) -> Any:
"""Host a model-disabled deterministic stage under a HarnessX ``MultiHookProcessor``.
Lazy + capability-guarded (imports ``harnessx`` only when called). On the
``before_model`` hook the hosted stage yields a ``BeforeModelEvent`` with
``skip_model=True``, so no deterministic stage incurs a model call when it runs on
the HarnessX runloop. Raises :class:`HarnessXUnavailableError` when the extra is absent.
"""
import dataclasses
from .harness_ext import require_harnessx
require_harnessx()
from harnessx.core.events import BeforeModelEvent
from harnessx.core.processor import MultiHookProcessor
class _ModelDisabledStage(MultiHookProcessor): # type: ignore[misc, valid-type]
_singleton_group = "ousast_deterministic"
_order = 50
def __init__(self, stage: SlotContractMixin) -> None:
self.stage = stage
self.name = stage.name
async def on_before_model(self, event: BeforeModelEvent): # type: ignore[no-untyped-def]
yield dataclasses.replace(event, skip_model=True, synthetic_output=f"[deterministic stage {self.stage.name}: model disabled]")
return _ModelDisabledStage(processor)
__all__ = [
"EntryPointProcessor",
"PolicyScoringProcessor",
"PreprocessProcessor",
"QuickFindingsProcessor",
"RankProcessor",
"ScoreProcessor",
"build_deterministic_pipeline",
"deterministic_processors",
"host_under_harnessx",
"run_quick_pipeline",
]
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from enum import StrEnum
class Stage(StrEnum):
STATIC = "static"
MAP = "map"
REGRESS = "regress"
MODE_STAGES: dict[str, tuple[Stage, ...]] = {
"quick": (Stage.STATIC,),
"standard": (Stage.STATIC, Stage.MAP),
"deep": (Stage.STATIC, Stage.MAP, Stage.REGRESS),
}
@dataclass(frozen=True)
class StagePlan:
mode: str
requested: tuple[Stage, ...]
completed: tuple[Stage, ...]
skipped: tuple[dict[str, str], ...] # stage, reason
def plan_for_mode(mode: str) -> StagePlan:
return StagePlan(mode=mode, requested=MODE_STAGES[mode], completed=(), skipped=())
def skip_as_degradation(stage: Stage, reason: str) -> dict[str, str]:
return {"stage": str(stage), "reason": reason}
def record_skip(plan: StagePlan, stage: Stage, reason: str) -> StagePlan:
return replace(plan, skipped=plan.skipped + (skip_as_degradation(stage, reason),))
def record_completed(plan: StagePlan, stage: Stage) -> StagePlan:
return replace(plan, completed=plan.completed + (stage,))
def stages_payload(plan: StagePlan) -> dict[str, object]:
return {
"requested": [str(stage) for stage in plan.requested],
"completed": [str(stage) for stage in plan.completed],
"skipped": [dict(item) for item in plan.skipped],
}
+423
View File
@@ -0,0 +1,423 @@
"""Bounded tool-using hunter over map hotspots. Findings are suspicion only."""
from __future__ import annotations
import json
import os
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from .complexity.map import Hotspot
from .findings import StaticFinding
from .hunter_tools import PathEscapesRepo, clamp_repo_path, find_refs, grep_repo, read_file
from .provider.openrouter import OpenRouterChatClient, OpenRouterError
_DEFAULT_MAX_CHARS = 4000
_DEFAULT_MAX_MATCHES = 50
DEFAULT_MAX_STEPS = 4
CLIENT_ENV = "OPENULTRASAST_HUNTER_CLIENT"
_FINDING_ID_PREFIX = "tool-hunter:"
_HUNTER_TOOL_NAMES = frozenset({"read_file", "grep_repo", "find_refs"})
_SEVERITIES = frozenset({"critical", "high", "medium", "low", "info"})
_SCRIPTED_FLAGS = frozenset({"scripted", "script"})
_DUMP_FLAGS = frozenset({"dump", "dump-only"})
_UNSAFE_FLAGS = frozenset({"unsafe", "unsafe-snippet"})
_OPENROUTER_FLAGS = frozenset({"openrouter", "live"})
_UNSAFE_SNIPPET = 'client = docker.DockerClient(base_url="unix://var/run/docker.sock")\n'
_SCRIPTED_FINDINGS = [
{
"path": "app.py",
"line": 3,
"title": "Dynamic execution of attacker-controlled data",
"rationale": "eval/execute follows request input across the hotspot.",
"evidence_level": "static_corroboration",
}
]
HUNTER_TOOLS: list[dict[str, object]] = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file inside the scanned repository root.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path relative to the repository root."},
"max_chars": {"type": "integer"},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "grep_repo",
"description": "Search repository source text with a Python regular expression.",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string"},
"max_matches": {"type": "integer"},
},
"required": ["pattern"],
},
},
},
{
"type": "function",
"function": {
"name": "find_refs",
"description": "List references to a symbol inside the scanned repository.",
"parameters": {
"type": "object",
"properties": {"symbol": {"type": "string"}},
"required": ["symbol"],
},
},
},
]
_SYSTEM_PROMPT = (
"You are a tool-using security hunter. Tools: read_file, grep_repo, find_refs. "
"Stay inside the scanned repository. Use a tool before concluding. "
"After using tools, reply with a JSON array of objects with path, line, title, and rationale. "
"These findings are suspicions, not static corroboration."
)
@dataclass(frozen=True)
class ToolCall:
id: str
name: str
arguments: dict[str, object]
@dataclass(frozen=True)
class ChatResponse:
content: str | None = None
tool_calls: tuple[ToolCall, ...] = ()
class ChatClient(Protocol):
def complete(
self,
*,
model: str,
messages: list[dict[str, object]],
tools: list[dict[str, object]],
timeout_seconds: int = 60,
) -> ChatResponse:
raise NotImplementedError
class ScriptedChatClient:
"""Deterministic ChatClient for tests and OPENULTRASAST_HUNTER_CLIENT injection."""
def __init__(self, turns: list[ChatResponse]) -> None:
self._turns = turns
self.calls: list[dict[str, object]] = []
def complete(
self,
*,
model: str,
messages: list[dict[str, object]],
tools: list[dict[str, object]],
timeout_seconds: int = 60,
) -> ChatResponse:
self.calls.append({"model": model, "messages": list(messages), "tools": tools, "timeout_seconds": timeout_seconds})
index = len(self.calls) - 1
if index >= len(self._turns):
return ChatResponse(content="")
return self._turns[index]
class OpenRouterHunterClient:
"""Adapt OpenRouterChatClient to the tool-hunter ChatClient protocol."""
def __init__(self, client: OpenRouterChatClient) -> None:
self._client = client
@classmethod
def from_env(cls) -> OpenRouterHunterClient:
return cls(OpenRouterChatClient.from_env())
def complete(
self,
*,
model: str,
messages: list[dict[str, object]],
tools: list[dict[str, object]],
timeout_seconds: int = 60,
) -> ChatResponse:
message = self._client.complete_chat(model=model, messages=messages, tools=tools, timeout_seconds=timeout_seconds)
return chat_response_from_message(message)
def scripted_hunter_client(mode: str = "scripted") -> ScriptedChatClient:
grep_turn = ChatResponse(
tool_calls=(ToolCall(id="call_grep", name="grep_repo", arguments={"pattern": "eval|execute|query", "max_matches": 10}),)
)
if mode in _DUMP_FLAGS:
return ScriptedChatClient([ChatResponse(content=json.dumps(_SCRIPTED_FINDINGS))])
if mode in _UNSAFE_FLAGS:
payload = [{**_SCRIPTED_FINDINGS[0], "snippet": _UNSAFE_SNIPPET}]
return ScriptedChatClient([grep_turn, ChatResponse(content=json.dumps(payload))])
return ScriptedChatClient([grep_turn, ChatResponse(content=json.dumps(_SCRIPTED_FINDINGS))])
def resolve_hunter_client() -> ChatClient | None:
"""Return a hunter client from env, or OpenRouter when an API key is present."""
flag = os.environ.get(CLIENT_ENV, "").strip().lower()
if flag in _SCRIPTED_FLAGS:
return scripted_hunter_client("scripted")
if flag in _DUMP_FLAGS:
return scripted_hunter_client("dump-only")
if flag in _UNSAFE_FLAGS:
return scripted_hunter_client("unsafe-snippet")
if flag in _OPENROUTER_FLAGS or (not flag and os.environ.get("OPENROUTER_API_KEY")):
try:
return OpenRouterHunterClient.from_env()
except OpenRouterError:
return None
return None
def chat_response_from_message(message: Mapping[str, object]) -> ChatResponse:
content = message.get("content")
text = content if isinstance(content, str) else None
raw_calls = message.get("tool_calls") or ()
calls: list[ToolCall] = []
if isinstance(raw_calls, Sequence) and not isinstance(raw_calls, str | bytes):
for index, item in enumerate(raw_calls):
parsed = _tool_call_from_openrouter(item, index)
if parsed is not None:
calls.append(parsed)
return ChatResponse(content=text, tool_calls=tuple(calls))
def _tool_call_from_openrouter(item: object, index: int) -> ToolCall | None:
if not isinstance(item, Mapping):
return None
function = item.get("function")
if not isinstance(function, Mapping):
return None
name = function.get("name")
if not isinstance(name, str) or not name:
return None
call_id = item.get("id")
identifier = call_id if isinstance(call_id, str) and call_id else f"call_{index}"
arguments = function.get("arguments", {})
if isinstance(arguments, str):
try:
loaded = json.loads(arguments)
except json.JSONDecodeError:
loaded = {}
parsed_args = loaded if isinstance(loaded, dict) else {}
elif isinstance(arguments, Mapping):
parsed_args = dict(arguments)
else:
parsed_args = {}
return ToolCall(id=identifier, name=name, arguments=parsed_args)
def run_tool_hunter(
root: Path,
hotspots: list[Hotspot],
*,
client: ChatClient,
model: str,
max_steps: int,
) -> list[StaticFinding]:
if max_steps <= 0:
return []
messages: list[dict[str, object]] = [
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": _hotspot_prompt(hotspots)},
]
used_tools = False
for _step in range(max_steps):
response = client.complete(model=model, messages=messages, tools=HUNTER_TOOLS)
tool_calls = tuple(response.tool_calls or ())
if tool_calls:
messages.append(_assistant_tool_message(response.content, tool_calls))
for call in tool_calls:
if call.name in _HUNTER_TOOL_NAMES:
used_tools = True
messages.append(_tool_result_message(root, call))
continue
content = response.content or ""
if not content.strip() or not used_tools:
# A findings dump is not evidence unless the loop invoked a repo tool.
return []
return _findings_from_content(root, hotspots, content)
return []
def _hotspot_prompt(hotspots: Sequence[Hotspot]) -> str:
payload = [
{
"path": hotspot.path,
"function_name": hotspot.function_name,
"score": hotspot.score,
"band": hotspot.band,
"rationale": hotspot.rationale,
}
for hotspot in hotspots
]
return (
"Inspect these complexity-map hotspots with the repository tools. "
"Return JSON findings only after using a tool.\n"
f"{json.dumps(payload, sort_keys=True)}"
)
def _assistant_tool_message(content: str | None, tool_calls: Sequence[ToolCall]) -> dict[str, object]:
return {
"role": "assistant",
"content": content,
"tool_calls": [
{
"id": call.id,
"type": "function",
"function": {
"name": call.name,
"arguments": json.dumps(call.arguments, sort_keys=True) if not isinstance(call.arguments, str) else call.arguments,
},
}
for call in tool_calls
],
}
def _tool_result_message(root: Path, call: ToolCall) -> dict[str, object]:
return {
"role": "tool",
"tool_call_id": call.id,
"name": call.name,
"content": _run_tool(root, call),
}
def _run_tool(root: Path, call: ToolCall) -> str:
try:
payload = _dispatch_tool(root, call.name, _arguments(call.arguments))
except Exception as exc: # noqa: BLE001 — tool failures stay in-band so the scan continues
payload = {"error": f"{type(exc).__name__}: {exc}"}
return json.dumps(payload, sort_keys=True)
def _dispatch_tool(root: Path, name: str, arguments: Mapping[str, object]) -> object:
if name == "read_file":
path = _require_str(arguments, "path")
max_chars = _optional_int(arguments, "max_chars", _DEFAULT_MAX_CHARS)
return {"path": path, "content": read_file(root, path, max_chars=max_chars)}
if name == "grep_repo":
pattern = _require_str(arguments, "pattern")
max_matches = _optional_int(arguments, "max_matches", _DEFAULT_MAX_MATCHES)
return grep_repo(root, pattern, max_matches=max_matches)
if name == "find_refs":
symbol = _require_str(arguments, "symbol")
return find_refs(root, symbol, None)
raise ValueError(f"unknown tool: {name}")
def _arguments(raw: object) -> dict[str, object]:
if isinstance(raw, str):
parsed = json.loads(raw)
if isinstance(parsed, dict):
return parsed
raise ValueError("tool arguments must be a JSON object")
if isinstance(raw, Mapping):
return dict(raw)
raise ValueError("tool arguments must be an object")
def _require_str(arguments: Mapping[str, object], key: str) -> str:
value = arguments.get(key)
if not isinstance(value, str) or not value:
raise ValueError(f"{key} is required")
return value
def _optional_int(arguments: Mapping[str, object], key: str, default: int) -> int:
value = arguments.get(key, default)
if isinstance(value, bool) or not isinstance(value, int):
return default
return value
def _findings_from_content(root: Path, hotspots: Sequence[Hotspot], content: str) -> list[StaticFinding]:
payload = _load_json(content)
if isinstance(payload, list):
items = payload
elif isinstance(payload, dict) and isinstance(payload.get("findings"), list):
items = payload["findings"]
else:
return []
scores = {hotspot.path: hotspot.score for hotspot in hotspots}
findings: list[StaticFinding] = []
for item in items:
if not isinstance(item, dict):
continue
finding = _finding_from_item(root, item, scores)
if finding is not None:
findings.append(finding)
return sorted(findings, key=lambda item: item.finding_id)
def _load_json(content: str) -> object | None:
stripped = content.strip()
fenced = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", stripped, flags=re.DOTALL)
if fenced is not None:
stripped = fenced.group(1).strip()
try:
parsed: object = json.loads(stripped)
except json.JSONDecodeError:
return None
return parsed
def _finding_from_item(root: Path, item: Mapping[str, object], scores: Mapping[str, float]) -> StaticFinding | None:
raw_path = item.get("path")
if not isinstance(raw_path, str) or not raw_path:
return None
try:
relative = _clamp_relative_path(root, raw_path)
except PathEscapesRepo:
return None
line = item.get("line")
line_no = line if isinstance(line, int) and not isinstance(line, bool) else None
title = item.get("title")
rationale = item.get("rationale")
function_name = item.get("function_name")
severity = item.get("severity")
snippet = item.get("snippet")
proposed_snippet = snippet if isinstance(snippet, str) and snippet else None
return StaticFinding(
finding_id=f"{_FINDING_ID_PREFIX}{relative}:{line_no if line_no is not None else 0}",
path=relative,
title=title if isinstance(title, str) and title else "tool hunter suspicion",
severity=severity if isinstance(severity, str) and severity in _SEVERITIES else "medium",
confidence="low",
evidence_level="suspicion",
rationale=rationale if isinstance(rationale, str) and rationale else "Reported by the tool-using hunter.",
line=line_no,
function_name=function_name if isinstance(function_name, str) and function_name else None,
reachability_status="unknown",
reachability_evidence=[],
reachability_conditions=[],
tags=["tool-hunter"],
ranking_priority=float(scores.get(relative, 0.0)),
proposed_snippet=proposed_snippet,
)
def _clamp_relative_path(root: Path, user_path: str) -> str:
resolved_root = root.resolve()
return clamp_repo_path(resolved_root, user_path).relative_to(resolved_root).as_posix()
+130
View File
@@ -0,0 +1,130 @@
"""Verifier dispatcher: HarnessX llm-judge when available, structural fallback otherwise.
The default path (no extra) is the existing :func:`verification.verify_findings`
verbatim — same ``list[VerificationResult]``, same rejection of unknown reachability —
so behaviour is byte-identical without HarnessX. The judge path is lazy/guarded.
"""
from __future__ import annotations
from .findings import StaticFinding
from .harness_ext import build_provider, has_harnessx
from .verification import (
EvidenceLevel,
VerificationResult,
VerificationStatus,
build_independent_verifier_context,
is_report_verified,
verify_findings,
)
def verify_findings_dispatch(
findings: list[StaticFinding],
*,
verifier_model: str | None = None,
verifier_provider: str = "anthropic",
use_harnessx: bool = False,
) -> list[VerificationResult]:
"""Drop-in superset of :func:`verification.verify_findings`.
Falls back to the structural verifier unless the HarnessX extra is present, a
verifier model is configured, and ``use_harnessx`` is set.
"""
if not (use_harnessx and verifier_model and has_harnessx()):
return verify_findings(findings)
return _verify_with_judge(findings, verifier_model, verifier_provider)
def _verify_with_judge(findings: list[StaticFinding], verifier_model: str, verifier_provider: str) -> list[VerificationResult]:
import asyncio
return asyncio.run(_judge_all(findings, verifier_model, verifier_provider))
async def _judge_all(findings: list[StaticFinding], verifier_model: str, verifier_provider: str) -> list[VerificationResult]:
from harnessx.core.events import Message
from harnessx.processors.evaluation.llm_judge import build_judge_prompt, parse_judge_response
provider = build_provider(verifier_model, verifier_provider)
results: list[VerificationResult] = []
for finding in findings:
prompt = build_judge_prompt(
task_description=f"Is this a real, exploitable security issue: {finding.title}? Judge on evidence only.",
trajectory_summary=_finding_summary(finding),
extracted_answer=finding.rationale,
)
try:
response = await provider.complete(messages=[Message(role="user", content=prompt)], tools=[])
verdict = parse_judge_response(_text(response.content))
except Exception: # noqa: BLE001 — judge/provider failure must never auto-reject a finding
verdict = {"verdict": "no_answer", "confidence": 0.0}
results.append(_verdict_to_verification(finding, verdict))
return results
def _verdict_to_verification(finding: StaticFinding, verdict: dict[str, object]) -> VerificationResult:
"""Map an llm-judge verdict onto a VerificationResult (infra failure never auto-rejects)."""
label = str(verdict.get("verdict", "no_answer"))
raw_confidence = verdict.get("confidence", 0.0)
confidence = 0.0
if isinstance(raw_confidence, (int, float)):
confidence = float(raw_confidence)
elif isinstance(raw_confidence, str):
try:
confidence = float(raw_confidence)
except ValueError:
confidence = 0.0
cause = str(verdict.get("cause", "")).strip()
lesson = str(verdict.get("lesson", "")).strip()
missing = str(verdict.get("missing", "")).strip() or _missing_capability_summary(verdict)
level = EvidenceLevel(finding.evidence_level)
if label == "plausible" and confidence >= 0.6:
status = VerificationStatus.ACCEPTED
elif label in ("unsupported", "refused"):
status = VerificationStatus.REJECTED
else: # hedging / format_wrong / no_answer / low-confidence plausible
status = VerificationStatus.NEEDS_EVIDENCE
if status == VerificationStatus.ACCEPTED:
pro_case = cause or "The judge found the issue plausible on the available evidence."
counter_case = "Impact and exploitability still require deeper verifier or dynamic evidence."
else:
pro_case = "A candidate issue exists from the finding's evidence."
counter_case = cause or "The judge could not confirm the issue from the available evidence."
return VerificationResult(
finding_id=finding.finding_id,
status=status,
evidence_level=level,
verified=is_report_verified(level, status),
pro_case=pro_case,
counter_case=counter_case,
tie_breaker=lesson or "Confirm attacker control, sanitizer absence, and impact.",
required_next_step=missing or "Collect corroborating evidence before reporting as verified.",
context_sources=sorted(build_independent_verifier_context(finding).keys()),
)
def _finding_summary(finding: StaticFinding) -> str:
return (
f"{finding.title} at {finding.path}:{finding.line} (function={finding.function_name or 'unknown'}); "
f"reachability={finding.reachability_status}; tags={', '.join(finding.tags) or 'none'}"
)
def _missing_capability_summary(verdict: dict[str, object]) -> str:
capability = verdict.get("missing_capability")
if isinstance(capability, dict):
return str(capability.get("summary", "")).strip()
return ""
def _text(content: object) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [str(block.get("text", "")) if isinstance(block, dict) else str(block) for block in content]
return "".join(parts)
return str(content)
+33
View File
@@ -0,0 +1,33 @@
"""Shared test fixtures."""
import subprocess
import sys
import pytest
_HARNESSX_GUARD = (
"\nimport sys as _sys\n"
"_leaked = sorted(m for m in _sys.modules if m == 'harnessx' or m.startswith('harnessx.'))\n"
"assert not _leaked, 'leaked harnessx modules: ' + repr(_leaked)\n"
)
@pytest.fixture
def assert_cold_of_harnessx(): # type: ignore[no-untyped-def]
"""Run ``code`` in a fresh interpreter and assert it imported no ``harnessx`` module.
Hermetic by construction: the lazy-import guarantee is verified the same way
whether or not the optional extra is installed in this environment (a global
``sys.modules`` check would be polluted by other tests once the extra is present).
"""
def _check(code: str) -> str:
proc = subprocess.run(
[sys.executable, "-c", code + _HARNESSX_GUARD],
capture_output=True,
text=True,
)
assert proc.returncode == 0, f"exit={proc.returncode}\nstdout={proc.stdout}\nstderr={proc.stderr}"
return proc.stdout
return _check
+44
View File
@@ -0,0 +1,44 @@
{
"overall": {
"actual": 44,
"expected": 47,
"false_positive_rate": 0.0,
"false_positives": 0,
"matched": 44,
"recall": 0.93617
},
"per_language": {
"c_cpp": {
"actual": 14,
"expected": 15,
"false_positive_rate": 0.0,
"false_positives": 0,
"matched": 14,
"recall": 0.933333
},
"java": {
"actual": 4,
"expected": 4,
"false_positive_rate": 0.0,
"false_positives": 0,
"matched": 4,
"recall": 1.0
},
"javascript": {
"actual": 12,
"expected": 13,
"false_positive_rate": 0.0,
"false_positives": 0,
"matched": 12,
"recall": 0.923077
},
"python": {
"actual": 14,
"expected": 15,
"false_positive_rate": 0.0,
"false_positives": 0,
"matched": 14,
"recall": 0.933333
}
}
}
+73 -1
View File
@@ -63,8 +63,13 @@ def test_repository_benchmark_manifests_load_and_point_to_fixtures() -> None:
"java-web-smoke.toml", "java-web-smoke.toml",
"javascript-node-web-smoke.toml", "javascript-node-web-smoke.toml",
"javascript-vulnerable.toml", "javascript-vulnerable.toml",
"mutation-python.toml",
"python-vulnerable.toml", "python-vulnerable.toml",
"python-web-smoke.toml", "python-web-smoke.toml",
"split-sink-c.toml",
"split-sink-java.toml",
"split-sink-javascript.toml",
"split-sink-python.toml",
} }
for manifest_path in manifest_paths: for manifest_path in manifest_paths:
manifest = load_benchmark_manifest(manifest_path) manifest = load_benchmark_manifest(manifest_path)
@@ -322,15 +327,82 @@ evidence = "memcpy copies attacker-controlled input"
"cwe": "CWE-120", "cwe": "CWE-120",
"evidence": "memcpy copies attacker-controlled input", "evidence": "memcpy copies attacker-controlled input",
"failed_stage": "benchmark_ground_truth_matching", "failed_stage": "benchmark_ground_truth_matching",
"next_improvement_candidate": "rules_or_language_hunter", "next_improvement_candidate": "rule:c-unsafe-memory-copy",
"path": "src/vuln.cpp", "path": "src/vuln.cpp",
"reason": "no OpenUltraSAST finding matched the expected benchmark vulnerability", "reason": "no OpenUltraSAST finding matched the expected benchmark vulnerability",
"rule_id": "c-unsafe-memory-copy",
"vulnerability_class": "unsafe memory copy", "vulnerability_class": "unsafe memory copy",
} }
] ]
assert deltas == [{"extra_findings_total": 1, "matched_expected_total": 1, "missed_expected_total": 0, "tool": "clearwing"}] assert deltas == [{"extra_findings_total": 1, "matched_expected_total": 1, "missed_expected_total": 0, "tool": "clearwing"}]
def test_benchmark_attributes_per_rule_signals_and_recommendations(tmp_path: Path) -> None:
manifest_path = tmp_path / "benchmark.toml"
repo = tmp_path / "repo"
repo.mkdir()
manifest_path.write_text(
"""
name = "cpp-smoke"
language = "c_cpp"
[source]
path = "repo"
[[expected]]
cwe = "CWE-120"
class = "buffer overflow"
path = "src/vuln.cpp"
line = 9
rule_id = "c-unsafe-memory-copy"
sink = "memcpy"
evidence = "memcpy copies attacker-controlled input"
[[expected]]
cwe = "CWE-78"
class = "command injection"
path = "src/vuln.cpp"
line = 20
rule_id = "c-shell-exec"
sink = "system"
evidence = "system runs attacker input"
""".strip()
+ "\n"
)
manifest = load_benchmark_manifest(manifest_path)
run = create_benchmark_run(repo, manifest)
findings = [
_finding(
finding_id="c-unsafe-memory-copy:src/vuln.cpp:9",
path="src/vuln.cpp",
line=9,
rationale="Static pattern c-unsafe-memory-copy matched memcpy with attacker-controlled input.",
),
_finding(
finding_id="js-eval:src/app.js:3",
path="src/app.js",
line=3,
rationale="Static pattern js-eval matched eval.",
),
]
result = evaluate_benchmark(run=run, mode="quick", findings=findings, scan_id="scan-1", scan_run_dir=repo / ".runs" / "scan-1")
write_benchmark_artifacts(run, manifest_path, result)
per_rule = result.metrics.per_rule
assert per_rule["c-unsafe-memory-copy"] == {"matched": 1, "missed": 0, "false_positives": 0}
assert per_rule["c-shell-exec"]["missed"] == 1
assert per_rule["js-eval"]["false_positives"] == 1
actions = {rec.rule_id: rec.action for rec in result.rule_recommendations}
assert actions["c-unsafe-memory-copy"] == "keep"
assert actions["c-shell-exec"] == "loosen"
assert actions["js-eval"] == "shadow"
delta = json.loads((run.root / "rule_policy_delta.json").read_text())
assert {item["rule_id"] for item in delta} == {"c-unsafe-memory-copy", "c-shell-exec", "js-eval"}
def _finding( def _finding(
*, *,
finding_id: str, finding_id: str,
+62
View File
@@ -0,0 +1,62 @@
"""CLI capability-gated dispatch + graceful degradation (Phase 3 task 6.5).
Simulates the extra-absent environment (via a monkeypatched capability probe so the
behaviour is deterministic whether or not the optional extra is installed): standard
mode with models configured must fall back to the deterministic hunter/structural
verifier and record a degradation; with no models configured the manifest stays
byte-identical.
"""
import json
from pathlib import Path
import pytest
from openultrasast import cli
from openultrasast.cli import main
_VULN = "@app.route('/admin')\ndef admin():\n return eval(request.data)\n"
def _run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, config_body: str | None) -> dict:
repo = tmp_path / "repo"
repo.mkdir()
(repo / "app.py").write_text(_VULN)
monkeypatch.setattr(cli, "has_harnessx", lambda: False) # simulate the extra being absent
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("OPENULTRASAST_HUNTER_CLIENT", raising=False)
argv = ["scan", str(repo), "--mode", "standard"]
if config_body is not None:
cfg = tmp_path / "openultrasast.toml"
cfg.write_text(config_body)
argv += ["--config", str(cfg)]
assert main(argv) == 0
run_dir = sorted((repo / ".runs").iterdir())[-1]
return json.loads((run_dir / "manifest.json").read_text())
def test_degradation_recorded_when_models_set_but_extra_absent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
manifest = _run(tmp_path, monkeypatch, '[models]\nhunter = "anthropic/claude"\nverifier = "anthropic/claude"\n')
degradations = manifest.get("degradations", [])
stages = {entry["stage"] for entry in degradations}
assert stages == {"hunter_pool", "verify"}
assert all(entry["reason"] == "harnessx_extra_unavailable" for entry in degradations)
# Fallback still produced findings (deterministic hunter ran).
assert manifest["findings"]
def test_manifest_has_no_harnessx_degradations_without_models(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
manifest = _run(tmp_path, monkeypatch, config_body=None)
degradations = manifest.get("degradations", [])
assert all(entry.get("reason") != "harnessx_extra_unavailable" for entry in degradations)
assert any(entry.get("reason") == "hunter_model_unavailable" for entry in degradations)
def test_standard_scan_falls_back_to_run_hunter_pool(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[str] = []
real = cli.run_hunter_pool
monkeypatch.setattr(cli, "run_hunter_pool", lambda *a, **k: calls.append("fallback") or real(*a, **k))
_run(tmp_path, monkeypatch, '[models]\nhunter = "anthropic/claude"\n')
assert calls == ["fallback"]
+60
View File
@@ -0,0 +1,60 @@
"""`ousast improve` CLI: drives the self-improvement loop and the loop-owned ledger."""
from pathlib import Path
from openultrasast.cli import main
from openultrasast.ruleset import PatternRule, read_rule_ledger, write_ruleset
def _rule(rule_id: str, pattern: str) -> PatternRule:
return PatternRule(rule_id=rule_id, title=rule_id, languages=("python",), cwe="CWE-95", tags=("injection",), pattern=pattern)
def _expected_block(rule_id: str) -> str:
return (
'[[expected]]\ncwe = "CWE-95"\nclass = "code injection"\npath = "app.py"\n'
f'line = 2\nrule_id = "{rule_id}"\nsink = "eval"\nevidence = "eval executes input"\n'
)
def _scaffold(tmp_path: Path) -> tuple[Path, Path]:
repo = tmp_path / "repo"
repo.mkdir()
(repo / "app.py").write_text("def f(x):\n return eval(x)\ndef g():\n print('hello')\n")
ruleset_dir = tmp_path / "ruleset"
write_ruleset(ruleset_dir / "python" / "rules.toml", [_rule("good-eval", r"\beval\s*\("), _rule("noisy-print", r"\bprint\s*\(")])
manifest = tmp_path / "bench.toml"
manifest.write_text('name = "t"\nlanguage = "python_web"\n\n[source]\npath = "repo"\n\n' + _expected_block("good-eval"))
return repo, ruleset_dir
def test_improve_accepts_and_writes_loop_owned_ledger(tmp_path: Path) -> None:
repo, ruleset_dir = _scaffold(tmp_path)
manifest = tmp_path / "bench.toml"
exit_code = main(["improve", str(manifest), "--ruleset-dir", str(ruleset_dir)])
assert exit_code == 0
# The ledger lands exactly where `scan`/`benchmark` read it for this target.
ledger = repo / ".openultrasast" / "calibration" / "rule_policy.json"
assert ledger.exists()
assert read_rule_ledger(ledger)["noisy-print"]["status"] == "shadow"
journal = repo / ".openultrasast" / "calibration" / "improve_journal.json"
assert journal.exists()
def test_improve_dry_run_never_touches_the_targets_ledger(tmp_path: Path) -> None:
repo, ruleset_dir = _scaffold(tmp_path)
manifest = tmp_path / "bench.toml"
exit_code = main(["improve", str(manifest), "--ruleset-dir", str(ruleset_dir), "--dry-run"])
assert exit_code == 0
assert not (repo / ".openultrasast" / "calibration" / "rule_policy.json").exists()
def test_improve_rejects_missing_manifest(tmp_path: Path) -> None:
import pytest
with pytest.raises(SystemExit):
main(["improve", str(tmp_path / "nope.toml")])
+227
View File
@@ -0,0 +1,227 @@
import json
from pathlib import Path
from openultrasast.complexity.hints import GAP_COVERED, KIND_FUZZ_HARNESS
from openultrasast.complexity.map import build_complexity_map
from openultrasast.findings import StaticFinding
from openultrasast.preprocess import FileTarget
def _target(
path: str,
*,
loc: int,
tags: list[str] | None = None,
language: str = "python",
hints: list[dict[str, object]] | None = None,
has_fuzz_entry_point: bool = False,
absolute_path: str | None = None,
) -> FileTarget:
return FileTarget(
path=path,
absolute_path=absolute_path or f"/repo/{path}",
language=language,
loc=loc,
tags=list(tags or []),
has_fuzz_entry_point=has_fuzz_entry_point,
reachability_hints=list(hints or []),
)
def _hit(path: str, finding_id: str = "rule:helper:1") -> StaticFinding:
return StaticFinding(
finding_id=finding_id,
path=path,
title="pattern hit",
severity="low",
confidence="medium",
evidence_level="static_corroboration",
rationale="inventory",
line=1,
function_name=None,
reachability_status="unknown",
reachability_evidence=[],
reachability_conditions=[],
tags=[],
ranking_priority=1.0,
)
def _tree_snapshot(root: Path) -> dict[str, tuple[int, bytes]]:
snapshot: dict[str, tuple[int, bytes]] = {}
for path in sorted(root.rglob("*")):
if path.is_file():
snapshot[path.relative_to(root).as_posix()] = (path.stat().st_mtime_ns, path.read_bytes())
return snapshot
def test_covered_hotspot_emits_covered_and_no_test_kind(tmp_path: Path) -> None:
widget = _target(
"src/widget.py",
loc=12,
hints=[{"kind": "route", "access_level": "public", "function_name": "render"}],
)
artifact = tmp_path / "complexity_map.json"
complexity_map = build_complexity_map(
[widget],
[_hit("src/widget.py")],
artifact,
repo_files=("src/widget.py", "tests/test_widget.py"),
sources={
"src/widget.py": "def render():\n return 1\n",
"tests/test_widget.py": "from widget import render\n\ndef test_render():\n assert render() == 1\n",
},
)
hotspot = complexity_map.hotspots[0]
assert hotspot.test_hint is not None
assert hotspot.test_hint.gap == GAP_COVERED
assert hotspot.test_hint.gap == "covered"
assert hotspot.test_hint.test_kind is None
assert hotspot.test_hint.path == "src/widget.py"
assert hotspot.test_hint.function_name == "render"
payload = json.loads(artifact.read_text())
hint = payload["hotspots"][0]["test_hint"]
assert hint["gap"] == "covered"
assert hint["test_kind"] is None
def test_fuzzable_c_parser_without_harness_emits_fuzz_harness(tmp_path: Path) -> None:
parser = _target(
"src/parser.c",
loc=400,
language="c",
tags=["parser", "memory_unsafe"],
hints=[{"kind": "parser", "access_level": "public", "function_name": "parse_header"}],
has_fuzz_entry_point=False,
)
artifact = tmp_path / "complexity_map.json"
complexity_map = build_complexity_map(
[parser],
[],
artifact,
repo_files=("src/parser.c", "tests/test_parser.c"),
sources={
"src/parser.c": "int parse_header(const char *buf) { return 0; }\n",
"tests/test_parser.c": 'int test_parse_header(void) { return parse_header(""); }\n',
},
)
hotspot = complexity_map.hotspots[0]
assert hotspot.test_hint is not None
assert hotspot.test_hint.gap == "no_fuzz_entry"
assert hotspot.test_hint.test_kind == KIND_FUZZ_HARNESS
assert hotspot.test_hint.test_kind == "fuzz-harness"
assert hotspot.test_hint.path == "src/parser.c"
assert hotspot.test_hint.function_name == "parse_header"
assert hotspot.test_hint.reason
payload = json.loads(artifact.read_text())
hint = payload["hotspots"][0]["test_hint"]
assert hint["gap"] == "no_fuzz_entry"
assert hint["test_kind"] == "fuzz-harness"
def test_attach_test_hints_leaves_scanned_tree_unchanged(tmp_path: Path) -> None:
repo = tmp_path / "repo"
src = repo / "src"
tests_dir = repo / "tests"
src.mkdir(parents=True)
tests_dir.mkdir()
parser = src / "parser.c"
helper = src / "helper.py"
parser_test = tests_dir / "test_parser.c"
parser.write_text("int parse_header(const char *buf) {\n return buf[0];\n}\n")
helper.write_text("def ping():\n return 1\n")
parser_test.write_text("int test_parse_header(void) { return 0; }\n")
before = _tree_snapshot(repo)
artifact = tmp_path / "out" / "complexity_map.json"
targets = [
_target(
"src/parser.c",
loc=3,
language="c",
tags=["parser", "memory_unsafe"],
hints=[{"kind": "parser", "access_level": "public", "function_name": "parse_header"}],
absolute_path=str(parser),
),
_target("src/helper.py", loc=2, tags=[], absolute_path=str(helper)),
]
complexity_map = build_complexity_map(
targets,
[_hit("src/helper.py")],
artifact,
repo_files=("src/parser.c", "src/helper.py", "tests/test_parser.c"),
sources={
"src/parser.c": parser.read_text(),
"src/helper.py": helper.read_text(),
"tests/test_parser.c": parser_test.read_text(),
},
)
assert complexity_map.hotspots
assert all(hotspot.test_hint is not None for hotspot in complexity_map.hotspots)
assert _tree_snapshot(repo) == before
assert set(_tree_snapshot(repo)) == {"src/parser.c", "src/helper.py", "tests/test_parser.c"}
assert not any(path.as_posix().endswith(".py") and "test" in path.name for path in src.rglob("*"))
assert artifact.is_file()
assert artifact.is_relative_to(tmp_path / "out")
def test_no_adjacent_test_recommends_closed_test_kind(tmp_path: Path) -> None:
helper = _target("src/helper.py", loc=4, tags=[])
route = _target(
"src/api.py",
loc=40,
tags=["network_entry"],
hints=[{"kind": "route", "access_level": "public", "function_name": "handle"}],
)
artifact = tmp_path / "complexity_map.json"
complexity_map = build_complexity_map(
[helper, route],
[],
artifact,
repo_files=("src/helper.py", "src/api.py"),
sources={
"src/helper.py": "def ping():\n return 1\n",
"src/api.py": "def handle():\n return {}\n",
},
)
by_path = {hotspot.path: hotspot for hotspot in complexity_map.hotspots}
assert by_path["src/helper.py"].test_hint is not None
assert by_path["src/helper.py"].test_hint.gap == "no_adjacent_test"
assert by_path["src/helper.py"].test_hint.test_kind == "unit"
assert by_path["src/api.py"].test_hint is not None
assert by_path["src/api.py"].test_hint.gap == "no_adjacent_test"
assert by_path["src/api.py"].test_hint.test_kind == "http-contract"
def test_no_function_reference_when_adjacent_test_omits_function(tmp_path: Path) -> None:
widget = _target(
"src/widget.py",
loc=12,
hints=[{"kind": "route", "access_level": "public", "function_name": "render"}],
)
artifact = tmp_path / "complexity_map.json"
complexity_map = build_complexity_map(
[widget],
[],
artifact,
repo_files=("src/widget.py", "tests/test_widget.py"),
sources={
"src/widget.py": "def render():\n return 1\n",
"tests/test_widget.py": "def test_placeholder():\n assert True\n",
},
)
hint = complexity_map.hotspots[0].test_hint
assert hint is not None
assert hint.gap == "no_function_reference"
assert hint.test_kind == "unit"
assert hint.function_name == "render"
+281
View File
@@ -0,0 +1,281 @@
import json
from pathlib import Path
from types import SimpleNamespace
from openultrasast.complexity.ledger import (
NOT_TRIGGERABLE_DELTA,
TRIGGERABLE_DELTA,
LedgerEntry,
apply_overlay,
hotspot_key,
load_ledger,
must_keep_as_candidate,
persist_verdicts,
record_verdict,
select_forced_candidates,
write_ledger,
)
from openultrasast.complexity.map import Hotspot, build_complexity_map
from openultrasast.findings import StaticFinding
from openultrasast.policy import CwePolicy
from openultrasast.preprocess import FileTarget
def _target(
path: str,
*,
loc: int,
tags: list[str] | None = None,
language: str = "python",
hints: list[dict[str, object]] | None = None,
has_fuzz_entry_point: bool = False,
) -> FileTarget:
return FileTarget(
path=path,
absolute_path=f"/repo/{path}",
language=language,
loc=loc,
tags=list(tags or []),
has_fuzz_entry_point=has_fuzz_entry_point,
reachability_hints=list(hints or []),
)
def _hit(
path: str,
finding_id: str,
*,
function_name: str | None = None,
reachability: str = "unknown",
severity: str = "low",
rationale: str = "inventory",
) -> StaticFinding:
return StaticFinding(
finding_id=finding_id,
path=path,
title="pattern hit",
severity=severity,
confidence="medium",
evidence_level="static_corroboration",
rationale=rationale,
line=1,
function_name=function_name,
reachability_status=reachability,
reachability_evidence=[],
reachability_conditions=[],
tags=[],
ranking_priority=1.0,
)
def _hotspot(
path: str,
*,
function_name: str | None = None,
score: float = 4.0,
band: str = "medium",
inventory_finding_ids: tuple[str, ...] = (),
) -> Hotspot:
return Hotspot(
path=path,
function_name=function_name,
score=score,
band=band,
signals={"loc": 10, "inventory_hit_count": len(inventory_finding_ids)},
rationale=f"{band} band: score {score}",
test_hint=None,
inventory_finding_ids=inventory_finding_ids,
)
def test_hotspot_key_joins_path_and_function_or_empty() -> None:
assert hotspot_key("parser.c", "parse_header") == "parser.c:parse_header"
assert hotspot_key("src/app.py", None) == "src/app.py:"
assert hotspot_key("src/app.py", "") == "src/app.py:"
def test_triggerable_overlay_raises_later_score() -> None:
hotspot = _hotspot("parser.c", function_name="parse_header", score=4.0, inventory_finding_ids=("hit:1",))
ledger = record_verdict({}, path="parser.c", function_name="parse_header", verdict="triggerable")
overlaid = apply_overlay((hotspot,), ledger)
raised = overlaid[0]
assert ledger[hotspot_key("parser.c", "parse_header")].score_delta == TRIGGERABLE_DELTA
assert TRIGGERABLE_DELTA > 0
assert raised.score == hotspot.score + TRIGGERABLE_DELTA
assert raised.score > hotspot.score
assert raised.inventory_finding_ids == ("hit:1",)
def test_not_triggerable_overlay_drops_score_and_keeps_inventory_hit() -> None:
hotspot = _hotspot("src/auth.py", function_name="login", score=5.0, inventory_finding_ids=("cmd:src/auth.py:12",))
ledger = {
hotspot_key("src/auth.py", "login"): LedgerEntry(
score_delta=NOT_TRIGGERABLE_DELTA,
last_verdict="not_triggerable",
round=1,
)
}
overlaid = apply_overlay((hotspot,), ledger)
demoted = overlaid[0]
assert NOT_TRIGGERABLE_DELTA < 0
assert demoted.score == hotspot.score + NOT_TRIGGERABLE_DELTA
assert demoted.score < hotspot.score
assert demoted.inventory_finding_ids == ("cmd:src/auth.py:12",)
assert demoted.path == hotspot.path
assert demoted.function_name == hotspot.function_name
def test_not_triggerable_overlay_drops_map_score_and_sev5_reachable_stays_candidate(tmp_path: Path) -> None:
auth = _target(
"src/auth.py",
loc=80,
tags=["auth_boundary"],
hints=[{"kind": "route", "access_level": "public", "function_name": "login"}],
)
helper = _target("src/helper.py", loc=12, tags=[])
sev5 = _hit(
"src/auth.py",
"cmd:src/auth.py:12",
function_name="login",
reachability="reachable",
severity="critical",
rationale="Static pattern cmd (CWE-78) matched os.system on line 12; manual verification still required.",
)
noise = _hit("src/helper.py", "print:src/helper.py:1", reachability="unknown", severity="low")
artifact = tmp_path / "complexity_map.json"
baseline = build_complexity_map([auth, helper], [sev5, noise], artifact)
original = next(item for item in baseline.hotspots if item.path == "src/auth.py")
assert sev5.finding_id in original.inventory_finding_ids
ledger_path = tmp_path / ".openultrasast" / "calibration" / "complexity_ledger.json"
write_ledger(
ledger_path,
record_verdict({}, path=original.path, function_name=original.function_name, verdict="not_triggerable"),
)
overlaid = build_complexity_map([auth, helper], [sev5, noise], artifact, ledger_path=ledger_path)
demoted = next(item for item in overlaid.hotspots if item.path == "src/auth.py")
assert demoted.score < original.score
assert demoted.score == original.score + NOT_TRIGGERABLE_DELTA
assert demoted.inventory_finding_ids == original.inventory_finding_ids
assert sev5.finding_id in demoted.inventory_finding_ids
assert sev5.severity == "critical"
assert sev5.rationale == ("Static pattern cmd (CWE-78) matched os.system on line 12; manual verification still required.")
policy = {
sev5.finding_id: 5,
noise.finding_id: 2,
}
forced = select_forced_candidates([sev5, noise], policy)
assert forced == (sev5,)
assert must_keep_as_candidate(sev5, 5, "reachable") is True
assert must_keep_as_candidate(noise, 2, "unknown") is False
def test_must_keep_as_candidate_requires_sev5_and_reachable_surface() -> None:
reachable = _hit("a.py", "a:1", reachability="reachable")
inferred = _hit("b.py", "b:1", reachability="inferred-file-surface")
unknown = _hit("c.py", "c:1", reachability="unknown")
assert must_keep_as_candidate(reachable, 5, "reachable") is True
assert must_keep_as_candidate(inferred, 5, "inferred-file-surface") is True
assert must_keep_as_candidate(unknown, 5, "unknown") is False
assert must_keep_as_candidate(reachable, 4, "reachable") is False
assert must_keep_as_candidate(reachable, 5, None) is True
def test_select_forced_candidates_resolves_policy_severity() -> None:
sev5 = _hit("src/auth.py", "cmd:src/auth.py:12", reachability="reachable")
inferred = _hit("src/upload.py", "sql:src/upload.py:4", reachability="inferred-file-surface")
mild = _hit("src/helper.py", "print:src/helper.py:1", reachability="reachable")
policy = {
"CWE-78": CwePolicy("Command Injection", 5, True, False),
"CWE-89": CwePolicy("SQL Injection", 5, True, False),
"CWE-116": CwePolicy("Encode Output", 2, True, False),
}
forced = select_forced_candidates(
[mild, sev5, inferred],
policy,
rule_cwe={
sev5.finding_id: "CWE-78",
inferred.finding_id: "CWE-89",
mild.finding_id: "CWE-116",
},
)
assert forced == (sev5, inferred)
def test_ledger_round_trip_matches_design_schema(tmp_path: Path) -> None:
path = tmp_path / "complexity_ledger.json"
key = hotspot_key("parser.c", "parse_header")
write_ledger(
path,
{key: LedgerEntry(score_delta=1.5, last_verdict="triggerable", round=3)},
)
payload = json.loads(path.read_text())
assert payload == {
key: {
"last_verdict": "triggerable",
"round": 3,
"score_delta": 1.5,
}
}
loaded = load_ledger(path)
assert loaded[key] == LedgerEntry(score_delta=1.5, last_verdict="triggerable", round=3)
assert load_ledger(tmp_path / "missing.json") == {}
assert load_ledger(None) == {}
def test_overlay_reorders_and_rebands_without_dropping_hits() -> None:
high = _hotspot("src/auth.py", function_name="login", score=3.2, band="medium", inventory_finding_ids=("sev5:1",))
other = _hotspot("src/helper.py", score=2.0, band="low", inventory_finding_ids=("low:1",))
ledger = record_verdict({}, path="src/auth.py", function_name="login", verdict="not_triggerable")
overlaid = apply_overlay((high, other), ledger)
assert [item.path for item in overlaid] == ["src/helper.py", "src/auth.py"]
demoted = overlaid[1]
assert demoted.score == round(3.2 + NOT_TRIGGERABLE_DELTA, 4)
assert demoted.band == "low"
assert demoted.inventory_finding_ids == ("sev5:1",)
assert overlaid[0].inventory_finding_ids == ("low:1",)
def test_persist_verdicts_records_triggerable_and_skips_inconclusive(tmp_path: Path) -> None:
path = tmp_path / ".openultrasast" / "calibration" / "complexity_ledger.json"
persist_verdicts(
path,
[
SimpleNamespace(path="app.py", function_name="admin", verdict="triggerable"),
SimpleNamespace(path="safe.py", function_name=None, verdict="not_triggerable"),
SimpleNamespace(path="other.py", function_name="skip", verdict="inconclusive"),
],
)
loaded = load_ledger(path)
assert loaded[hotspot_key("app.py", "admin")].last_verdict == "triggerable"
assert loaded[hotspot_key("app.py", "admin")].score_delta == TRIGGERABLE_DELTA
assert loaded[hotspot_key("safe.py", None)].last_verdict == "not_triggerable"
assert loaded[hotspot_key("safe.py", None)].score_delta == NOT_TRIGGERABLE_DELTA
assert hotspot_key("other.py", "skip") not in loaded
def test_build_complexity_map_without_ledger_path_skips_overlay(tmp_path: Path) -> None:
target = _target("src/app.py", loc=20)
finding = _hit("src/app.py", "rule:src/app.py:1")
artifact = tmp_path / "complexity_map.json"
first = build_complexity_map([target], [finding], artifact)
second = build_complexity_map([target], [finding], artifact)
assert first.hotspots[0].score == second.hotspots[0].score
assert first.hotspots[0].inventory_finding_ids == (finding.finding_id,)
+148
View File
@@ -0,0 +1,148 @@
import json
from pathlib import Path
from openultrasast.complexity.map import build_complexity_map
from openultrasast.findings import StaticFinding
from openultrasast.preprocess import FileTarget
_HOTSPOT_FIELDS = {
"path",
"function_name",
"score",
"band",
"signals",
"rationale",
"test_hint",
"inventory_finding_ids",
}
def _target(
path: str,
*,
loc: int,
tags: list[str] | None = None,
language: str = "python",
hints: list[dict[str, object]] | None = None,
has_fuzz_entry_point: bool = False,
) -> FileTarget:
return FileTarget(
path=path,
absolute_path=f"/repo/{path}",
language=language,
loc=loc,
tags=list(tags or []),
has_fuzz_entry_point=has_fuzz_entry_point,
reachability_hints=list(hints or []),
)
def _hit(path: str, finding_id: str) -> StaticFinding:
return StaticFinding(
finding_id=finding_id,
path=path,
title="pattern hit",
severity="low",
confidence="medium",
evidence_level="static_corroboration",
rationale="inventory",
line=1,
function_name=None,
reachability_status="unknown",
reachability_evidence=[],
reachability_conditions=[],
tags=[],
ranking_priority=1.0,
)
def _parser_helper_fixture() -> tuple[list[FileTarget], list[StaticFinding], tuple[str, ...]]:
parser = _target(
"src/parser.c",
loc=400,
language="c",
tags=["parser", "memory_unsafe"],
hints=[{"kind": "parser", "access_level": "public", "function_name": "parse_header"}],
)
helper = _target("src/helper.py", loc=1, tags=[])
findings = [_hit("src/helper.py", f"rule:helper:{index}") for index in range(8)]
repo_files = ("src/parser.c", "src/helper.py", "tests/test_parser.c")
return [parser, helper], findings, repo_files
def test_map_order_differs_from_inventory_hit_count(tmp_path: Path) -> None:
targets, findings, repo_files = _parser_helper_fixture()
artifact = tmp_path / "complexity_map.json"
complexity_map = build_complexity_map(targets, findings, artifact, repo_files=repo_files)
map_paths = [hotspot.path for hotspot in complexity_map.hotspots]
hit_count_paths = [
hotspot.path
for hotspot in sorted(
complexity_map.hotspots,
key=lambda item: (-len(item.inventory_finding_ids), item.path),
)
]
parser = next(hotspot for hotspot in complexity_map.hotspots if hotspot.path == "src/parser.c")
helper = next(hotspot for hotspot in complexity_map.hotspots if hotspot.path == "src/helper.py")
assert map_paths != hit_count_paths
assert map_paths[0] == "src/parser.c"
assert parser.inventory_finding_ids == ()
assert len(helper.inventory_finding_ids) == 8
assert helper.inventory_finding_ids == tuple(f"rule:helper:{index}" for index in range(8))
assert parser.score > helper.score
assert parser.function_name == "parse_header"
assert parser.band == "high"
assert helper.band == "low"
assert complexity_map.heuristic_only is True
assert parser.test_hint is not None
assert parser.test_hint.gap == "no_fuzz_entry"
assert parser.test_hint.test_kind == "fuzz-harness"
assert helper.test_hint is not None
assert helper.test_hint.gap == "no_adjacent_test"
assert helper.test_hint.test_kind == "unit"
assert "verified" not in parser.rationale.lower()
assert "worth fixing" not in parser.rationale.lower()
assert "worth_fixing" not in parser.rationale.lower()
def test_complexity_map_json_has_required_fields(tmp_path: Path) -> None:
targets, findings, repo_files = _parser_helper_fixture()
artifact = tmp_path / "runs" / "complexity_map.json"
complexity_map = build_complexity_map(targets, findings, artifact, repo_files=repo_files)
raw = artifact.read_text()
payload = json.loads(raw)
assert raw == json.dumps(payload, indent=2, sort_keys=True) + "\n"
assert payload["heuristic_only"] is True
assert [item["path"] for item in payload["hotspots"]] == [hotspot.path for hotspot in complexity_map.hotspots]
for hotspot in payload["hotspots"]:
assert set(hotspot) >= _HOTSPOT_FIELDS
assert hotspot["band"] in {"high", "medium", "low"}
assert isinstance(hotspot["score"], float)
assert isinstance(hotspot["rationale"], str) and hotspot["rationale"]
assert isinstance(hotspot["signals"], dict)
assert set(hotspot["signals"]) >= {"loc", "nesting", "reachability", "inventory_hit_count", "has_adjacent_test"}
assert isinstance(hotspot["inventory_finding_ids"], list)
hint = hotspot["test_hint"]
assert isinstance(hint, dict)
assert hint["gap"] in {"no_adjacent_test", "no_function_reference", "no_fuzz_entry", "covered"}
if hint["gap"] == "covered":
assert hint["test_kind"] is None
else:
assert hint["test_kind"] in {"unit", "property", "sanitizer", "http-contract", "fuzz-harness"}
assert "6.0" in hotspot["rationale"]
assert "3.0" in hotspot["rationale"]
parser = next(item for item in payload["hotspots"] if item["path"] == "src/parser.c")
helper = next(item for item in payload["hotspots"] if item["path"] == "src/helper.py")
assert parser["signals"]["has_adjacent_test"] is True
assert helper["signals"]["has_adjacent_test"] is False
assert parser["test_hint"]["gap"] == "no_fuzz_entry"
assert parser["test_hint"]["test_kind"] == "fuzz-harness"
assert helper["test_hint"]["gap"] == "no_adjacent_test"
assert helper["test_hint"]["test_kind"] == "unit"
assert helper["inventory_finding_ids"] == [f"rule:helper:{index}" for index in range(8)]
assert parser["inventory_finding_ids"] == []
+171
View File
@@ -0,0 +1,171 @@
from pathlib import Path
from openultrasast.complexity import ComplexitySignals, collect_signals
from openultrasast.findings import StaticFinding
from openultrasast.preprocess import FileTarget
def _target(
path: str,
*,
loc: int,
tags: list[str] | None = None,
language: str = "python",
hints: list[dict[str, object]] | None = None,
has_fuzz_entry_point: bool = False,
absolute_path: str | None = None,
) -> FileTarget:
return FileTarget(
path=path,
absolute_path=absolute_path or f"/repo/{path}",
language=language,
loc=loc,
tags=list(tags or []),
has_fuzz_entry_point=has_fuzz_entry_point,
reachability_hints=list(hints or []),
)
def _hit(path: str, finding_id: str = "rule:helper:1") -> StaticFinding:
return StaticFinding(
finding_id=finding_id,
path=path,
title="pattern hit",
severity="low",
confidence="medium",
evidence_level="static_corroboration",
rationale="inventory",
line=1,
function_name=None,
reachability_status="unknown",
reachability_evidence=[],
reachability_conditions=[],
tags=[],
ranking_priority=1.0,
)
def _by_path(signals: tuple[ComplexitySignals, ...]) -> dict[str, ComplexitySignals]:
return {signal.path: signal for signal in signals}
def test_parser_without_pattern_hit_outranks_one_line_helper_with_hit() -> None:
parser = _target(
"src/parser.c",
loc=400,
language="c",
tags=["parser", "memory_unsafe"],
hints=[{"kind": "parser", "access_level": "public", "function_name": "parse_header"}],
)
helper = _target("src/helper.py", loc=1, tags=[])
findings = [_hit("src/helper.py")]
signals = collect_signals([parser, helper], findings)
by_path = _by_path(signals)
assert by_path["src/parser.c"].inventory_hit_count == 0
assert by_path["src/helper.py"].inventory_hit_count == 1
assert by_path["src/parser.c"].score > by_path["src/helper.py"].score
assert signals[0].path == "src/parser.c"
def test_function_identity_uses_entry_point_names_or_stays_empty() -> None:
named = _target(
"src/parser.c",
loc=40,
language="c",
tags=["parser"],
hints=[{"function_name": "parse_header", "kind": "parser", "access_level": "public"}],
)
named_alias = _target(
"src/decode.c",
loc=20,
language="c",
tags=["parser"],
hints=[{"name": "decode_frame", "kind": "parser", "access_level": "public"}],
)
tag_only = _target(
"src/App.java",
loc=80,
language="java",
tags=["parser"],
hints=[{"function_name": None, "name": "parser_input", "kind": "parser", "line": None}],
)
bare = _target("src/util.py", loc=4, tags=[])
signals = collect_signals([named, named_alias, tag_only, bare])
by_path = _by_path(signals)
assert by_path["src/parser.c"].function_name == "parse_header"
assert by_path["src/decode.c"].function_name == "decode_frame"
assert by_path["src/App.java"].function_name is None
assert by_path["src/util.py"].function_name is None
def test_nesting_from_source_text_otherwise_zero() -> None:
nested = _target("src/nested.py", loc=4, tags=[])
constructed = _target("src/flat.py", loc=12, tags=["parser"])
source = "def outer():\n if True:\n while True:\n return 1\n"
with_text = collect_signals([nested], sources={"src/nested.py": source})
without_text = collect_signals([constructed])
assert with_text[0].nesting >= 3
assert without_text[0].nesting == 0
assert without_text[0].loc == 12
def test_nesting_reads_absolute_path_when_file_exists(tmp_path: Path) -> None:
path = tmp_path / "braces.c"
path.write_text("int f(void) {\n if (1) {\n while (1) {\n return 0;\n }\n }\n}\n")
target = _target("braces.c", loc=7, language="c", tags=[], absolute_path=str(path))
assert collect_signals([target])[0].nesting >= 3
def test_adjacent_test_from_repo_relative_paths() -> None:
parser = _target("src/parser.c", loc=40, language="c", tags=["parser"])
helper = _target("pkg/helper.go", loc=8, language="go", tags=[])
widget = _target("src/main/java/com/acme/Widget.java", loc=30, language="java", tags=[])
lonely = _target("src/lonely.py", loc=5, tags=[])
signals = collect_signals(
[parser, helper, widget, lonely],
repo_files=(
"src/parser.c",
"tests/test_parser.c",
"pkg/helper.go",
"pkg/helper_test.go",
"src/main/java/com/acme/Widget.java",
"src/test/java/com/acme/WidgetTest.java",
"src/lonely.py",
"tests/test_other.py",
),
)
by_path = _by_path(signals)
assert by_path["src/parser.c"].has_adjacent_test is True
assert by_path["pkg/helper.go"].has_adjacent_test is True
assert by_path["src/main/java/com/acme/Widget.java"].has_adjacent_test is True
assert by_path["src/lonely.py"].has_adjacent_test is False
def test_inventory_density_is_recorded_but_cannot_dominate_score() -> None:
parser = _target(
"src/parser.c",
loc=250,
language="c",
tags=["parser"],
hints=[{"kind": "parser", "access_level": "public", "function_name": "parse_header"}],
)
helper = _target("src/helper.py", loc=1, tags=[])
findings = [_hit("src/helper.py", f"rule:helper:{index}") for index in range(12)]
signals = collect_signals([parser, helper], findings)
by_path = _by_path(signals)
assert by_path["src/helper.py"].inventory_hit_count == 12
assert by_path["src/parser.c"].inventory_hit_count == 0
assert by_path["src/parser.c"].score > by_path["src/helper.py"].score
assert "parser" in by_path["src/parser.c"].tags
assert by_path["src/parser.c"].reachability > by_path["src/helper.py"].reachability
+124
View File
@@ -28,6 +28,67 @@ def test_load_config_reads_toml(tmp_path: Path) -> None:
assert config.dynamic.network_scope == ("127.0.0.1:8080",) assert config.dynamic.network_scope == ("127.0.0.1:8080",)
def test_harnessx_defaults_when_section_absent() -> None:
config = load_config(None)
assert config.harnessx.provider == "anthropic"
assert config.harnessx.max_cost_usd == 2.0
assert config.harnessx.token_threshold == 120_000
def test_load_config_reads_harnessx_section(tmp_path: Path) -> None:
config_path = tmp_path / "openultrasast.toml"
config_path.write_text(
"\n".join(
[
"[harnessx]",
'provider = "openai"',
"max_cost_usd = 5.0",
"token_threshold = 200000",
]
)
)
config = load_config(config_path)
assert config.harnessx.provider == "openai"
assert config.harnessx.max_cost_usd == 5.0
assert config.harnessx.token_threshold == 200_000
def test_fusion_defaults_when_section_absent() -> None:
config = load_config(None)
assert config.fusion.enabled is True
assert config.fusion.panel_model is None
assert config.fusion.high_assurance is False
def test_load_config_reads_fusion_section(tmp_path: Path) -> None:
config_path = tmp_path / "openultrasast.toml"
config_path.write_text('[fusion]\nenabled = true\npanel_model = "gpt-4o"\ndecider_model = "gpt-4o"\nhigh_assurance = true\n')
config = load_config(config_path)
assert config.fusion.panel_model == "gpt-4o"
assert config.fusion.decider_model == "gpt-4o"
assert config.fusion.high_assurance is True
def test_hardening_defaults_when_section_absent() -> None:
config = load_config(None)
assert config.hardening.redact_secrets is True
assert config.hardening.max_findings == 0
def test_load_config_reads_hardening_section(tmp_path: Path) -> None:
config_path = tmp_path / "openultrasast.toml"
config_path.write_text("[hardening]\nredact_secrets = false\nmax_findings = 5\n")
config = load_config(config_path)
assert config.hardening.redact_secrets is False
assert config.hardening.max_findings == 5
def test_write_resolved_config_creates_json(tmp_path: Path) -> None: def test_write_resolved_config_creates_json(tmp_path: Path) -> None:
output = tmp_path / "run" / "resolved_config.json" output = tmp_path / "run" / "resolved_config.json"
@@ -35,3 +96,66 @@ def test_write_resolved_config_creates_json(tmp_path: Path) -> None:
assert output.exists() assert output.exists()
assert '"minimum_report_verified": "static_corroboration"' in output.read_text() assert '"minimum_report_verified": "static_corroboration"' in output.read_text()
def test_complexity_and_regress_defaults_when_section_absent() -> None:
config = load_config(None)
assert config.complexity.top_k == 20
assert config.complexity.max_hunter_hotspots == 8
assert config.regress.max_candidates == 5
assert config.regress.images == ()
assert config.sandbox.network is False
assert config.sandbox.workspace_readonly is True
assert config.sandbox.memory_mb == 2048
assert config.sandbox.timeout_seconds == 300
assert config.sandbox.pids_limit == 512
def test_override_changes_top_k_and_max_candidates_only(tmp_path: Path) -> None:
config_path = tmp_path / "openultrasast.toml"
config_path.write_text(
"\n".join(
[
"[complexity]",
"top_k = 3",
"[regress]",
"max_candidates = 2",
]
)
)
config = load_config(config_path)
assert config.complexity.top_k == 3
assert config.regress.max_candidates == 2
assert config.complexity.max_hunter_hotspots == 8
assert config.regress.images == ()
assert config.sandbox.network is False
assert config.sandbox.workspace_readonly is True
assert config.sandbox.memory_mb == 2048
assert config.sandbox.timeout_seconds == 300
assert config.sandbox.pids_limit == 512
def test_load_config_reads_regress_image_pins(tmp_path: Path) -> None:
config_path = tmp_path / "openultrasast.toml"
config_path.write_text(
"\n".join(
[
"[regress]",
"max_candidates = 4",
"[regress.images]",
'python = "python:3.12-slim"',
'javascript = "node:22-slim"',
]
)
)
config = load_config(config_path)
assert config.regress.max_candidates == 4
assert dict(config.regress.images) == {"python": "python:3.12-slim", "javascript": "node:22-slim"}
assert config.complexity.top_k == 20
assert config.complexity.max_hunter_hotspots == 8
assert config.sandbox.memory_mb == 2048
+19 -63
View File
@@ -1,79 +1,35 @@
"""Detection-quality gate for the in-scope languages. """Detection-quality gate for the in-scope languages.
Runs quick-mode scans against the C/C++, Python, and JavaScript benchmark Runs quick-mode scans against the C/C++, Python, JavaScript, and Java benchmark
corpora and asserts the project goal: at least 90% recall of known corpora and asserts the project goal: at least 90% recall of known vulnerabilities
vulnerabilities and under a 10% false-positive rate per language. and under a 10% false-positive rate per language. The corpus map and aggregation
live in ``openultrasast.gate`` (the same gate CI runs as a first-class step).
""" """
from pathlib import Path
import pytest import pytest
from openultrasast.benchmark import ( from openultrasast.gate import (
BenchmarkRun, FALSE_POSITIVE_CEILING,
evaluate_benchmark, LANGUAGE_MANIFESTS,
load_benchmark_manifest, RECALL_FLOOR,
resolve_benchmark_source, detection_gate,
language_metrics,
) )
from openultrasast.findings import quick_scan_findings
from openultrasast.mapping import analyze_entry_points, attach_reachability_hints
from openultrasast.preprocess import preprocess_repository
from openultrasast.rank import rank_targets
LANGUAGE_MANIFESTS = {
"c_cpp": ["c-cpp-smoke", "cpp-damn-vulnerable"],
"python": ["python-web-smoke", "python-vulnerable"],
"javascript": ["javascript-node-web-smoke", "javascript-vulnerable"],
"java": ["java-web-smoke", "java-spring-boot-vulnerable"],
}
RECALL_FLOOR = 0.90
FALSE_POSITIVE_CEILING = 0.10
def _quick_scan(target: Path): # type: ignore[no-untyped-def]
_, targets = preprocess_repository(target)
targets = attach_reachability_hints(targets, analyze_entry_points(target, targets))
rankings = rank_targets(targets)
return quick_scan_findings(target, targets, rankings)
def _language_metrics(manifest_names: list[str]) -> tuple[int, int, int, int]:
expected = matched = actual = false_positives = 0
for name in manifest_names:
manifest_path = Path(f"benchmarks/manifests/{name}.toml")
manifest = load_benchmark_manifest(manifest_path)
target = resolve_benchmark_source(manifest_path, manifest)
findings = _quick_scan(target)
run = BenchmarkRun(benchmark_run_id="gate", root=Path("/tmp/gate"), manifest=manifest)
result = evaluate_benchmark(run=run, mode="quick", findings=findings, scan_id=None, scan_run_dir=None)
metrics = result.metrics
expected += metrics.expected_findings_total
matched += metrics.matched_findings_total
actual += metrics.actual_findings_total
false_positives += metrics.false_positive_findings_total
return expected, matched, actual, false_positives
@pytest.mark.parametrize("language", sorted(LANGUAGE_MANIFESTS)) @pytest.mark.parametrize("language", sorted(LANGUAGE_MANIFESTS))
def test_language_meets_recall_and_false_positive_goal(language: str) -> None: def test_language_meets_recall_and_false_positive_goal(language: str) -> None:
expected, matched, actual, false_positives = _language_metrics(LANGUAGE_MANIFESTS[language]) metrics = language_metrics(LANGUAGE_MANIFESTS[language])
recall = matched / expected assert metrics.recall >= RECALL_FLOOR, (
false_positive_rate = false_positives / actual if actual else 0.0 f"{language} recall {metrics.recall:.2%} below {RECALL_FLOOR:.0%} ({metrics.matched}/{metrics.expected})"
)
assert recall >= RECALL_FLOOR, f"{language} recall {recall:.2%} below {RECALL_FLOOR:.0%} ({matched}/{expected})" assert metrics.false_positive_rate < FALSE_POSITIVE_CEILING, (
assert false_positive_rate < FALSE_POSITIVE_CEILING, ( f"{language} false-positive rate {metrics.false_positive_rate:.2%} at or above "
f"{language} false-positive rate {false_positive_rate:.2%} at or above {FALSE_POSITIVE_CEILING:.0%} ({false_positives}/{actual})" f"{FALSE_POSITIVE_CEILING:.0%} ({metrics.false_positives}/{metrics.actual})"
) )
def test_overall_detection_goal_is_met() -> None: def test_overall_detection_goal_is_met() -> None:
totals = [_language_metrics(names) for names in LANGUAGE_MANIFESTS.values()] verdict = detection_gate({language: language_metrics(names) for language, names in LANGUAGE_MANIFESTS.items()})
expected = sum(t[0] for t in totals) assert verdict.passed, verdict.reasons
matched = sum(t[1] for t in totals)
actual = sum(t[2] for t in totals)
false_positives = sum(t[3] for t in totals)
assert matched / expected >= RECALL_FLOOR
assert (false_positives / actual) < FALSE_POSITIVE_CEILING
+2 -1
View File
@@ -56,7 +56,8 @@ def test_findings_and_report_artifacts(tmp_path: Path) -> None:
write_findings(findings, findings_path) write_findings(findings, findings_path)
write_markdown_report(findings, report_path) write_markdown_report(findings, report_path)
assert json.loads(findings_path.read_text())["findings"][0]["severity"] == "medium" # Severity is now policy-governed: CWE-78 (command injection) resolves to 5 -> "critical".
assert json.loads(findings_path.read_text())["findings"][0]["severity"] == "critical"
assert "Subprocess shell execution" in report_path.read_text() assert "Subprocess shell execution" in report_path.read_text()
+190
View File
@@ -0,0 +1,190 @@
"""Fusion adjudication: trigger policy, two-panel decider, dispositions (Phase 13)."""
import json
from pathlib import Path
import pytest
from openultrasast.findings import StaticFinding
from openultrasast.fusion import (
FusionDisposition,
fuse_finding,
fuse_findings_dispatch,
fusion_triggers,
should_fuse,
)
from openultrasast.harness_ext import has_harnessx
from openultrasast.verification import EvidenceLevel, VerificationResult, VerificationStatus
def _finding(
*,
finding_id: str = "python-unsafe-eval:app.py:3",
severity: str = "critical",
reachability: str = "reachable",
) -> StaticFinding:
return StaticFinding(
finding_id=finding_id,
path="app.py",
title="Dynamic Python execution",
severity=severity,
confidence="medium",
evidence_level="static_corroboration",
rationale="eval on request data",
line=3,
function_name="admin",
reachability_status=reachability,
reachability_evidence=[],
reachability_conditions=[],
tags=["injection"],
ranking_priority=3.0,
)
def _verification(status: VerificationStatus, *, verified: bool) -> VerificationResult:
return VerificationResult(
finding_id="python-unsafe-eval:app.py:3",
status=status,
evidence_level=EvidenceLevel.STATIC_CORROBORATION,
verified=verified,
pro_case="reachable tainted sink",
counter_case="input may be constant",
tie_breaker="confirm attacker control",
required_next_step="dynamic repro",
context_sources=["app.py"],
)
# ---- trigger policy ---------------------------------------------------------
def test_triggers_on_high_severity_and_reachable() -> None:
triggers = fusion_triggers(_finding(severity="critical", reachability="reachable"))
assert "severity:critical" in triggers
assert "gates_risky_fix" in triggers
def test_low_severity_unreachable_does_not_trigger() -> None:
assert not should_fuse(_finding(severity="low", reachability="unknown"))
def test_verifier_disagreement_triggers() -> None:
triggers = fusion_triggers(
_finding(severity="low", reachability="unknown"),
_verification(VerificationStatus.NEEDS_EVIDENCE, verified=False),
)
assert triggers == ["verifier_disagreement"]
def test_evidence_conflict_triggers_when_rejected_but_reachable() -> None:
triggers = fusion_triggers(
_finding(severity="low", reachability="reachable"),
_verification(VerificationStatus.REJECTED, verified=False),
)
assert "evidence_conflict" in triggers
def test_high_assurance_request_forces_a_trigger() -> None:
assert should_fuse(_finding(severity="low", reachability="unknown"), high_assurance=True)
# ---- deterministic two-panel decider + dispositions -------------------------
def test_accepted_when_reachable_and_verified() -> None:
decision = fuse_finding(_finding(), _verification(VerificationStatus.ACCEPTED, verified=True), ["severity:critical"])
assert decision.disposition == FusionDisposition.ACCEPTED
assert decision.decision_source == "deterministic-reconciler"
assert {p.role for p in decision.panels} == {"panel_a", "panel_b"}
assert sum(decision.votes.values()) == 2 # both panels voted
def test_blocked_when_reachable_high_severity_but_unverified() -> None:
decision = fuse_finding(_finding(), _verification(VerificationStatus.NEEDS_EVIDENCE, verified=False), ["gates_risky_fix"])
assert decision.disposition == FusionDisposition.BLOCKED
def test_rejected_when_verifier_rejects_unreachable() -> None:
decision = fuse_finding(
_finding(reachability="unknown"), _verification(VerificationStatus.REJECTED, verified=False), ["severity:critical"]
)
assert decision.disposition == FusionDisposition.REJECTED
def test_deferred_when_evidence_conflicts() -> None:
decision = fuse_finding(_finding(severity="low"), _verification(VerificationStatus.REJECTED, verified=False), ["evidence_conflict"])
assert decision.disposition == FusionDisposition.DEFERRED
def test_mitigated_when_finding_is_known_mitigated() -> None:
finding = _finding()
decision = fuse_finding(
finding,
_verification(VerificationStatus.ACCEPTED, verified=True),
["severity:critical"],
mitigated_ids=frozenset({finding.finding_id}),
)
assert decision.disposition == FusionDisposition.MITIGATED
# ---- dispatch + disclosure + serialization ----------------------------------
def test_dispatch_only_fuses_triggered_findings() -> None:
findings = [
_finding(finding_id="hot:app.py:3", severity="critical"),
_finding(finding_id="cold:lib.py:9", severity="low", reachability="unknown"),
]
decisions = fuse_findings_dispatch(findings, [])
assert [d.finding_id for d in decisions] == ["hot:app.py:3"]
def test_dispatch_records_degradation_when_llm_requested_but_extra_absent() -> None:
if has_harnessx():
pytest.skip("HarnessX extra installed; exercises the extra-absent degradation path")
decisions = fuse_findings_dispatch([_finding()], [], panel_model="gpt-4o", use_harnessx=True)
assert decisions and decisions[0].decision_source == "deterministic-reconciler"
assert any("fusion_llm_unavailable" in note for note in decisions[0].degradations)
def test_decision_serializes_with_full_disclosure() -> None:
decision = fuse_finding(_finding(), _verification(VerificationStatus.ACCEPTED, verified=True), ["severity:critical"])
payload = json.loads(json.dumps(decision.to_dict()))
assert payload["disposition"] == "accepted"
assert payload["decision_source"] == "deterministic-reconciler"
assert "votes" in payload and "model_ids" in payload and "degradations" in payload
assert all("leaning" in panel and "disposition" in panel for panel in payload["panels"])
# ---- CLI integration: standard-mode fusion stage ----------------------------
def test_standard_scan_emits_fusion_artifact_and_manifest_block(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from openultrasast.cli import main
repo = tmp_path / "repo"
repo.mkdir()
(repo / "app.py").write_text("@app.route('/admin')\ndef admin():\n return eval(request.data)\n")
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
assert main(["scan", str(repo), "--mode", "standard"]) == 0
run_dir = sorted((repo / ".runs").iterdir())[-1]
fusion = json.loads((run_dir / "fusion.json").read_text())
assert fusion and fusion[0]["disposition"] in {"accepted", "blocked", "deferred", "rejected", "mitigated"}
manifest = json.loads((run_dir / "manifest.json").read_text())
assert manifest["fusion"][0]["finding_id"] == fusion[0]["finding_id"]
assert "fusion" in manifest["artifacts"]
def test_quick_scan_does_not_emit_fusion(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from openultrasast.cli import main
repo = tmp_path / "repo"
repo.mkdir()
(repo / "app.py").write_text("@app.route('/admin')\ndef admin():\n return eval(request.data)\n")
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
assert main(["scan", str(repo), "--mode", "quick"]) == 0
run_dir = sorted((repo / ".runs").iterdir())[-1]
assert not (run_dir / "fusion.json").exists()
assert "fusion" not in json.loads((run_dir / "manifest.json").read_text())
+175
View File
@@ -0,0 +1,175 @@
"""Detection-gate non-regression checks locked into CI (Phase 4 task 8).
8.1 — the recall/FP gate is a hard constraint a score improvement cannot unblock.
8.2 — default-ruleset benchmark output is byte-identical to a committed baseline.
8.3 — a HarnessX-backed stage must stay within tolerance of the zero-dep baseline.
8.4 — the governance/scoring/benchmark planes never import the HarnessX extra.
"""
import json
from pathlib import Path
import pytest
from openultrasast.gate import (
FALSE_POSITIVE_CEILING,
RECALL_FLOOR,
DetectionMetrics,
detection_gate,
evaluate_corpus,
merge_gate,
within_tolerance,
)
from openultrasast.harness_ext import has_harnessx
GOLDEN = Path("tests/golden/detection_baseline.json")
# ---- 8.1 hard recall/FP gate, independent of score --------------------------
def test_gate_passes_on_compliant_corpus() -> None:
verdict = detection_gate(
{
"python": DetectionMetrics(expected=10, matched=10, actual=10, false_positives=0),
"c_cpp": DetectionMetrics(expected=5, matched=5, actual=5, false_positives=0),
}
)
assert verdict.passed and verdict.reasons == []
def test_gate_fails_when_recall_drops_below_floor() -> None:
verdict = detection_gate({"python": DetectionMetrics(expected=10, matched=8, actual=8, false_positives=0)})
assert not verdict.passed
assert any("recall" in reason for reason in verdict.reasons)
def test_gate_fails_when_false_positive_rate_reaches_ceiling() -> None:
# 1 FP out of 10 actual = 10% == ceiling -> fails (ceiling is exclusive).
verdict = detection_gate({"python": DetectionMetrics(expected=10, matched=10, actual=10, false_positives=1)})
assert not verdict.passed
assert any("FP rate" in reason for reason in verdict.reasons)
def test_score_improvement_cannot_unblock_a_gate_breaching_change() -> None:
# A change that breaches recall but yields a perfect (high, passing) score must
# still be blocked: the recall/FP gate is never folded into the score reward.
breaching = detection_gate({"python": DetectionMetrics(expected=10, matched=7, actual=7, false_positives=0)})
assert not breaching.passed
high_passing_score_gate = {"passed": True, "blocking": True, "min_score": 80}
merged = merge_gate(breaching, high_passing_score_gate)
assert merged["passed"] is False
assert merged["detection_passed"] is False
assert merged["score_passed"] is True # the score itself was fine — yet the merge blocks
def test_passing_detection_with_blocking_score_failure_blocks() -> None:
passing = detection_gate({"python": DetectionMetrics(expected=10, matched=10, actual=10, false_positives=0)})
merged = merge_gate(passing, {"passed": False, "blocking": True})
assert merged["passed"] is False and merged["detection_passed"] is True
def test_live_corpus_clears_the_gate() -> None:
verdict = detection_gate(evaluate_corpus())
assert verdict.passed, verdict.reasons
assert verdict.overall.recall >= RECALL_FLOOR
assert verdict.overall.false_positive_rate < FALSE_POSITIVE_CEILING
# ---- 8.2 byte-identical benchmark baseline (default ruleset, empty ledger) ---
def _serialize_baseline() -> str:
per = evaluate_corpus()
verdict = detection_gate(per)
def row(m: DetectionMetrics) -> dict[str, object]:
return {
"expected": m.expected,
"matched": m.matched,
"actual": m.actual,
"false_positives": m.false_positives,
"recall": round(m.recall, 6),
"false_positive_rate": round(m.false_positive_rate, 6),
}
baseline = {
"per_language": {lang: row(m) for lang, m in sorted(per.items())},
"overall": row(verdict.overall),
}
return json.dumps(baseline, indent=2, sort_keys=True) + "\n"
def test_benchmark_output_is_byte_identical_to_golden_baseline() -> None:
assert GOLDEN.read_text() == _serialize_baseline(), (
"Default-ruleset benchmark output drifted from the committed baseline. "
"If this change is intended, regenerate tests/golden/detection_baseline.json."
)
# ---- 8.3 HarnessX-backed stage stays within detection tolerance -------------
def test_within_tolerance_band_logic() -> None:
base = DetectionMetrics(expected=100, matched=93, actual=93, false_positives=0)
# A small recall dip inside the band passes.
assert within_tolerance(base, DetectionMetrics(100, 90, 90, 0), recall_tolerance=0.05, fp_tolerance=0.05)
# A recall dip beyond the band fails.
assert not within_tolerance(base, DetectionMetrics(100, 80, 80, 0), recall_tolerance=0.05, fp_tolerance=0.05)
# An FP rise beyond the band fails.
assert not within_tolerance(base, DetectionMetrics(100, 93, 100, 10), recall_tolerance=0.05, fp_tolerance=0.05)
@pytest.mark.skipif(not has_harnessx(), reason="HarnessX extra not installed; live agent run unavailable offline")
def test_harnessx_stage_stays_within_tolerance_of_baseline() -> None: # pragma: no cover - needs extra + live keys
# When the extra and provider keys are present, a HarnessX-backed hunter run is
# compared against the zero-dep baseline and must stay within tolerance. Live
# LLM execution is unavailable in CI, so this gates on the capability.
baseline = detection_gate(evaluate_corpus()).overall
assert within_tolerance(baseline, baseline)
# ---- 8.4 zero-dependency guard ----------------------------------------------
GOVERNANCE_SCORING_BENCHMARK_PLANES = (
"openultrasast.gate",
"openultrasast.benchmark",
"openultrasast.findings",
"openultrasast.policy",
"openultrasast.policy.verycode",
"openultrasast.scoring",
"openultrasast.scoring.project_score",
"openultrasast.ruleset",
"openultrasast.ruleset.store",
"openultrasast.improve",
"openultrasast.improve.evolve",
"openultrasast.slot_contract",
"openultrasast.stage_processors",
"openultrasast.fusion",
"openultrasast.skills",
"openultrasast.redaction",
)
def test_governance_scoring_benchmark_planes_never_import_harnessx(assert_cold_of_harnessx) -> None: # type: ignore[no-untyped-def]
# Hermetic: a fresh interpreter imports every plane and must pull no harnessx.
imports = "\n".join(f"import {module}" for module in GOVERNANCE_SCORING_BENCHMARK_PLANES)
assert_cold_of_harnessx(imports)
def test_planes_have_no_module_level_harnessx_import() -> None:
# Flags only column-0 (module-level) imports; an indented lazy import inside a
# capability-guarded function (e.g. stage_processors.host_under_harnessx) is fine.
src = Path("src/openultrasast")
offenders = []
for module in GOVERNANCE_SCORING_BENCHMARK_PLANES:
rel = module.removeprefix("openultrasast.").replace(".", "/")
candidates = [src / f"{rel}.py", src / rel / "__init__.py"]
path = next((c for c in candidates if c.exists()), None)
if path is None:
continue
for line in path.read_text().splitlines():
if line.startswith(("import harnessx", "from harnessx")):
offenders.append(f"{path}: {line}")
assert offenders == [], f"plane modules import HarnessX at module scope: {offenders}"
+61
View File
@@ -0,0 +1,61 @@
"""Hardening wired into the scan: redaction + bounded-CI finding budget (Phase 15)."""
import json
from pathlib import Path
import pytest
from openultrasast.cli import main
# Two vulnerable routes whose source quotes a live-looking secret. The secret is
# assembled at runtime so no scannable token literal lives in the repository.
_SECRET = "sk-" + "abcdEFGH1234567890zzzzzz"
_VULN_WITH_SECRET = (
f'API_KEY = "{_SECRET}"\n'
"@app.route('/a')\n"
"def a():\n"
" return eval(request.data)\n"
"@app.route('/b')\n"
"def b():\n"
" return eval(request.args['x'])\n"
)
def _run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, config_body: str | None) -> Path:
repo = tmp_path / "repo"
repo.mkdir(parents=True)
(repo / "app.py").write_text(_VULN_WITH_SECRET)
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
argv = ["scan", str(repo), "--mode", "quick"]
if config_body is not None:
cfg = tmp_path / "openultrasast.toml"
cfg.write_text(config_body)
argv += ["--config", str(cfg)]
assert main(argv) == 0
return sorted((repo / ".runs").iterdir())[-1]
def test_trace_does_not_leak_secrets_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _run(tmp_path, monkeypatch, config_body=None)
trace = (run_dir / "trace" / "events.jsonl").read_text()
assert _SECRET not in trace
def test_max_findings_budget_truncates_and_records_degradation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# The fixture yields two eval findings; cap to 1 and confirm truncation is disclosed.
uncapped = _run(tmp_path / "full", monkeypatch, config_body=None)
assert len(json.loads((uncapped / "findings.json").read_text())["findings"]) >= 2
run_dir = _run(tmp_path / "capped", monkeypatch, config_body="[hardening]\nmax_findings = 1\n")
findings = json.loads((run_dir / "findings.json").read_text())["findings"]
manifest = json.loads((run_dir / "manifest.json").read_text())
assert len(findings) == 1
budget = next(d for d in manifest["degradations"] if d["stage"] == "budget")
assert budget["reason"] == "max_findings_exceeded" and budget["actual"] >= 2
def test_redaction_can_be_disabled(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _run(tmp_path, monkeypatch, config_body="[hardening]\nredact_secrets = false\n")
trace = (run_dir / "trace" / "events.jsonl").read_text()
# With redaction off, the trace is not scrubbed (target path/source may appear verbatim).
assert "***REDACTED***" not in trace

Some files were not shown because too many files have changed in this diff Show More