feat(plane): honest coverage in alerts; PHP alerts from the engine on the host

Quick mode has rules only for c/cpp, groovy, java, javascript/typescript and
python, so every PHP case's "0 alerts" read as clean. The `alerts` task now
records per language of the files read whether quick mode covers it
(derived from the bundled ruleset, not a list): coverage quick | none, with
file counts per pin, and `uncovered`. `remember` turns that into `coverage`
memory rows (a new kind; the Run passes the alerts summary.json as input),
loop-measure reports coverage per language, and improve/memory.py's M1/M2
skip a repository+pin whose coverage for the rule's language is only
`none` (counted under `coverage_none` in the proposal's excluded).

`ousast plane alerts-engine <Run.yaml> --source <frozen export>`
(plane/engine_alerts.py) does the alerts task on the host for cases whose
files include a language the engine covers but quick mode does not (PHP):
exports both pins from the case cache, runs the quick scan and the
openultrasast:dev engine container (finding_dump.py, --network none,
--memory 3g, one at a time, per-pin deadline), writes engine findings in
the alerts.jsonl row shape (rule_id engine:<family>, source engine,
in_fix_range from case.json) with a summary (files read, questions,
completed, seconds, degradations per pin), and marks the task done via
reconciler.mark_done. No result, no files read, or questions == 0 fails
loudly and leaves the run untouched. Refuses a git checkout as source.

Runner image rebuilt and re-pinned (sha256:ebb40a9a...); validation-46
regenerated (digest only). Live, recorded in
benchmarks/measurements/2026-09-30-php-engine-alerts/: froxlor 366 files,
130/130 questions per pin, 6 alerts per pin, 0 in the fix range; wp-gopay
19 files, 101/101 questions, 0 alerts. The loop: 32 run rows (coverage 8,
php=engine), 0 proposals, gate no_proposals, $0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-30 13:44:32 +02:00
co-authored by Claude Opus 5.5
parent f4bd984fda
commit 45d02ecf20
35 changed files with 1949 additions and 142 deletions
+32
View File
@@ -274,6 +274,18 @@ def _main(argv: list[str] | None) -> int:
plane_remember.add_argument("--plane", type=Path, default=Path("plane"), help="where runs/<run>.yaml names population and split")
plane_remember.add_argument("--population", help="the population the run measured (default: the Run's annotation)")
plane_remember.add_argument("--split", help="the split of that population (default: the Run's annotation)")
plane_engine = plane_sub.add_parser(
"alerts-engine", help="a Run's `alerts` for languages quick mode does not cover (PHP), from the engine image on this host"
)
plane_engine.add_argument("run_manifest", type=Path)
plane_engine.add_argument(
"--source", type=Path, required=True, help="a frozen export: git archive HEAD src benchmarks/push/finding_dump.py"
)
plane_engine.add_argument("--cases", default="", help="comma-separated case ids (default: every alerts case of the Run)")
plane_engine.add_argument("--deadline", type=float, default=1800.0, help="the engine's scan deadline per pin, seconds")
plane_engine.add_argument("--image", default="openultrasast:dev")
plane_engine.add_argument("--repos", type=Path, help="the case cache (default ~/.cache/openultrasast/independent)")
plane_engine.add_argument("--work", type=Path, help="where pins are exported and results land (default: a temporary directory)")
plane_ws = plane_sub.add_parser("workspaces", help="one Workspace manifest per case pin of a population file")
plane_ws.add_argument("population", type=Path)
plane_ws.add_argument("--out", type=Path, default=Path("plane/workspaces"))
@@ -1318,6 +1330,8 @@ def _plane(args: argparse.Namespace) -> int:
if args.plane_command == "status":
print(reconciler.status(args.run, units=args.units))
return 0
if args.plane_command == "alerts-engine":
return _plane_alerts_engine(args)
if args.plane_command == "doctor":
checks = reconciler.doctor()
for name, ok, text in checks:
@@ -1332,6 +1346,24 @@ def _plane(args: argparse.Namespace) -> int:
return 0
def _plane_alerts_engine(args: argparse.Namespace) -> int:
from .plane import engine_alerts
only = [c.strip() for c in args.cases.split(",") if c.strip()]
try:
results = engine_alerts.alerts_engine(
args.run_manifest, source=args.source, only=only, work=args.work, cache=args.repos or engine_alerts.CACHE,
deadline=args.deadline, image=args.image,
) # fmt: skip
except (engine_alerts.EngineAlertsError, OSError, ValueError, subprocess.CalledProcessError) as exc:
print(f"alerts-engine failed: {exc}", file=sys.stderr)
return 2
keys = ("case", "status", "reason", "alerts", "alerts_engine", "in_fix_range", "engine", "uncovered", "seconds")
for result in results:
print(json.dumps({k: result[k] for k in keys if k in result}, sort_keys=True))
return 0
def _plane_memory(
step: str, run_manifest: Path | None, *, run: str | None = None, population: str | None = None, split: str | None = None
) -> int:
+32 -2
View File
@@ -19,6 +19,11 @@ final counts as no evidence in either direction.
- **M3, disputed families**: families with at least :data:`M3_MIN_CANDIDATES` candidates and a dispute rate of at
least :data:`M3_MIN_DISPUTE_RATE`. Advisory (``signals.json``); never an edit, never shown to the validator.
**Coverage**: M1 and M2 skip every repository and pin whose ``coverage`` rows record the rule's language only as
``none`` (quick mode had no rule for it and no engine alerts were produced): a rule cannot be demoted or promoted
on evidence that could not exist. The skipped alerts are counted per ``repo@pin`` under ``coverage_none`` in the
proposal's ``excluded``.
**Train-on-test guard** (:class:`Guard`), applied before any rule sees a row: a row is dropped when its population
is one the proposal will be qualified on, its (repo, pin) is a case of the gated benchmark manifest, or its
repository is a holdout pair's repository. The pair catalog records a holdout pair's *fix* commit, not the
@@ -142,6 +147,7 @@ class _View:
verdicts: dict[tuple[str, str, str], Record] = field(default_factory=dict) # (repo, pin, candidate)
sites: set[tuple[str, str]] = field(default_factory=set) # (repo, candidate) with site_match true
alerts: dict[str, list[Record]] = field(default_factory=dict) # rule_id -> alerts
coverage: dict[tuple[str, str, str], set[str]] = field(default_factory=dict) # (repo, pin, language) -> recorded kinds
@classmethod
def of(cls, records: Sequence[Record]) -> _View:
@@ -151,7 +157,9 @@ class _View:
seen: set[tuple[object, ...]] = set()
for rec in records:
row = rec.row
if row["kind"] == "verdict":
if row["kind"] == "coverage" and row.get("language"):
view.coverage.setdefault((row["repo"], row["pin"], str(row["language"])), set()).add(str(row.get("coverage")))
elif row["kind"] == "verdict":
view.verdicts[(row["repo"], row["pin"], str(row.get("candidate")))] = rec
if row.get("site_match") is True:
view.sites.add((row["repo"], str(row.get("candidate"))))
@@ -171,6 +179,26 @@ class _View:
flag = alert.get("in_fix_range")
return bool(flag) if flag is not None else (alert["repo"], _cand(alert)) in self.sites
def blind(self, repo: str, pin: str, languages: Iterable[str]) -> bool:
"""True when a language of the rule was recorded at this repository and pin with coverage ``none`` only: no
rule could fire there, so what the rule did or did not say there is no evidence either way."""
return any(self.coverage.get((repo, pin, language)) == {"none"} for language in languages)
def seeing(self, languages_by_rule: Mapping[str, Sequence[str]]) -> tuple[_View, dict[str, int]]:
"""This view without the alerts at a repository and pin blind to their rule's language, and how many were
dropped per ``repo@pin``."""
kept: dict[str, list[Record]] = {}
dropped: dict[str, int] = {}
for rule_id, alerts in self.alerts.items():
languages = languages_by_rule.get(rule_id, ())
for alert in alerts:
if self.blind(alert.row["repo"], alert.row["pin"], languages):
label = f"{alert.row['repo']}@{alert.row['pin']}"
dropped[label] = dropped.get(label, 0) + 1
else:
kept.setdefault(rule_id, []).append(alert)
return _View(self.verdicts, self.sites, kept, self.coverage), dict(sorted(dropped.items()))
def _m1(rule_id: str, view: _View) -> tuple[list[Record], dict[str, int]] | None:
evidence: list[Record] = []
@@ -262,7 +290,9 @@ def propose_from_memory(
) -> list[MemoryProposal]:
"""M1 and M2 over the rows the guard keeps, minus proposals already reverted on the same evidence."""
kept, dropped = excluded.apply(rows)
view = _View.of(kept)
view, blind = _View.of(kept).seeing({rid: rule.languages for rid, rule in ruleset_by_id.items()})
if blind:
dropped = {**dropped, "coverage_none": blind}
statuses = {rid: _status(rid, ruleset_by_id, current_ledger) for rid in ruleset_by_id}
enabled = {rid for rid, status in statuses.items() if status == "enabled"}
proposals: list[MemoryProposal] = []
+1
View File
@@ -66,6 +66,7 @@ SPEC_OWNED = {
"plane.memory": "harnessx-removal Req 6.1/6.4: the memory store (file, MinIO), ingest and fact reuse; `ousast plane remember`",
"plane.tasks.remember": "harnessx-removal Req 6.1: a case's delivered artifacts as memory rows, model-free",
"plane.tasks.alerts": "harnessx-removal Req 6.3: quick-mode alerts on a case's vulnerable and fixed pins, model-free",
"plane.engine_alerts": "harnessx-removal Req 6.3: engine alerts where quick mode has no rules (PHP), on the host",
"plane.tasks.loop": "harnessx-removal Req 6.3: the improvement loop's snapshot, measure, propose and improve steps",
"plane.workspaces": "ai-service-plane task 4: Workspace manifests per case pin of a population; `ousast plane workspaces`",
"plane.runner": "ai-service-plane Req 4.1-4.4: the ax-task-runner entrypoint (PID 1 of the task image); delivers the output directory",
+257
View File
@@ -0,0 +1,257 @@
"""Engine alerts for the languages quick mode does not cover, produced on the host (harnessx-removal Req 6.3).
The loop's ``alerts`` task runs the quick-mode scan, which has rules only for the languages its ruleset declares; a
PHP case yields zero alerts that mean nothing (``coverage: none``). PHP detection comes from the Joern taint engine
(``ruleset/semantic/php.toml``), which runs in the ``openultrasast:dev`` image, not in the runner sandbox. So
``ousast plane alerts-engine <Run.yaml>`` does the ``alerts`` task's work on the host for every case whose files
include a language quick mode does not cover but the engine does:
- export the case's vulnerable and fixed pins from the case cache (``git archive``, as
``benchmarks/independent/evaluate.py`` does), run the quick scan on each (:func:`.tasks.alerts.scan`) and the
engine container on each -- ``benchmarks/push/finding_dump.py`` from a FROZEN source export (never a live tree),
``--network none --memory 3g``, the case's family, one container at a time, a per-case deadline;
- convert the engine's findings into the ``alerts.jsonl`` row shape of the ``alerts`` task (``rule_id`` is
``engine:<family>``, ``source: "engine"``, ``in_fix_range`` from ``case.json``'s ranges), write both kinds of rows
and a ``summary.json`` (``coverage`` with the engine's languages as ``engine``, the engine's files read, questions,
completed, seconds and degradations per pin) into ``<run dir>/<case>-alerts/``;
- mark the task done with :func:`.reconciler.mark_done`, the mechanism fact reuse uses, so the Run skips it.
The instrument must have read its input: no result file, no ``read N files`` line, or ``questions == 0`` fails the
case loudly (:class:`EngineAlertsError`) and leaves the run directory untouched.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import tempfile
import time
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from . import reconciler
from .tasks.alerts import ROLES, coverage, covers, engine_languages, function_at, in_range, quick_languages, scan, uncovered
from .tasks.alerts import write_rows as write_alert_rows
from .tasks.alerts import write_summary as write_alert_summary
IMAGE = "openultrasast:dev"
MEMORY = "3g"
DEADLINE = 1800.0
GRACE = 600.0 # the container's own startup and teardown beyond the scan deadline
CACHE = Path.home() / ".cache" / "openultrasast" / "independent"
DUMP = Path("benchmarks") / "push" / "finding_dump.py"
_READ = re.compile(r"^read (\d+) files, (\d+) bytes", re.MULTILINE)
_SITE = re.compile(r"^(.+?):(\d+):(.*)$")
Runner = Callable[[Sequence[str], float], subprocess.CompletedProcess[str]]
class EngineAlertsError(RuntimeError):
"""The engine did not demonstrably read its input, or a case cannot be prepared."""
def run_command(command: Sequence[str], timeout: float) -> subprocess.CompletedProcess[str]:
return subprocess.run(list(command), capture_output=True, text=True, timeout=timeout, check=False)
@dataclass(frozen=True)
class EngineCase:
case_id: str
entry: str # the Run entry of the case's `alerts` task
repo: str
pins: Mapping[str, str] # role -> commit
family: str
ranges: Mapping[str, Any] # role -> {path: [[lo, hi], ...]} or None
def engine_cases(run_manifest: Path) -> tuple[str, list[EngineCase]]:
"""(Run name, every `alerts` entry of the Run as a case): pins from the task's env, family and fix ranges from the
`case.json` of its bound inputs Workspace, the repository from its bound git Workspace."""
run, manifests = reconciler.load_run(Path(run_manifest))
cases: list[EngineCase] = []
for entry in run.tasks:
task = manifests.tasks[entry.task]
if tuple(task.command[:1]) != ("alerts",):
continue
env = {e.name: e.value for e in task.env}
bound = [manifests.workspaces[b.name] for b in task.workspaces if b.name in manifests.workspaces]
files = {f.path: f.content for ws in bound for f in ws.files}
repos = [g.repo for ws in bound for g in ws.git]
if "case.json" not in files or not repos:
raise EngineAlertsError(f"{entry.name}: no bound case.json or git Workspace")
case = json.loads(files["case.json"])
pins = {"vulnerable": env.get("OUSAST_VULNERABLE_PIN", ""), "fixed": env.get("OUSAST_FIXED_PIN", "")}
if not all(pins.values()) or not case.get("family"):
raise EngineAlertsError(f"{entry.name}: pins {pins} or family {case.get('family')!r} missing")
case_id = str(case.get("id") or entry.name.removesuffix("-alerts"))
ranges = {"vulnerable": case.get("ranges"), "fixed": case.get("fixed_ranges")}
cases.append(EngineCase(case_id, entry.name, repos[0], pins, str(case["family"]), ranges))
return run.metadata.name, cases
def frozen_source(source: Path) -> Path:
"""The analyzer tree the engine container mounts: an export (``git archive HEAD src benchmarks/push/finding_dump.py``),
never a live checkout, whose files could change under a long run."""
source = Path(source).resolve()
if (source / ".git").exists():
raise EngineAlertsError(f"{source} is a git checkout; export it first (git archive HEAD src {DUMP.as_posix()})")
if not (source / "src" / "openultrasast").is_dir() or not (source / DUMP).is_file():
raise EngineAlertsError(f"{source} holds no src/openultrasast and {DUMP.as_posix()}")
return source
def export_pin(cache_repo: Path, pin: str, target: Path) -> Path:
"""``git archive <pin>`` of the case cache into ``target`` (emptied first)."""
if target.exists():
shutil.rmtree(target)
target.mkdir(parents=True)
archive = target.parent / f"{target.name}.tar"
with archive.open("wb") as stream:
subprocess.run(["git", "-C", str(cache_repo), "archive", pin], stdout=stream, check=True)
subprocess.run(["tar", "-xf", str(archive), "-C", str(target)], check=True)
archive.unlink()
if not any(target.rglob("*")):
raise EngineAlertsError(f"{cache_repo} @ {pin}: the export is empty")
return target
def docker_command(source: Path, checkout: Path, out_dir: Path, name: str, family: str, deadline: float, image: str) -> list[str]:
return [
"docker", "run", "--rm", "--init", "--network", "none", "--memory", MEMORY, "--entrypoint", "python",
"-e", "OUSAST_LOG_LEVEL=WARNING", "-e", "PYTHONPATH=/new/src",
"-v", f"{source / 'src'}:/new/src:ro", "-v", f"{source / DUMP}:/dump.py:ro",
"-v", f"{checkout}:/case:ro", "-v", f"{out_dir}:/out",
image, "/dump.py", "--root", "/case", "--families", family,
"--out", f"/out/{name}.json", "--deadline", str(int(deadline)), "--max-regions", "5000",
] # fmt: skip
def read_record(out: Path, log: str, returncode: int) -> dict[str, Any]:
"""The engine's result, with the files and bytes it said it read; fails unless it read input and asked questions."""
tail = log.strip()[-600:]
if not out.is_file():
raise EngineAlertsError(f"the engine wrote no result {out.name} (exit {returncode}): {tail}")
record = json.loads(out.read_text(encoding="utf-8"))
read = _READ.search(log)
if read is None or int(read.group(1)) == 0:
raise EngineAlertsError(f"{out.name}: the engine did not report reading any file (exit {returncode}): {tail}")
if not record.get("questions"):
raise EngineAlertsError(f"{out.name}: no question was asked (exit {returncode}): the engine measured nothing")
return {**record, "files": int(read.group(1)), "bytes": int(read.group(2)), "exit": returncode}
def parse_site(site: str) -> tuple[str, int | None, str | None]:
match = _SITE.match(site)
if match is None:
return site, None, None
return match.group(1), int(match.group(2)), match.group(3) or None
def engine_rows(record: Mapping[str, Any], root: Path, role: str, pin: str, ranges: Mapping[str, Any] | None) -> list[dict[str, Any]]:
"""The engine's findings as ``alerts.jsonl`` rows (the ``alerts`` task's shape plus ``source: "engine"``), one per
rule, file, line; ``function`` by the ``repo-facts`` patterns when ``root`` holds the file, else the engine's."""
cache: dict[str, list[str]] = {}
rows: dict[tuple[str, str, int | None], dict[str, Any]] = {}
for finding in record.get("findings") or []:
path, line, function = parse_site(str(finding["site"]))
rule_id = f"engine:{finding['family']}"
row = {
"rule_id": rule_id, "rule_status": "enabled", "path": path, "line": line,
"function": function_at(root, path, line, function, cache), "pin_role": role, "pin": pin,
"in_fix_range": in_range(ranges, path, line), "source": "engine",
} # fmt: skip
rows.setdefault((rule_id, path, line), row)
return sorted(rows.values(), key=lambda r: (r["path"], r["line"] or 0, r["rule_id"]))
def _image_id(image: str, runner: Runner) -> str:
done = runner(["docker", "image", "inspect", "--format", "{{.Id}}", image], 60.0)
return done.stdout.strip() if done.returncode == 0 and done.stdout.strip() else image
def produce(
case: EngineCase, run_name: str, source: Path, work: Path, *, cache: Path = CACHE, deadline: float = DEADLINE,
image: str = IMAGE, runner: Runner | None = None, results_root: Path | None = None,
) -> dict[str, Any]: # fmt: skip
"""Quick and engine alerts of one case on both pins into the run directory, the task marked done; the summary.
A case whose files are all in quick-covered languages is ``skipped`` and nothing is written."""
runner = runner or run_command
started = time.monotonic()
quick, engine = quick_languages(), engine_languages()
rows: list[dict[str, Any]] = []
files: dict[str, dict[str, int]] = {}
summary: dict[str, Any] = {"read": {}, "alerts": {}, "alerts_engine": {}, "in_fix_range": {}, "engine": {}, "pins": dict(case.pins)}
needed: set[str] = set()
for role in ROLES:
checkout = export_pin(cache / case.case_id, case.pins[role], work / f"{case.case_id}--{role}")
try:
quick_rows, read = scan(checkout, role, case.pins[role], case.ranges.get(role))
files[role] = read.pop("languages")
if read["files"] == 0:
raise EngineAlertsError(f"{case.case_id} {role}: the quick scan read no file of {checkout}")
needed |= {lang for lang in files[role] if lang != "unknown" and not covers(quick, lang) and lang in engine}
if not needed:
return {"case": case.case_id, "status": "skipped", "reason": "quick mode covers every language read", "languages": files}
out_dir = work / "out"
out_dir.mkdir(parents=True, exist_ok=True)
name = f"{case.case_id}--{role}"
(out_dir / f"{name}.json").unlink(missing_ok=True)
command = docker_command(source, checkout, out_dir, name, case.family, deadline, image)
try:
done = runner(command, deadline + GRACE)
except subprocess.TimeoutExpired as exc:
raise EngineAlertsError(f"{name}: the engine container outlived {deadline + GRACE:g}s") from exc
record = read_record(out_dir / f"{name}.json", (done.stdout or "") + "\n" + (done.stderr or ""), done.returncode)
found = engine_rows(record, checkout, role, case.pins[role], case.ranges.get(role))
finally:
shutil.rmtree(checkout, ignore_errors=True)
rows.extend(quick_rows + found)
summary["read"][role] = read
summary["alerts"][role] = len(quick_rows) + len(found)
summary["alerts_engine"][role] = len(found)
summary["in_fix_range"][role] = sum(1 for r in found if r["in_fix_range"] is True)
keys = ("files", "bytes", "questions", "completed", "seconds", "degradations", "exit")
summary["engine"][role] = {**{k: record.get(k) for k in keys}, "findings": len(record.get("findings") or [])}
cov = coverage(files, quick, needed)
summary.update(
status="done", units_done=len(ROLES), units_total=len(ROLES), usd=0, calls=0, usage={}, model=None, source="engine",
host=True, family=case.family, image=_image_id(image, runner), deadline=deadline, quick_languages=sorted(quick),
engine_languages=sorted(needed), coverage=cov, uncovered=uncovered(cov), seconds=round(time.monotonic() - started, 1),
) # fmt: skip
output = reconciler.run_dir(run_name, results_root) / case.entry
output.mkdir(parents=True, exist_ok=True)
write_alert_rows(output, rows)
write_alert_summary(output, summary)
reused = {"source": "engine", "host": True, "image": summary["image"], "alerts": summary["alerts"], "seconds": summary["seconds"]}
reconciler.mark_done(run_name, case.entry, reused=reused, results_root=results_root)
return {"case": case.case_id, **summary}
def alerts_engine(
run_manifest: Path, *, source: Path, only: Sequence[str] = (), work: Path | None = None, cache: Path = CACHE,
deadline: float = DEADLINE, image: str = IMAGE, runner: Runner | None = None, results_root: Path | None = None,
) -> list[dict[str, Any]]: # fmt: skip
""":func:`produce` for every `alerts` case of the Run (or those named), one engine container at a time."""
run_name, cases = engine_cases(run_manifest)
unknown = set(only) - {c.case_id for c in cases}
if unknown:
raise EngineAlertsError(f"no alerts task in {run_manifest} for {sorted(unknown)}")
frozen = frozen_source(source)
scratch = Path(work) if work is not None else Path(tempfile.mkdtemp(prefix="ousast-engine-alerts-"))
results: list[dict[str, Any]] = []
for case in cases:
if only and case.case_id not in only:
continue
results.append(
produce(case, run_name, frozen, scratch, cache=cache, deadline=deadline, image=image, runner=runner, results_root=results_root)
)
if work is None:
shutil.rmtree(scratch, ignore_errors=True)
return results
__all__ = ["EngineAlertsError", "EngineCase", "alerts_engine", "engine_cases", "engine_rows", "parse_site", "produce", "read_record"]
+2 -1
View File
@@ -314,6 +314,7 @@ def _remember(item: CaseInputs, templates: Mapping[str, Task], population: str,
inputs["agreed"] = f"{case_id}-final/agreed.json"
if alerts:
inputs["alerts"] = f"{case_id}-alerts/alerts.jsonl"
inputs["alerts_summary"] = f"{case_id}-alerts/summary.json" # the coverage: whether a zero could mean clean
entry = {"name": f"{case_id}-remember", "task": f"remember-{case_id}", "inputs": inputs, "outputs": ["memory.jsonl"], "budget": _free()}
return task, entry
@@ -332,7 +333,7 @@ def _alerts(item: CaseInputs, templates: Mapping[str, Task]) -> tuple[dict, dict
"OUSAST_FIXED_PIN": str(case["fixed"]),
} # fmt: skip
task = _task(templates["alerts"], f"alerts-{case['id']}", env, bindings)
entry = {"name": f"{case['id']}-alerts", "task": f"alerts-{case['id']}", "outputs": ["alerts.jsonl"], "budget": _free()}
entry = {"name": f"{case['id']}-alerts", "task": f"alerts-{case['id']}", "outputs": ["alerts.jsonl", "summary.json"], "budget": _free()}
entry["serialize"] = "engine"
return fixed, task, entry
+2 -2
View File
@@ -1,7 +1,7 @@
"""The plane's memory store (harnessx-removal Requirement 6.1/6.4, design section 4).
One store, keyed by repository and pin, holds what plane runs learned: repository facts by content hash, and rows
of five kinds (``facts``, ``verdict``, ``unit_cost``, ``alert``, ``proposal_outcome``) that the ``remember`` task
of six kinds (``facts``, ``verdict``, ``unit_cost``, ``alert``, ``coverage``, ``proposal_outcome``) that ``remember``
(:mod:`.tasks.remember`) derives from a run's delivered artifacts. Layout, identical for both backends::
index.jsonl one row per ingested (run, task): sha256 of its rows, row count
@@ -49,7 +49,7 @@ MEMORY_KEY_ANNOTATION = "openultrasast.io/memory-key"
POPULATION_ANNOTATION = "openultrasast.io/population"
SPLIT_ANNOTATION = "openultrasast.io/split"
SNAPSHOT_ANNOTATION = "openultrasast.io/memory-snapshot" # the loop's guard: {"catalog", "manifest", "populations"}
KINDS = ("facts", "verdict", "unit_cost", "alert", "proposal_outcome")
KINDS = ("facts", "verdict", "unit_cost", "alert", "coverage", "proposal_outcome")
ROW_FIELDS = ("id", "kind", "repo", "pin", "run", "task", "population", "split", "image")
TAG_FIELDS = ("repo", "pin", "kind", "family", "run", "population", "split")
MIN_FREE_BYTES = 1 << 30
+88 -16
View File
@@ -15,6 +15,15 @@ of ``case.json`` on the vulnerable pin, the new-side ``fixed_ranges`` on the fix
none). A fixed-pin alert inside the fix is a false alert on code the fix wrote: rule M1 of ``improve/memory.py``
counts it.
**Coverage** (an honest zero): quick mode has rules only for the languages its ruleset declares
(:func:`quick_languages`, derived from the bundled ruleset directory, never listed by hand). ``summary.json`` records
per language of the files the scan read whether quick mode covers it -- ``coverage: {language: {"coverage": "quick" |
"none", "files": {role: n}}}`` -- and ``uncovered``, the languages with ``none``. Zero alerts on an uncovered
language is not a clean result: nothing could have fired. ``remember`` turns the coverage into ``coverage`` rows,
and M1/M2 skip a repository and pin whose coverage for a rule's language is ``none``. The deterministic engine covers
more languages (:func:`engine_languages`, PHP among them); ``ousast plane alerts-engine`` (:mod:`..engine_alerts`)
produces those alerts on the host, where the engine image runs, and marks this task done.
Env: ``OUSAST_OUTPUT_DIR``, ``OUSAST_WORKSPACE_DIR`` (vulnerable checkout), ``OUSAST_FIXED_DIR`` (fixed checkout),
``OUSAST_INPUT_CASE`` (``case.json``), ``OUSAST_VULNERABLE_PIN``, ``OUSAST_FIXED_PIN``. A pin whose checkout yields no
source file fails the task: an unread tree and a silent one are indistinguishable afterwards. Exit 0 done, 2 failed.
@@ -26,7 +35,7 @@ import json
import os
import sys
import traceback
from collections.abc import Mapping, Sequence
from collections.abc import Iterable, Mapping, Sequence
from pathlib import Path
from typing import Any
@@ -34,14 +43,55 @@ from ...findings import quick_scan_findings
from ...mapping import analyze_entry_points, attach_reachability_hints
from ...preprocess import detect_language, preprocess_repository
from ...rank import rank_targets
from ...ruleset import DEFAULT_RULESET_DIR, load_ruleset
from .repo_facts import DECLARATION, GLOBAL, enclosing
ROLES = ("vulnerable", "fixed")
DIRS = ("OUSAST_WORKSPACE_DIR", "OUSAST_FIXED_DIR")
PINS = ("OUSAST_VULNERABLE_PIN", "OUSAST_FIXED_PIN")
ANY_LANGUAGE = "*" # a rule that names no language runs on every file
Ranges = Mapping[str, Sequence[Sequence[int]]]
def quick_languages(directory: Path = DEFAULT_RULESET_DIR) -> frozenset[str]:
"""The languages quick mode has a rule for: the ``languages`` of every rule the ruleset directory holds that is
not disabled (``*`` when a rule names none: it runs on every file)."""
covered: set[str] = set()
for rule in load_ruleset(directory):
if rule.status != "disabled":
covered.update(rule.languages or (ANY_LANGUAGE,))
return frozenset(covered)
def engine_languages(directory: Path = DEFAULT_RULESET_DIR) -> frozenset[str]:
"""The languages the deterministic engine has a semantic model for: one ``semantic/<language>.toml`` each."""
return frozenset(path.stem for path in (directory / "semantic").glob("*.toml"))
def covers(covered: Iterable[str], language: str) -> bool:
languages = set(covered)
return ANY_LANGUAGE in languages or language in languages
def coverage(files: Mapping[str, Mapping[str, int]], quick: Iterable[str], engine: Iterable[str] = ()) -> dict[str, dict[str, Any]]:
"""Per language of the files read (``{role: {language: n}}``): ``quick`` when quick mode has a rule for it,
``engine`` when the engine's alerts were produced for it, else ``none``, with the file count per role."""
quick, engine = set(quick), set(engine)
result: dict[str, dict[str, Any]] = {}
for role in sorted(files):
for language, number in files[role].items():
if language == "unknown":
continue
kind = "quick" if covers(quick, language) else "engine" if language in engine else "none"
entry = result.setdefault(language, {"coverage": kind, "files": {}})
entry["files"][role] = int(number)
return dict(sorted(result.items()))
def uncovered(cov: Mapping[str, Mapping[str, Any]]) -> list[str]:
return sorted(language for language, entry in cov.items() if entry.get("coverage") == "none")
def _lines(path: Path) -> list[str]:
try:
return path.read_text(encoding="utf-8", errors="replace").splitlines()
@@ -55,24 +105,35 @@ def in_range(ranges: Ranges | None, path: str, line: int | None) -> bool | None:
return line is not None and any(int(lo) <= line <= int(hi) for lo, hi in ranges.get(path, []))
def scan(root: Path, role: str, pin: str, ranges: Ranges | None) -> tuple[list[dict[str, Any]], dict[str, int]]:
"""(rows, what was read) of one pin: the quick-mode findings as alert rows, one per rule, file, line."""
def function_at(root: Path, path: str, line: int | None, fallback: str | None, cache: dict[str, list[str]]) -> str:
"""The enclosing declaration of ``path:line`` by the ``repo-facts`` patterns, else ``fallback`` (``<global>``)."""
lines = cache.setdefault(path, _lines(root / path))
language = detect_language(root / path)
if language in DECLARATION and line and 0 < line <= len(lines):
return enclosing(lines, line - 1, language)
return fallback or GLOBAL
def scan(root: Path, role: str, pin: str, ranges: Ranges | None) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""(rows, what was read) of one pin: the quick-mode findings as alert rows, one per rule, file, line; what was read
counts files and bytes, and files per language."""
_, targets = preprocess_repository(root)
targets = attach_reachability_hints(targets, analyze_entry_points(root, targets))
findings = quick_scan_findings(root, targets, rank_targets(targets))
read = {"files": len(targets), "bytes": sum((root / t.path).stat().st_size for t in targets if (root / t.path).is_file())}
languages: dict[str, int] = {}
for target in targets:
languages[target.language] = languages.get(target.language, 0) + 1
read = {
"files": len(targets), "bytes": sum((root / t.path).stat().st_size for t in targets if (root / t.path).is_file()),
"languages": dict(sorted(languages.items())),
} # fmt: skip
cache: dict[str, list[str]] = {}
rows: dict[tuple[str, str, int | None], dict[str, Any]] = {}
for finding in findings:
rule_id = finding.finding_id.split(":", 1)[0]
lines = cache.setdefault(finding.path, _lines(root / finding.path))
language = detect_language(root / finding.path)
if language in DECLARATION and finding.line and 0 < finding.line <= len(lines):
function = enclosing(lines, finding.line - 1, language)
else:
function = finding.function_name or GLOBAL
row = {
"rule_id": rule_id, "rule_status": finding.status, "path": finding.path, "line": finding.line, "function": function,
"rule_id": rule_id, "rule_status": finding.status, "path": finding.path, "line": finding.line,
"function": function_at(root, finding.path, finding.line, finding.function_name, cache),
"pin_role": role, "pin": pin, "in_fix_range": in_range(ranges, finding.path, finding.line),
} # fmt: skip
rows.setdefault((rule_id, finding.path, finding.line), row)
@@ -80,10 +141,14 @@ def scan(root: Path, role: str, pin: str, ranges: Ranges | None) -> tuple[list[d
return ordered, read
def _summary(output_dir: Path, summary: Mapping[str, Any]) -> None:
def write_summary(output_dir: Path, summary: Mapping[str, Any]) -> None:
(output_dir / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def write_rows(output_dir: Path, rows: Iterable[Mapping[str, Any]]) -> None:
(output_dir / "alerts.jsonl").write_text("".join(json.dumps(r, sort_keys=True) + "\n" for r in rows), encoding="utf-8")
def main(environ: Mapping[str, str] | None = None) -> int:
env = environ if environ is not None else os.environ
output = Path(env["OUSAST_OUTPUT_DIR"]) if env.get("OUSAST_OUTPUT_DIR") else None
@@ -95,6 +160,7 @@ def main(environ: Mapping[str, str] | None = None) -> int:
case = json.loads(Path(env["OUSAST_INPUT_CASE"]).read_text(encoding="utf-8")) if env.get("OUSAST_INPUT_CASE") else {}
ranges = {"vulnerable": case.get("ranges"), "fixed": case.get("fixed_ranges")}
rows: list[dict[str, Any]] = []
files: dict[str, dict[str, int]] = {}
for role, directory, pin in zip(ROLES, DIRS, PINS, strict=True):
root = Path(env.get(directory) or "")
if not env.get(directory) or not root.is_dir():
@@ -102,21 +168,27 @@ def main(environ: Mapping[str, str] | None = None) -> int:
found, read = scan(root, role, str(env.get(pin) or ""), ranges[role])
if read["files"] == 0:
raise ValueError(f"the {role} checkout {root} has no source file the scan reads: an unread tree is not a clean one")
files[role] = read.pop("languages")
summary.setdefault("read", {})[role] = read
summary.setdefault("alerts", {})[role] = len(found)
summary.setdefault("pins", {})[role] = str(env.get(pin) or "")
rows.extend(found)
summary["units_done"] += 1
(output / "alerts.jsonl").write_text("".join(json.dumps(r, sort_keys=True) + "\n" for r in rows), encoding="utf-8")
quick = quick_languages()
summary["quick_languages"] = sorted(quick)
summary["coverage"] = coverage(files, quick)
summary["uncovered"] = uncovered(summary["coverage"])
write_rows(output, rows)
summary["status"] = "done"
except Exception: # noqa: BLE001 -- a crash is `failed` with its traceback
summary["reason"] = traceback.format_exc()[-2000:]
if output is not None:
_summary(output, summary)
print(json.dumps({k: summary.get(k) for k in ("status", "alerts", "read")}), file=sys.stderr)
write_summary(output, summary)
print(json.dumps({k: summary.get(k) for k in ("status", "alerts", "read", "uncovered")}), file=sys.stderr)
return 0 if summary["status"] == "done" else 2
__all__ = ["in_range", "main", "scan"]
__all__ = ["coverage", "covers", "engine_languages", "function_at", "in_range", "main", "quick_languages", "scan", "uncovered"]
if __name__ == "__main__":
+5
View File
@@ -119,6 +119,10 @@ def metrics(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
tally_a = alerts.setdefault(str(a.get("rule_id")), {"vulnerable": 0, "fixed": 0, "fixed_in_fix_range": 0})
tally_a[role] += 1
tally_a["fixed_in_fix_range"] += role == "fixed" and a.get("in_fix_range") is True
coverage: dict[str, dict[str, int]] = {} # language -> pins per coverage kind: a `none` pin's zero alerts mean nothing
for c in (r for r in rows if r["kind"] == "coverage"):
tally_c = coverage.setdefault(str(c.get("language")), {"quick": 0, "engine": 0, "none": 0})
tally_c[str(c.get("coverage"))] = tally_c.get(str(c.get("coverage")), 0) + 1
return {
"cases": len({(v["repo"], v["pin"]) for v in verdicts}),
"candidates": len(verdicts),
@@ -130,6 +134,7 @@ def metrics(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
"usd_per_candidate": None if usd is None or not verdicts else round(usd / len(verdicts), 6),
"dispute_rate": {f: {"candidates": n, "disputed": d, "rate": round(d / n, 4)} for f, (n, d) in sorted(families.items())},
"alerts": dict(sorted(alerts.items())),
"coverage": dict(sorted(coverage.items())),
}
+36 -7
View File
@@ -2,8 +2,9 @@
No model, budget ``{usd: 0, calls: 0}``, one per case. It reads the case's `facts.json`, the `units.jsonl` of the
verify passes a, b and c, the final `agreed.json` (its per-candidate rows and `disputed` list) and, when present,
`alerts.jsonl`, and writes `memory.jsonl` -- rows of the kinds `facts`, `verdict`, `unit_cost` and `alert` (the
fifth kind, `proposal_outcome`, comes from the loop) -- and passes `facts.json` through. Task code interprets the
`alerts.jsonl` with the alerts' `summary.json`, and writes `memory.jsonl` -- rows of the kinds `facts`, `verdict`,
`unit_cost`, `alert` and `coverage` (per language and pin: whether any rule could have fired there; the kind
`proposal_outcome` comes from the loop) -- and passes `facts.json` through. Task code interprets the
artifacts, so the reconciler and the runner stay content-blind (ai-service-plane Req 3.4).
Every row carries `id` (deterministic: :func:`..memory.row_id`), `kind`, `repo`, `pin`, `run`, `task`,
@@ -12,7 +13,8 @@ Every row carries `id` (deterministic: :func:`..memory.row_id`), `kind`, `repo`,
Env contract in a Run: `OUSAST_OUTPUT_DIR`, `OUSAST_RUN`, `OUSAST_TASK`, `OUSAST_INPUT_FACTS` (and `OUSAST_INPUT_FACTS_SUMMARY`,
whose reused or recorded `image` the facts row carries; without one named, the row is omitted),
`OUSAST_INPUT_PASS_A`/`_B`, optional `OUSAST_INPUT_PASS_C`, `OUSAST_INPUT_SUMMARY_A`/`_B`/`_C` (the passes'
`summary.json`, for the model), `OUSAST_INPUT_AGREED`, `OUSAST_INPUT_ALERTS`; the repository and pin from the bound
`summary.json`, for the model), `OUSAST_INPUT_AGREED`, `OUSAST_INPUT_ALERTS` (and `OUSAST_INPUT_ALERTS_SUMMARY`,
the coverage); the repository and pin from the bound
Workspace (`AX_WORKSPACES_YAML`, `OUSAST_GIT_PINS`), the image from `AX_TASK_YAML`, the population and split from
`OUSAST_POPULATION`/`OUSAST_SPLIT` (the generator copies the Run's `openultrasast.io/population` and
`openultrasast.io/split` annotations). Exit 0 done, 2 failed.
@@ -74,6 +76,26 @@ def _final(row: Mapping[str, Any], disputed: set[str]) -> str:
return "disputed" if str(row["candidate"]) in disputed else "rejected"
def coverage_rows(ctx: Context, alerts_summary: Mapping[str, Any] | None) -> list[dict[str, Any]]:
"""One ``coverage`` row per language and pin of the alerts' ``summary.json``: ``quick``, ``engine`` or ``none``
(no rule could fire, so no alert there is evidence of anything), with the files read in that language. A summary
from before coverage was recorded yields none: unknown stays unknown."""
if not alerts_summary or not isinstance(alerts_summary.get("coverage"), Mapping):
return []
recorded = alerts_summary.get("pins")
pins: Mapping[str, Any] = recorded if isinstance(recorded, Mapping) else {}
rows: list[dict[str, Any]] = []
for language, entry in sorted(alerts_summary["coverage"].items()):
for role, files in sorted((entry.get("files") or {}).items()):
fields: dict[str, Any] = {"language": language, "coverage": entry.get("coverage"), "files": files, "pin_role": role}
if pins.get(role):
fields["pin"] = str(pins[role])
if alerts_summary.get("source"):
fields["source"] = str(alerts_summary["source"])
rows.append(ctx.row("coverage", f"{language}:{role}", **fields))
return rows
def rows_for(
ctx: Context,
*,
@@ -82,8 +104,9 @@ def rows_for(
models: Mapping[str, str | None],
agreed: Mapping[str, Any] | None,
alerts: Sequence[Mapping[str, Any]] = (),
alerts_summary: Mapping[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""The memory rows of one case: facts, verdicts, unit costs, alerts."""
"""The memory rows of one case: facts, verdicts, unit costs, alerts, and the alerts' coverage per language and pin."""
rows: list[dict[str, Any]] = []
if facts is not None:
loaded = json.loads(facts)
@@ -115,8 +138,11 @@ def rows_for(
fields["in_fix_range"] = alert["in_fix_range"]
if alert.get("pin"): # a fixed-pin alert is a row of the pin it was raised on, not of the case's vulnerable pin
fields["pin"] = str(alert["pin"])
if alert.get("source"): # `engine` when `ousast plane alerts-engine` produced it on the host
fields["source"] = str(alert["source"])
subject = f"{fields['rule_id']}:{fields['path']}:{fields['line']}" + (":fixed" if fields.get("pin_role") == "fixed" else "")
rows.append(ctx.row("alert", subject, **fields))
rows.extend(coverage_rows(ctx, alerts_summary))
return [validate_row(r, f"{ctx.task} {r['kind']}") for r in rows]
@@ -185,7 +211,9 @@ def case_rows(base: Path, case: str, *, population: str, split: str) -> tuple[li
passes = {p: _jsonl(base / f"{case}-v{p}" / "units.jsonl") for p in PASSES}
models = {p: _model(_json(base / f"{case}-v{p}" / "summary.json")) for p in PASSES}
alerts = _jsonl(base / f"{case}-alerts" / "alerts.jsonl")
return rows_for(ctx, facts=facts, passes=passes, models=models, agreed=agreed, alerts=alerts), facts
alerts_summary = _json(base / f"{case}-alerts" / "summary.json")
rows = rows_for(ctx, facts=facts, passes=passes, models=models, agreed=agreed, alerts=alerts, alerts_summary=alerts_summary)
return rows, facts
def remember_run(run_dir: Path, store: MemoryStore, *, population: str = UNKNOWN, split: str = UNKNOWN) -> list[IngestResult]:
@@ -246,7 +274,8 @@ def main(environ: Mapping[str, str] | None = None) -> int:
passes = {p: _units(_path(env, f"OUSAST_INPUT_PASS_{p.upper()}")) for p in PASSES}
models = {p: _model(_json(_path(env, f"OUSAST_INPUT_SUMMARY_{p.upper()}"))) for p in PASSES}
agreed = _json(_path(env, "OUSAST_INPUT_AGREED"))
rows = rows_for(ctx, facts=None, passes=passes, models=models, agreed=agreed, alerts=_jsonl(_path(env, "OUSAST_INPUT_ALERTS")))
alerts, alerts_summary = _jsonl(_path(env, "OUSAST_INPUT_ALERTS")), _json(_path(env, "OUSAST_INPUT_ALERTS_SUMMARY"))
rows = rows_for(ctx, facts=None, passes=passes, models=models, agreed=agreed, alerts=alerts, alerts_summary=alerts_summary)
facts_image = _facts_image(env, ctx.image)
if facts is not None and facts_image is not None: # the facts row carries the image the facts were computed on
facts_ctx = Context(ctx.run, ctx.task, ctx.repo, ctx.pin, facts_image, ctx.population, ctx.split)
@@ -265,7 +294,7 @@ def main(environ: Mapping[str, str] | None = None) -> int:
return 0 if summary["status"] == "done" else 2
__all__ = ["Context", "candidates_digest", "case_rows", "context_from", "main", "remember_run", "rows_for"]
__all__ = ["Context", "candidates_digest", "case_rows", "context_from", "coverage_rows", "main", "remember_run", "rows_for"]
if __name__ == "__main__":