mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
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:
co-authored by
Claude Opus 5.5
parent
f4bd984fda
commit
45d02ecf20
@@ -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:
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user