From 8215644584587ab3334b7a89debe235d1a304b5d Mon Sep 17 00:00:00 2001 From: pureliture Date: Sun, 21 Jun 2026 22:23:05 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(vuln-rollout):=20JSONL-first=20?= =?UTF-8?q?=EC=97=B0=EC=86=8D=20=EC=8A=A4=EC=BA=94=20rollout=20draft=20(br?= =?UTF-8?q?anch-local,=20enable=20=EB=B3=B4=EB=A5=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vuln/SAST 연속 스캔 production rollout 준비. PR #59 substrate(JSONL-grain) 위에 JSONL 스케줄 아티팩트 잡으로 먼저 가고 durable SCAN_JOB/SCAN_LEDGER 통합은 human-gated H-track로 연기(storage-projection / secret-default 게이트 회피). - deploy/systemd/user/security-scanner-personal-vuln-{scan,freshness-eval}.{service,timer}: secret unit mirror, JSONL backend(DYNAMO_* 없음), oneshot, securityscanner.slice, scan 일1회 03:30 / eval 6h. 전부 "DRAFT, not for enable" 헤더. - tests/test_vuln_rollout_systemd_units.py: unit 구조 검증(real semgrep 불필요). - docs/adr/20260621-vuln-continuous-scanning-rollout.md: 결정 + 승격 트리거 + durable seam. - docs/runbooks/vuln-rollout-enable-checklist.md: 단일-owner enable 순서/롤백/abort. proof: pytest systemd 47 + vuln/parity/governance 232 passed (semgrep 없이). NOT enabled: 호스트 checkout 66aa165(main보다 뒤), semgrep 미설치, deploy/** allowed_writes 밖 → production enable은 단일-owner 후속 작업. Co-Authored-By: Claude Opus 4.8 --- ...anner-personal-vuln-freshness-eval.service | 37 ++ ...scanner-personal-vuln-freshness-eval.timer | 17 + ...ecurity-scanner-personal-vuln-scan.service | 45 ++ .../security-scanner-personal-vuln-scan.timer | 18 + ...260621-vuln-continuous-scanning-rollout.md | 173 ++++++++ .../runbooks/vuln-rollout-enable-checklist.md | 417 ++++++++++++++++++ tests/test_vuln_rollout_systemd_units.py | 219 +++++++++ 7 files changed, 926 insertions(+) create mode 100644 deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.service create mode 100644 deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.timer create mode 100644 deploy/systemd/user/security-scanner-personal-vuln-scan.service create mode 100644 deploy/systemd/user/security-scanner-personal-vuln-scan.timer create mode 100644 docs/adr/20260621-vuln-continuous-scanning-rollout.md create mode 100644 docs/runbooks/vuln-rollout-enable-checklist.md create mode 100644 tests/test_vuln_rollout_systemd_units.py diff --git a/deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.service b/deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.service new file mode 100644 index 0000000..355fd9e --- /dev/null +++ b/deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.service @@ -0,0 +1,37 @@ +[Unit] +Description=security-scanner personal code-vuln freshness/coverage eval +Documentation=https://github.com/source-security-dev/security-scanner + +# ROLLOUT DRAFT (not for enable). Authored under .worktrees/vuln-rollout-prep +# per the JSONL-scheduled vuln-rollout DECISION (Phase 2). Artifact-enumeration +# freshness: this evaluator walks the per-run vuln JSONL artifacts under +# SECURITY_SCANNER_CACHE_ROOT / the vuln-artifacts dir and the INCLUDED CATALOG +# set, then reports per-repo last-vuln-scan recency and coverage gap. It does +# NOT read or write vuln findings to the DynamoDB table; durable REPO_HEALTH / +# BREACH_COUNTER for vuln is H-track (human-gated) work, not this draft. +# +# REQUIRED CODE CHANGE (Phase 2): the `vuln-freshness-eval` subcommand does not +# exist yet. It is the artifact-walking evaluator described in the DECISION +# (Phase 2) and is recorded as a followup. Until it lands this ExecStart fails +# argparse; that is intentional for a draft. + +[Service] +Type=oneshot +Slice=securityscanner.slice +Nice=19 +IOSchedulingClass=idle +TasksMax=128 +WorkingDirectory=%h/security-scanner +EnvironmentFile=-%h/.config/security-scanner/personal-prod.env +Environment=PATH=%h/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/bin +Environment=SECURITY_SCANNER_STORAGE_BACKEND=jsonl +Environment=SECURITY_SCANNER_CACHE_ROOT=%h/.cache/security-scanner-personal/repos +ExecStart=%h/.local/bin/uv run security-scanner vuln-freshness-eval \ + --artifact-dir %h/.local/state/security-scanner/vuln-artifacts \ + --scan-cadence-hours 24 \ + --margin-hours 6 \ + --backlog-alert-threshold 10 \ + --notification-log %h/.local/state/security-scanner/personal-vuln-alerts.log.jsonl + +[Install] +WantedBy=default.target diff --git a/deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.timer b/deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.timer new file mode 100644 index 0000000..c271c34 --- /dev/null +++ b/deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.timer @@ -0,0 +1,17 @@ +[Unit] +Description=Scheduler for security-scanner personal code-vuln freshness/coverage eval +Documentation=https://github.com/source-security-dev/security-scanner + +# ROLLOUT DRAFT (not for enable). Evaluation cadence is much looser than the +# secret freshness-eval (10 min) because the vuln scan cadence itself is daily, +# not per-commit. Every 6 hours is enough to surface a missed daily vuln pass +# without churn. Tune alongside the vuln-scan timer during H-track calibration. + +[Timer] +OnCalendar=*-*-* 00/6:00:00 +Persistent=true +RandomizedDelaySec=300 +Unit=security-scanner-personal-vuln-freshness-eval.service + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/user/security-scanner-personal-vuln-scan.service b/deploy/systemd/user/security-scanner-personal-vuln-scan.service new file mode 100644 index 0000000..21668b9 --- /dev/null +++ b/deploy/systemd/user/security-scanner-personal-vuln-scan.service @@ -0,0 +1,45 @@ +[Unit] +Description=security-scanner personal code-vuln (SAST) scheduled scan +Documentation=https://github.com/source-security-dev/security-scanner + +# ROLLOUT DRAFT (not for enable). Authored under .worktrees/vuln-rollout-prep +# per the JSONL-scheduled vuln-rollout DECISION (Phase 0). DO NOT `systemctl +# enable` this unit: the host checkout is behind origin/main, the catalog-driven +# scan-vuln entrypoint referenced below is a Phase 1 code change that does not +# exist yet, and semgrep is not installed on the host. See +# docs/runbooks/vuln-rollout-enable-checklist.md before any enable. +# +# Storage divergence from the secret units: the code-vuln plane is JSONL-only. +# report/gate/evaluate --category code-vuln reject --storage-backend dynamodb, +# and VulnerabilityJsonlStore has no DynamoDB schema. This unit therefore sets +# SECURITY_SCANNER_STORAGE_BACKEND=jsonl and writes artifacts under +# SECURITY_SCANNER_CACHE_ROOT, not into the security_scanner_personal table. + +[Service] +Type=oneshot +Slice=securityscanner.slice +Nice=19 +IOSchedulingClass=idle +TasksMax=256 +WorkingDirectory=%h/security-scanner +EnvironmentFile=-%h/.config/security-scanner/personal-prod.env +Environment=PATH=%h/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/bin +Environment=SECURITY_SCANNER_STORAGE_BACKEND=jsonl +Environment=SECURITY_SCANNER_CACHE_ROOT=%h/.cache/security-scanner-personal/repos +# REQUIRED CODE CHANGE (Phase 1): `scan-vuln` today accepts only `--root DIR` +# (single local checkout) and writes a single artifact via write_all (mode 'w'). +# A catalog-driven, per-repo, no-clobber form does not exist yet. The flags +# below (--from-catalog, --artifact-dir, --semgrep-binary, --notification-log) +# describe the intended Phase 1 entrypoint and are recorded as a followup. Until +# that lands this ExecStart will fail argparse; that is intentional for a draft. +ExecStart=%h/.local/bin/uv run security-scanner scan-vuln \ + --from-catalog \ + --artifact-dir %h/.local/state/security-scanner/vuln-artifacts \ + --semgrep-binary %h/.local/bin/semgrep \ + --semgrep-config auto \ + --timeout-seconds 1800 \ + --path-policy redacted \ + --notification-log %h/.local/state/security-scanner/personal-vuln-scan.log.jsonl + +[Install] +WantedBy=default.target diff --git a/deploy/systemd/user/security-scanner-personal-vuln-scan.timer b/deploy/systemd/user/security-scanner-personal-vuln-scan.timer new file mode 100644 index 0000000..07720bc --- /dev/null +++ b/deploy/systemd/user/security-scanner-personal-vuln-scan.timer @@ -0,0 +1,18 @@ +[Unit] +Description=Scheduler for security-scanner personal code-vuln (SAST) scan +Documentation=https://github.com/source-security-dev/security-scanner + +# ROLLOUT DRAFT (not for enable). Cadence is deliberately LONG: semgrep-compatible +# SAST is a full directory-tree HEAD scan, far more expensive than the per-commit +# secret incr-poll (5 min) or daily secret baseline. Daily at 03:30 keeps the +# vuln pass off the secret baseline's 04:00 slot. Tune per real semgrep wall-clock +# during H-track calibration; weekly is also acceptable for large catalogs. + +[Timer] +OnCalendar=*-*-* 03:30:00 +Persistent=true +RandomizedDelaySec=1800 +Unit=security-scanner-personal-vuln-scan.service + +[Install] +WantedBy=timers.target diff --git a/docs/adr/20260621-vuln-continuous-scanning-rollout.md b/docs/adr/20260621-vuln-continuous-scanning-rollout.md new file mode 100644 index 0000000..05e7432 --- /dev/null +++ b/docs/adr/20260621-vuln-continuous-scanning-rollout.md @@ -0,0 +1,173 @@ +# ADR: Vulnerability/SAST continuous-scanning rollout — JSONL scheduled artifact first, durable queue/ledger deferred to the human-gated track + +- Status: Proposed +- Date: 2026-06-21 +- Decision owner: security-scanner maintainers (production enable is a single-owner step) +- Confidence: high +- Scope of this ADR: branch-local design + drafting in worktree `.worktrees/vuln-rollout-prep`. No host mutation, no production enable, no `systemctl`, no `ssh`. + +## Context + +### What PR #59 already shipped (the vuln/SAST plane) + +PR #59 landed a complete, self-contained vuln/SAST scanning plane (milestones M1–M3, merged to `main`). It exposes three producer entrypoints — `import-sarif` (normalize an existing SARIF 2.1.0 file), `scan-vuln` (invoke a semgrep-compatible binary as a subprocess, then normalize), and `verify --category code-vuln` (apply an Ollama-compatible LLM verifier) — and three consumers — `report` / `gate` / `evaluate --category code-vuln`. Every one of these reads and writes `VULN_FINDING` JSONL artifacts on local disk via `VulnerabilityJsonlStore`. + +The plane has **zero coupling** to the secret-scanning durable plane. This is verified, not assumed: + +- A grep for `VULN_FINDING` / `VulnerabilityFinding` under `src/security_scanner/storage/adapters/nosql_db/` returns **nothing** — no DynamoDB item kind, axis, or item class exists for vuln findings. +- `src/security_scanner/cli/commands/report.py` hard-rejects DynamoDB for the vuln category: + + ```python + def _read_vulnerability_findings_from_args(args): + if args.storage_backend != "jsonl": + raise ValueError("code-vuln category supports JSONL artifacts only") + return VulnerabilityJsonlStore(args.findings).read_all() + ``` + +- 139 vuln-related tests pass locally with **no real semgrep binary** present — the runner is injected (`FakeRunner`) at the `runner=` seam in `run_vulnerability_scan`. + +The downstream gates already read on-disk artifacts: the M3 synthetic regression gate (`eval/synthetic-code-vuln/corpus-snapshot.json`), the M1 codescan parity matcher (`eval/codescan-parity-corpus/synthetic-snapshot.json`), and the report-only `governance/vuln_parity_slo.py` SLO gate (no thresholds file committed → report-only). + +### The secret continuous-scanning plane (the mirror template) + +The secret plane is a five-unit systemd topology over a single DynamoDB-compatible table (`security_scanner_personal`) with 18 coexisting item kinds and two GSIs. Its machinery: `incr-poll` (every 5 min → enqueue `SCAN_JOB` job_type=incremental), `baseline` (daily 04:00 → enqueue job_type=baseline), N `scan-worker@` daemons (lease → fetch → `GitleaksScanner` → `SCAN_LEDGER` + findings + `REPO_HEALTH` advance), `lease-reaper` (every 2 min → return expired leases, dead-letter past `max_lease_expiries=5`), and `freshness-eval` (every 10 min → per-repo breach evaluation → `BREACH_COUNTER` rollup → deduped alerts). All units share `Slice=securityscanner.slice` and `EnvironmentFile=-%h/.config/security-scanner/personal-prod.env`. This plane is **running live on host `ragflow-ubuntu`**. + +The secret plane is fully queue-integrated and durable; the vuln plane stops at a local JSONL file. There is no `SCAN_JOB` for vuln, no lease, no ledger, no `REPO_HEALTH` freshness row, no DynamoDB persistence of vuln findings. + +### The question + +Should continuous vuln scanning be **(A)** a timer-driven JSONL artifact job, or **(B)** integrated into the durable `SCAN_JOB` / `SCAN_LEDGER` queue? + +### Constraints that dominate the decision + +1. **Governance.** The active autopilot goal `ghas-quality-vuln-parity` lists `storage-projection-or-schema-migration-required` and `existing-secret-default-behavior-change` as **hard stop-conditions** (`governance/autopilot_goal.yml`). Any durable-store change for vuln is, by definition, a storage projection / schema migration on the live shared table, and editing the shared `repo_health_freshness_attr` or `make_default_scanner` seams risks the secret default path. `deploy/systemd/**` is **not** in `allowed_writes`, so the drafted unit files are authorized only by the worktree handoff and must be flagged for single-owner review. +2. **Grain.** Vuln output is already JSONL; the consumers (`report`/`gate`/`evaluate`, parity SLO, regression gate) read on-disk artifacts and reject DynamoDB for `code-vuln`. +3. **Tooling.** `semgrep` is absent from PATH and a global install is forbidden. `SemgrepCompatibleRunner` subprocess-execs the binary, so nothing can be live-verified end-to-end in this scope (tests inject `FakeRunner`). +4. **Host drift.** The host checkout is behind `origin/main` (which contains the M1–M3 vuln substrate); production enable is a later single-owner step after the host is brought up to a commit containing the substrate and the drafted units. + +## Decision + +Adopt a **hybrid-phased** rollout: ship continuous vuln scanning as a **JSONL scheduled artifact job now** (Phases 0–3, branch-local, governance-permitted), and **defer durable queue/ledger integration to the human-gated H-track** (Phase H), with an explicit clean seam (`ScanRunSummary.category` + `ScanRunSummary.artifact_uri`, already present in `storage/adapters/nosql_db/items.py`) so the durable upgrade is in-place, not a rewrite. + +Recommended primary option for the immediate deliverable: **JSONL-scheduled**. Durable-queue is the correct *destination* but is **deferred**, not rejected. + +### Why JSONL-scheduled now + +- It is the **only option the active autonomous goal permits**: it persists nothing to the shared table, so it never trips `storage-projection-or-schema-migration-required`. +- It **extends the grain the vuln substrate was deliberately built on**; producer/consumer seams are live, tested, and already reject DynamoDB for `code-vuln`. +- It is **fully testable with no semgrep** via the injectable `runner=` seam plus the `configparser` systemd-unit test pattern in `tests/test_personal_prod_systemd_units.py`. +- The vuln scan model — a directory-tree HEAD scan with no `commit_range`, where ls-remote / `REF_STATE` change-detection is irrelevant, on a slow daily/weekly cadence — **matches a periodic oneshot timer**, not a change-driven per-commit queue. + +### Why durable-queue is deferred, not chosen now + +The durable path is genuinely low-surprise, and the evidence for it is real: + +- `verify_queue.py` already proved a third free-form `job_type` (`JOB_TYPE_VERIFY = "verify"`, `VERIFY_JOB_PRIORITY = 950`) round-trips the `SCAN_JOB` item shape with **zero new GSI / attribute**. +- `repo_health_freshness_attr(job_type)` (`items.py`) is a single dispatch seam with an attribute-scoped conditional `UpdateItem`, so a third freshness field (`lastSuccessfulVulnScanAt`) would be collision-free by construction and could not regress the incremental/baseline writes. + +**But** it is the right destination, not the right first step: + +1. It trips two stop-conditions and is therefore H-track, human-gated work. +2. `complete_processed_job` and the `CommitScanner.scan` contract are typed to the secret `Finding` model, while `VulnerabilityFinding` is a deliberately separate frozen model — so a durable vuln path needs a **new worker binding + adapter**, not a drop-in. `make_default_scanner()` hardwires `GitleaksScanner` with no `job_type` dispatch (`scan_worker.py:259-261`). +3. Its headline benefit — vuln staleness visible to `freshness-eval` / `BREACH_COUNTER` — is only needed once vuln enforces an SLA, which is itself H3-gated. Paying the schema / worker-dispatch cost before H-track calibration sets real enforce thresholds is mistimed. + +### Why hybrid beats pure-A + +Pure-A risks being read as a dead end. It is not: `ScanRunSummary` already carries `category` (default `"secret"`, `items.py:75`) and `artifact_uri` (`items.py:78`, serialized as `artifactUri` at `items.py:299`). Phase 2 can write an **index-only durable row** (no new item kind, no finding-schema migration) that the H-track durable phase upgrades in place. Naming that migration boundary now is the difference between deferral-by-design and deferral-by-omission. + +### Phasing + +**Phase 0 — branch-local; no host, no enable.** Author `deploy/systemd/user/security-scanner-personal-vuln-*.{service,timer}` as drafts in the worktree, mirroring the personal-prod baseline template (`Type=oneshot`, `Slice=securityscanner.slice`, `Nice=15`, `IOSchedulingClass=idle`, `EnvironmentFile=-%h/.config/security-scanner/personal-prod.env`, `SECURITY_SCANNER_DYNAMO_ENDPOINT=http://localhost:4567`, `SECURITY_SCANNER_DYNAMO_TABLE=security_scanner_personal`, `SECURITY_SCANNER_CACHE_ROOT=%h/.cache/security-scanner-personal/repos`, `WantedBy=default.target` / `timers.target`). Use a **LONG cadence** (daily or weekly `OnCalendar`, `Persistent=true`, `RandomizedDelaySec`) — never the 5-min `incr-poll` cadence. No `ExecStartPre` Docker mutation, no absolute machine-local paths. `deploy/systemd/**` is NOT in `autopilot_goal.yml` `allowed_writes` — these drafts are authorized only by the worktree handoff, so flag them for single-owner review and do not assume autonomous merge. + +**Phase 1 — branch-local; JSONL scheduled artifact path.** Fix the `write_all("w")` clobber: write per-run artifacts keyed by `scan_run_id` (or `repo_id` + `scan_run_id`) and/or a oneshot single-instance guard; add a test that two runs do not clobber. Add a per-repo target-iteration seam so the timer scans more than one root, reusing `read_all_catalog_entries` for INCLUDED repos + `fetch_or_clone`, emitting one artifact per repo, injecting the fetcher and `SemgrepCompatibleRunner` (`FakeRunner` in tests). It must **not** enqueue `SCAN_JOB`s. Add a `VULN_PERIODIC_UNITS` parametrize block to `tests/test_vuln_rollout_systemd_units.py` mirroring `PERIODIC_UNITS` in `tests/test_personal_prod_systemd_units.py`. + +**Phase 2 — branch-local; artifact-based freshness/coverage + index-only durability.** Add a pure-function evaluator that walks emitted vuln artifacts (`scan_run_id` / embedded `scanned_at`) plus the CATALOG included set and reports per-repo last-vuln-scan recency and coverage gap, with `tmp_path` tests, no live table writes. Optionally write `ScanRunSummary(category="code-vuln", artifact_uri=...)` via the existing `items.py` seam for index-only durability — this is the explicit clean seam the later durable phase upgrades in place, and it uses no new item kind. + +**Phase 3 — branch-local; pre-flight + governance wiring.** Add a semgrep binary pre-flight / `--semgrep-binary` (or container) wiring so a missing binary surfaces a distinguishable `binary-missing` outcome vs `scanned-clean` (cover the `FileNotFoundError` → `SemgrepExecutionError` mapping). Wire `governance.vuln_parity_slo --check` into `ci.yml` as an **advisory** step; keep `governance/vuln_parity_slo_thresholds.yml` **absent** so it stays report-only. Document in `CURRENT.md` that this is detection + freshness continuous scanning **only** — durable vuln verdict/disposition remains H4-gated. Run the full local check suite (`uv run pytest`, `governance.render --validate/--check`, `governance.public_safety --diff`, `governance.vuln_parity_slo --check`, `governance.autopilot_gate --base origin/main`). + +**Phase H — HUMAN-GATED; NOT autonomous, NOT this scope.** After the human-gated H1 real CodeQL snapshot fetch and H2 baseline measurement, and once H3 commits real enforce thresholds, do the durable migration as a human PR: add `JOB_TYPE_VULN` (free-form, zero item-shape change), extend `repo_health_freshness_attr` with `REPO_HEALTH_VULN_ATTR = "lastSuccessfulVulnScanAt"` (third attribute-scoped branch), add a vuln-enqueue runtime over `read_all_catalog_entries` with priority ~920 (between baseline=900 and verify=950) + `SCAN_LEDGER` idempotency, and a separate vuln-worker binding with a `SemgrepCommitScanner` adapter — persisting ledger + `REPO_HEALTH` durably while keeping `VulnerabilityFinding` bodies as per-`scan_run_id` artifacts (upgrading the Phase 2 `ScanRunSummary` index). Explicitly **defer** durable `VulnerabilityFinding` disposition / `FINDING_STATE` projection to H4 (`storage-projection-or-schema-migration-required`). Production enable on `ragflow-ubuntu` is the final single-owner step after the host is brought up to main. + +## Consequences + +### Positive + +- Ships detection + freshness in branch-local, governance-compliant scope. +- Zero blast radius on the 18-item-kind shared table; zero risk to the live secret path while a Codex session runs live secret scanning on the host. +- The durable upgrade is pre-scoped, low-surprise, and seam-ready (`ScanRunSummary.category` + `artifact_uri` already exist). +- Fully testable today with no semgrep, via the injectable `runner=` seam and the `configparser` systemd-unit test pattern. +- Cadence/scan model fit: a long-cadence oneshot timer matches the directory-tree HEAD scan model exactly; no irrelevant ls-remote / `REF_STATE` change-detection machinery is dragged in. + +### Negative / accepted + +- `VulnerabilityJsonlStore.write_all` opens mode `"w"` (verified `vulnerability_jsonl_store.py:26`) — overwrites, not appends; overlapping long semgrep runs clobber. Phase 1 must add per-`scan_run_id` paths / a no-overlap guard, with a regression test. +- No durable dedup / lease / backpressure / dead-letter for vuln in Phase 1–2; freshness is artifact-enumeration-based and filesystem-dependent. Acceptable at daily/weekly cadence, but weaker than a single ledger query. +- Coverage accounting is not first-class: CATALOG has no `vuln_enabled` field and there is no vuln `BREACH_COUNTER` slot; an artifact + catalog walker is net-new. +- Durable vuln verdict/disposition stays H4-gated — `set_finding_disposition` raises `ValueError` on missing `FINDING_STATE`. This must be communicated as detection + freshness only, never as triage/suppression parity with secrets. +- A later migration to durable is still required when a trigger fires (see Rollout seam). This is deferral, not avoidance. +- **Silent no-scan risk:** with semgrep absent and global install forbidden, a timer that cannot find the binary emits no artifact every tick — indistinguishable from "never scheduled" under a no-ledger freshness signal. Phase 3's pre-flight check mitigates this by surfacing a distinct `binary-missing` outcome. + +## Alternatives considered + +### Pure Option A — JSONL-scheduled, no durable seam (rejected) + +Rejected because it would leave the durable migration undocumented and risk being read as a dead end. Mitigated by adopting the `ScanRunSummary` index seam in Phase 2, which converts pure-A into the hybrid decision above. + +### Pure Option B — durable-queue now (rejected for immediate scope; adopted as deferred Phase H) + +Rejected for the immediate scope because: + +- It trips `storage-projection-or-schema-migration-required` and risks `existing-secret-default-behavior-change` — out of autonomous / branch-local scope. +- Its SLA-observability value is mistimed: vuln staleness visibility is only needed once H3 sets real enforce thresholds. +- It is "same contracts, new worker binding," not a drop-in: `complete_processed_job` / `CommitScanner.scan` are secret-`Finding`-typed, and `make_default_scanner()` hardwires `GitleaksScanner` (`scan_worker.py:259`). A durable vuln path needs a new VULN_FINDING item shape (or a ledger-only-durable + artifact-bodied cut) and a separate vuln-worker binding with a `SemgrepCommitScanner` adapter. +- Long-running semgrep jobs on the shared table would skew the gitleaks-tuned backpressure (>1000) and `queue-status` counts; isolating via a separate vuln table weakens the one-topology thesis and adds env-wiring work. + +Adopted as the **deferred Phase H destination**, gated on H1–H3. + +### Reusing the existing `scan-worker` via a `--scan-type` flag (rejected) + +Rejected: `make_default_scanner()` is single-scanner and the completion path is secret-typed; in-worker dispatch on `job.job_type` raises blast radius on the live secret path. The durable phase uses a separate vuln-worker binding instead. + +## Rollout seam — how to evolve to durable later + +JSONL-first is explicitly **not** a dead end. The migration boundary is named and seam-ready today. + +### The seam + +`ScanRunSummary` is an existing durable item kind that already carries the two fields the durable upgrade needs: + +- `category: str = "secret"` (`items.py:75`) — set to `"code-vuln"` to discriminate a vuln scan run. +- `artifact_uri: str | None = None` (`items.py:78`, serialized as `artifactUri` at `items.py:299`) — points at the per-`scan_run_id` JSONL artifact. + +Phase 2 may write `ScanRunSummary(category="code-vuln", artifact_uri=)`. This is **index-only durability**: the durable table learns *that* a vuln scan happened, for *which* repo/run, and *where* the artifact lives — with no new item kind and no finding-schema migration. Freshness and coverage can then be computed either by walking artifacts (Phase 2 evaluator) or by querying these summary rows. + +### The upgrade-in-place path (Phase H) + +When a migration trigger fires, the durable upgrade is bounded and pre-scoped, not a rewrite: + +1. **`JOB_TYPE_VULN`** — add as a free-form `job_type` string. `verify_queue.py` already proved a third value round-trips the `SCAN_JOB` shape with zero new GSI/attribute (`JOB_TYPE_VERIFY`, priority 950). Vuln slots at priority **~920** (between baseline=900 and verify=950) so semgrep never starves per-commit secret detection. +2. **`REPO_HEALTH_VULN_ATTR = "lastSuccessfulVulnScanAt"`** — add a third branch to `repo_health_freshness_attr(job_type)` (`items.py:129`). The attribute-scoped conditional `UpdateItem` makes this collision-free with `lastSuccessfulIncrementalAt` / `lastSuccessfulFullScanAt`; a test must assert a vuln advance leaves the incremental/baseline fields untouched. +3. **`SCAN_LEDGER` idempotency** — reuse the existing dedup pattern with a `vuln_baseline` sentinel analogous to `BASELINE_COMMIT_SENTINEL` (keyed `repo_id` + sentinel + semgrep scanner-tuple). Zero schema cost; gives idempotent re-enqueue for periodic vuln passes. +4. **A separate vuln-worker binding** with a `SemgrepCommitScanner` adapter — does **not** mutate the secret hot path or `make_default_scanner()`; persists ledger + `REPO_HEALTH` durably while keeping `VulnerabilityFinding` bodies as per-`scan_run_id` artifacts, upgrading the Phase 2 `ScanRunSummary` index in place. +5. **Defer durable disposition to H4** — `VulnerabilityFinding` has zero `FINDING_STATE` rows and `set_finding_disposition` raises on missing state. Durable triage/suppression parity is the H4 stop-condition (`storage-projection-or-schema-migration-required`) and is out of scope even for the Phase H durable scheduling/dedup/freshness work. + +### Migration trigger + +Promote JSONL → durable (Phase H) when **any** of: + +- **(a)** H3 commits enforce thresholds and a per-repo vuln-freshness SLA is required; +- **(b)** artifact-enumeration freshness proves unreliable (lost artifacts silently regress a repo to "never scanned"); +- **(c)** multi-worker fan-out / backpressure / retry guarantees become necessary; +- **(d)** durable vuln disposition (H4) is unblocked. + +Until a trigger fires, JSONL-first is the correct, governance-compliant steady state. + +## Validation (branch-local, no host) + +- `uv run pytest` +- `uv run python -m governance.render --validate` / `--check` +- `uv run python -m governance.public_safety --diff origin/main...HEAD` +- `uv run python -m governance.vuln_parity_slo --check` (kept report-only — thresholds file absent) +- `uv run python -m governance.autopilot_gate --base origin/main` +- New: `VULN_PERIODIC_UNITS` block in `tests/test_vuln_rollout_systemd_units.py`; artifact-no-clobber test; semgrep pre-flight outcome test. + +No `ssh`, no `systemctl enable`, no edits under host `~/.config/systemd/user`. diff --git a/docs/runbooks/vuln-rollout-enable-checklist.md b/docs/runbooks/vuln-rollout-enable-checklist.md new file mode 100644 index 0000000..6da1d2c --- /dev/null +++ b/docs/runbooks/vuln-rollout-enable-checklist.md @@ -0,0 +1,417 @@ +# Runbook: Vulnerability/SAST continuous-scanning — production enable checklist + +- Status: Draft (branch-local). **NOT yet executable.** +- Date: 2026-06-21 +- Scope: single-owner production enable on host `ragflow-ubuntu` of the JSONL-scheduled + vuln rollout drafted in worktree `.worktrees/vuln-rollout-prep`. +- Related ADR: [`docs/adr/20260621-vuln-continuous-scanning-rollout.md`](../adr/20260621-vuln-continuous-scanning-rollout.md) +- Drafted units: + - `deploy/systemd/user/security-scanner-personal-vuln-scan.service` + - `deploy/systemd/user/security-scanner-personal-vuln-scan.timer` + - `deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.service` + - `deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.timer` + +> **READ THIS FIRST.** Every `systemctl --user enable`/`start` and every host-side +> filesystem mutation in this document is a **SINGLE-OWNER** step. Do **not** run any +> of them now. They are recorded here so that the single owner can execute them later, +> in order, after the preconditions below are all green. At the time of writing: +> +> - `semgrep` is **not installed** on the host and a global install is **forbidden**. +> - The host checkout is **behind `origin/main`** and is missing both the PR #59 vuln +> substrate and this rollout. +> - The Phase 1/Phase 2 code changes that the drafted units invoke +> (`scan-vuln --from-catalog ...`, `vuln-freshness-eval ...`) **do not exist yet**. +> +> Because of the last point, enabling these units today would only produce argparse +> failures every tick. This checklist assumes the "Required code changes before enable" +> section at the bottom has already landed and reached the host. + +--- + +## 0. Roles and guardrails + +- **Single owner only.** No one other than the single owner performs `git pull` / `git + reset` / `git checkout` on the host, `systemctl --user enable|disable|start|stop`, or + edits under `~/.config/systemd/user` on the host. +- Do **not** `ssh` into `ragflow-ubuntu` as part of any automated/agent session for this + rollout. All steps below are for an interactive single-owner shell on the host. +- A separate live **secret** continuous-scanning topology + (`baseline` / `freshness-eval` / `incr-poll` / `lease-reaper` / `scan-worker@`) is + already running on this host. **Do not disturb it.** None of the steps below touch the + secret units, the secret timers, or the `security_scanner_personal` table contents. +- The vuln plane is **JSONL-only** by design. These units set + `SECURITY_SCANNER_STORAGE_BACKEND=jsonl` and write artifacts under the cache/state + dirs — they do **not** write vuln findings into the DynamoDB-compatible table. + +--- + +## 1. Preconditions (verify ALL before any enable) + +### 1.1 Host checkout must advance from `66aa165` to a commit containing PR #59 + this rollout + +The host checkout was last seen at `66aa165` (PR #54), which predates the PR #59 vuln +substrate. It must be advanced to a commit that contains **both**: + +1. The PR #59 vuln substrate (M1–M3 commits `305de47`, `c1716e8`, `75550d0`), and +2. This rollout (the four drafted vuln units + the Phase 1/Phase 2 CLI subcommands). + +The advance is a **single-owner** action. **No one but the single owner runs +`git pull` / `git reset` / `git checkout` on the host.** + +Verify (read-only; safe for anyone to run): + +```bash +# On the host, in the checkout dir (~/security-scanner): +git -C ~/security-scanner rev-parse HEAD +git -C ~/security-scanner log --oneline -1 + +# The PR #59 substrate commits MUST be ancestors of HEAD: +for c in 305de47 c1716e8 75550d0; do + git -C ~/security-scanner merge-base --is-ancestor "$c" HEAD \ + && echo "OK $c is in HEAD" \ + || echo "FAIL $c is NOT in HEAD — host is behind; do not enable"; +done + +# The drafted vuln units MUST be present in the checkout: +ls -1 ~/security-scanner/deploy/systemd/user/security-scanner-personal-vuln-*.service \ + ~/security-scanner/deploy/systemd/user/security-scanner-personal-vuln-*.timer +``` + +If any `merge-base` check prints `FAIL`, or any unit file is missing, **STOP**. The host +is not advanced; the single owner must fast-forward the checkout first (and only the +single owner may do so). + +### 1.2 Code-change preconditions present + +The drafted `ExecStart` lines invoke subcommands/flags that the substrate does **not** +ship today. Confirm they exist before enabling: + +```bash +cd ~/security-scanner +uv run security-scanner scan-vuln --help # must accept --from-catalog, --artifact-dir, --semgrep-binary, --notification-log +uv run security-scanner vuln-freshness-eval --help # subcommand must exist (artifact-walking evaluator) +``` + +If either `--help` errors with "invalid choice" / "unrecognized arguments", **STOP** — +the Phase 1/Phase 2 code changes have not reached the host. See "Required code changes +before enable". + +### 1.3 semgrep binary present at the path the unit expects + +The vuln-scan unit hard-codes `--semgrep-binary %h/.local/bin/semgrep`. A global install +is forbidden, so the binary must be staged at that per-user path (or the unit's +`--semgrep-binary` must be edited to the chosen path before install). + +```bash +ls -l ~/.local/bin/semgrep && ~/.local/bin/semgrep --version +``` + +If absent, **STOP**. Arrange a pinned per-user binary or a container shim first +(single-owner step). Do **not** `pip install semgrep` globally. A missing binary makes +every tick a no-op that is indistinguishable from "clean" under the artifact-only +freshness signal — see Risks in the ADR. + +### 1.4 Environment file present and well-formed + +```bash +test -f ~/.config/security-scanner/personal-prod.env && echo "env file present" +# Required keys for the vuln units (subset of the secret env file): +grep -E '^(GH_TOKEN|SECURITY_SCANNER_STORAGE_BACKEND|SECURITY_SCANNER_CACHE_ROOT)=' \ + ~/.config/security-scanner/personal-prod.env +``` + +Note: the units pin `SECURITY_SCANNER_STORAGE_BACKEND=jsonl` and +`SECURITY_SCANNER_CACHE_ROOT=...` via `Environment=` directives, so they override the env +file for those two keys. `GH_TOKEN` (for catalog repo fetch) comes from the env file. +The vuln units intentionally do **not** set `SECURITY_SCANNER_DYNAMO_*` (JSONL-only). + +### 1.5 State/artifact directories will resolve + +The units write to `%h/.local/state/security-scanner/...` and +`%h/.cache/security-scanner-personal/repos`. systemd `--user` creates `%h`-rooted dirs on +first write, but confirm the parents are writable: + +```bash +mkdir -p ~/.local/state/security-scanner ~/.local/state/security-scanner/vuln-artifacts +mkdir -p ~/.cache/security-scanner-personal/repos +``` + +### 1.6 Secret topology is healthy and will not be touched + +```bash +systemctl --user list-timers 'security-scanner-personal-*' --all +# Confirm baseline/freshness-eval/incr-poll/lease-reaper are present and not failing. +systemctl --user is-active securityscanner.slice +``` + +If the secret topology is degraded, resolve that **out of band** first. This rollout adds +units to the **same `securityscanner.slice`** (CPUQuota=150%, MemoryMax=3G, +TasksMax=1024); a long semgrep run shares that budget with secret scanning. Confirm there +is headroom before adding load. + +--- + +## 2. Artifact install steps (SINGLE-OWNER) + +Copy the drafted unit files into the user systemd directory and reload. This does **not** +enable or start anything. + +```bash +# SINGLE-OWNER. Run from the advanced host checkout (~/security-scanner). +install -m 0644 \ + deploy/systemd/user/security-scanner-personal-vuln-scan.service \ + deploy/systemd/user/security-scanner-personal-vuln-scan.timer \ + deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.service \ + deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.timer \ + ~/.config/systemd/user/ + +systemctl --user daemon-reload + +# Verify systemd parses the units cleanly (no enable yet): +systemctl --user cat security-scanner-personal-vuln-scan.service +systemd-analyze --user verify ~/.config/systemd/user/security-scanner-personal-vuln-scan.service || true +systemd-analyze --user verify ~/.config/systemd/user/security-scanner-personal-vuln-freshness-eval.service || true +``` + +> The `securityscanner.slice` unit is already installed for the secret topology; do not +> reinstall it. The vuln units reference it by `Slice=securityscanner.slice` and inherit +> its caps. + +--- + +## 3. Enable / start commands (SINGLE-OWNER — DO NOT RUN NOW) + +> **These are the exact commands the single owner runs to go live. They are listed for +> reference only. DO NOT run them as part of any current session.** + +Enable the **timers** (not the `.service` units; the services are `Type=oneshot` driven +by their timers). Start the scan timer first; let at least one scan cycle complete and be +verified (Section 5) before enabling the freshness evaluator. + +```bash +# SINGLE-OWNER. Step 3a — scan timer. +systemctl --user enable --now security-scanner-personal-vuln-scan.timer + +# (Optional) trigger one immediate scan cycle for the first-run verification without +# waiting for the 03:30 OnCalendar slot: +systemctl --user start security-scanner-personal-vuln-scan.service + +# SINGLE-OWNER. Step 3b — freshness/coverage evaluator timer (enable AFTER 3a verified). +systemctl --user enable --now security-scanner-personal-vuln-freshness-eval.timer +``` + +Cadence (informational — set in the timer drafts, tune during H-track calibration): + +| Unit | Cadence (`OnCalendar`) | Notes | +| ----------------------------------------------------- | ---------------------- | --------------------------------------- | +| `security-scanner-personal-vuln-scan.timer` | `*-*-* 03:30:00` daily | LONG cadence; off the 04:00 secret slot | +| `security-scanner-personal-vuln-freshness-eval.timer` | `*-*-* 00/6:00:00` | every 6h; looser than secret 10-min eval | + +--- + +## 4. DynamoDB / table / env changes (DEFERRED — not part of this enable) + +**No DynamoDB or `security_scanner_personal` table change is required for this rollout.** +The vuln plane is JSONL-only: + +- No new entity type, axis, or item class for `VULN_FINDING` is created in the table. +- No `job_type=vuln` `SCAN_JOB`, no vuln `SCAN_LEDGER`, no `REPO_HEALTH` vuln attribute. +- No new GSI. + +**No new env keys are required.** The vuln units reuse a strict subset of the existing +`personal-prod.env` keys (`GH_TOKEN`, `SECURITY_SCANNER_STORAGE_BACKEND`, +`SECURITY_SCANNER_CACHE_ROOT`) and add two unit-local `Environment=` pins +(`SECURITY_SCANNER_STORAGE_BACKEND=jsonl`, `SECURITY_SCANNER_CACHE_ROOT=...`). They +deliberately omit `SECURITY_SCANNER_DYNAMO_ENDPOINT` / `_TABLE` / `_AWS_REGION`. + +The following are **explicitly deferred to the human-gated H-track** (Phase H of the ADR) +and must NOT be done as part of this JSONL enable: + +- **Durable vuln scheduling/dedup/freshness.** Adding `JOB_TYPE_VULN`, a + `REPO_HEALTH` `lastSuccessfulVulnScanAt` attribute (a third branch in + `repo_health_freshness_attr`), a vuln-enqueue runtime over `read_all_catalog_entries` + with `SCAN_LEDGER` idempotency, and a separate vuln-worker binding with a + `SemgrepCommitScanner` adapter. This is a **storage projection / schema migration** on + the live shared table and trips the `storage-projection-or-schema-migration-required` + stop-condition — human PR only. +- **Durable vuln verdict/disposition (H4).** `VulnerabilityFinding` has zero + `FINDING_STATE` rows and `set_finding_disposition` raises on missing state. Requires a + storage projection — H4-gated. +- **Parity SLO enforce thresholds.** Committing `governance/vuln_parity_slo_thresholds.yml` + (so the SLO gate flips from report-only to enforce) requires a real CodeQL baseline + (H1/H2) — human PR only. **Do not create that file as part of this enable.** + +When/if Phase H lands, that work will arrive with its own runbook and its own table-aware +units; this checklist covers the JSONL phase only. + +--- + +## 5. Verification (after each enable step) + +### 5.1 Timers are scheduled + +```bash +systemctl --user list-timers 'security-scanner-personal-vuln-*' --all +# Expect both vuln timers listed with sane NEXT/LEFT columns and no PAST-due backlog churn. +``` + +### 5.2 First scan run — log check (run after Step 3a / the optional manual start) + +```bash +# Unit-level status and the last run's journal: +systemctl --user status security-scanner-personal-vuln-scan.service --no-pager +journalctl --user -u security-scanner-personal-vuln-scan.service --since "-1h" --no-pager + +# Confirm the oneshot exited 0 (look for: Deactivated successfully / code=exited status=0). +# A non-zero exit with "No such file or directory: .../semgrep" means the binary +# precondition (1.3) regressed — do not treat as a clean scan. + +# Artifacts were produced (per-run, no-clobber; one file per included repo): +ls -lt ~/.local/state/security-scanner/vuln-artifacts/ | head +# Scan-run notification log appended: +tail -n 20 ~/.local/state/security-scanner/personal-vuln-scan.log.jsonl +``` + +A healthy first run produces: oneshot `status=0`, at least one `VULN_FINDING` JSONL +artifact under `vuln-artifacts/`, and a `personal-vuln-scan.log.jsonl` summary record. +Zero artifacts with a `status=0` is **suspicious** — confirm semgrep actually ran and the +catalog had INCLUDED repos (see 1.3 / binary-missing risk). + +### 5.3 Freshness / coverage gate (run after Step 3b) + +```bash +journalctl --user -u security-scanner-personal-vuln-freshness-eval.service --since "-1h" --no-pager +tail -n 20 ~/.local/state/security-scanner/personal-vuln-alerts.log.jsonl +``` + +Confirm the evaluator walked the artifacts + the INCLUDED CATALOG set and reported +per-repo last-vuln-scan recency and a coverage gap. A spurious staleness/coverage alert +on the very first cycle (before any scan has run) is expected; it should clear after 5.2 +succeeds and the next evaluator tick runs. + +### 5.4 Parity SLO gate (report-only) + +```bash +cd ~/security-scanner +uv run python -m governance.vuln_parity_slo --check +# Expect: report-only mode (governance/vuln_parity_slo_thresholds.yml is intentionally +# ABSENT). It must NOT block. If it reports "enforce", the thresholds file leaked into +# the host checkout — investigate before continuing (enforce is H3, not this phase). +``` + +### 5.5 Recall / parity regression gate + +```bash +cd ~/security-scanner +uv run pytest tests/test_vulnerability_synthetic_regression_gate.py -q +# Synthetic corpus gate: recall >= 0.99, precision >= 0.90; non-vacuous (red-canary +# tests included). Must pass on the host checkout that is being enabled. +``` + +(Optionally run the full vuln test slice from the ADR/test manifest to confirm the host +checkout matches the validated branch: `uv run pytest -q`.) + +--- + +## 6. Rollback / abort criteria and disable commands + +### 6.1 Abort criteria (stop the rollout, do not proceed to the next enable step) + +Abort immediately if any of the following occur: + +- **Secret topology impact.** Any secret unit (`baseline` / `freshness-eval` / + `incr-poll` / `lease-reaper` / `scan-worker@`) enters `failed`, or the shared + `securityscanner.slice` shows MemoryMax/TasksMax pressure traceable to the vuln scan + (a long semgrep run starving secret scanning). The secret plane has priority. +- **Repeated oneshot failures.** `security-scanner-personal-vuln-scan.service` exits + non-zero on consecutive cycles (argparse failure, semgrep-not-found, fetch/auth + failure). +- **Silent no-scan.** `status=0` but zero artifacts produced across multiple cycles + (likely binary-missing or empty catalog masquerading as "clean"). +- **Artifact clobber / loss.** Overlapping runs overwrite each other's artifacts (the + Phase 1 no-clobber guard regressed), or artifacts vanish unexpectedly. +- **Unexpected durable writes.** Any vuln write appears in the `security_scanner_personal` + table, or the parity SLO gate flips to enforce — both mean out-of-scope (H-track) + artifacts leaked onto the host. +- **Public-safety / redaction concern.** Any raw path, secret, host, or code snippet + appears in a vuln artifact or notification log. + +### 6.2 Disable commands (SINGLE-OWNER) + +```bash +# SINGLE-OWNER. Stop and disable the vuln timers (and any in-flight oneshot). +systemctl --user disable --now security-scanner-personal-vuln-freshness-eval.timer +systemctl --user disable --now security-scanner-personal-vuln-scan.timer +systemctl --user stop security-scanner-personal-vuln-freshness-eval.service 2>/dev/null || true +systemctl --user stop security-scanner-personal-vuln-scan.service 2>/dev/null || true + +# Confirm nothing vuln-related remains active or scheduled: +systemctl --user list-timers 'security-scanner-personal-vuln-*' --all +systemctl --user --type=service --state=running list-units 'security-scanner-personal-vuln-*' + +# Full removal (optional — only if backing out the rollout entirely): +rm -f ~/.config/systemd/user/security-scanner-personal-vuln-scan.service \ + ~/.config/systemd/user/security-scanner-personal-vuln-scan.timer \ + ~/.config/systemd/user/security-scanner-personal-vuln-freshness-eval.service \ + ~/.config/systemd/user/security-scanner-personal-vuln-freshness-eval.timer +systemctl --user daemon-reload +``` + +Rollback is clean and self-contained: disabling the timers stops all future ticks, and +removing the units leaves **no residue** in the DynamoDB table (the vuln plane never +wrote to it). Already-emitted JSONL artifacts under `~/.local/state/security-scanner/` +are harmless to leave in place; delete them only if a redaction concern was found. + +> Do **not** disable, stop, or remove any secret unit, the `securityscanner.slice`, or +> any secret timer as part of vuln rollback. + +--- + +## 7. Required code changes before enable + +This JSONL-scheduled rollout depends on Phase 1/Phase 2 CLI work that is **not yet +shipped**. The drafted units encode the intended entrypoints; until these land, the units +fail argparse and must not be enabled. Summary of the decision and the open followups: + +**Decision (from the ADR):** hybrid-phased. Ship JSONL-scheduled vuln scanning now; +defer durable queue/ledger integration to the human-gated H-track. Recommended primary +option for this enable: **JSONL-scheduled** (governance-permitted, zero shared-table +blast radius). + +**Followups required before this checklist is executable:** + +1. **Phase 1 — `scan-vuln` catalog-driven, no-clobber form.** Today `scan-vuln` accepts + only `--root DIR` (single checkout) and `VulnerabilityJsonlStore.write_all` opens mode + `'w'` (clobbers). Add: `--from-catalog` (iterate `read_all_catalog_entries` INCLUDED + repos via `fetch_or_clone`), `--artifact-dir` (write one per-repo artifact keyed by + `scan_run_id`/`repo_id`, no clobber + oneshot single-instance guard), + `--semgrep-binary`, and `--notification-log`. Must inject the fetcher and + `SemgrepCompatibleRunner` (`FakeRunner` in tests). Must **not** enqueue `SCAN_JOB`s. +2. **Phase 2 — `vuln-freshness-eval` subcommand.** Artifact-walking evaluator: walk the + per-run vuln JSONL artifacts (embedded `scanned_at`/`scan_run_id`) + the INCLUDED + CATALOG set, report per-repo last-vuln-scan recency and coverage gap. No live table + writes. Optionally write `ScanRunSummary(category='code-vuln', artifact_uri=...)` via + the existing `items.py` seam for index-only durability (no new item kind). +3. **Phase 3 — semgrep pre-flight + governance wiring.** Add a binary pre-flight so a + missing binary surfaces a distinguishable "binary-missing" outcome vs "scanned-clean" + (cover `FileNotFoundError -> SemgrepExecutionError`). Wire + `governance.vuln_parity_slo --check` into `ci.yml` as an **advisory** step; keep + `governance/vuln_parity_slo_thresholds.yml` **absent** (report-only). +4. **Tests.** Add a `VULN_PERIODIC_UNITS` parametrize block to + `tests/test_personal_prod_systemd_units.py` (mirroring `PERIODIC_UNITS`): assert + `Type=oneshot`, `Slice=securityscanner.slice`, `EnvironmentFile`, `uv run + security-scanner` in `ExecStart`, JSONL backend (no `DYNAMO_*`), `CACHE_ROOT` + isolation, no forbidden absolute machine-local paths, `WantedBy=default.target` + (service) / `WantedBy=timers.target` (timer). Canonical path: + `tests/test_vuln_rollout_systemd_units.py` if kept separate. + +**Deferred to the human-gated H-track (NOT before this enable):** durable +queue/ledger/`REPO_HEALTH` integration (`JOB_TYPE_VULN`, `lastSuccessfulVulnScanAt`, +vuln-enqueue + `SCAN_LEDGER`, separate vuln-worker), durable vuln disposition (H4), and +parity SLO enforce thresholds (H1–H3). These are out of scope for the JSONL enable and +require their own human PRs and a follow-on runbook. + +**Final single-owner sequence:** land followups 1–4 → merge → advance host checkout +(single owner) → stage semgrep binary → run Section 1 preconditions → Section 2 install +→ Section 3a enable scan timer → Section 5 verify → Section 3b enable freshness timer → +Section 5 verify. Abort per Section 6 on any criterion. diff --git a/tests/test_vuln_rollout_systemd_units.py b/tests/test_vuln_rollout_systemd_units.py new file mode 100644 index 0000000..0cdba27 --- /dev/null +++ b/tests/test_vuln_rollout_systemd_units.py @@ -0,0 +1,219 @@ +"""Structure checks for the vuln-rollout (SAST) personal-prod user systemd drafts. + +These tests are pure file parsing: they read the drafted unit files under +``deploy/systemd/user`` with ``configparser`` and assert structural correctness +and parity with the secret personal-prod unit conventions. They do NOT require a +real semgrep binary, do NOT import any runtime module, and do NOT touch the host. + +The vuln units intentionally diverge from the secret units in two ways that are +asserted here as first-class invariants: + +1. ``SECURITY_SCANNER_STORAGE_BACKEND=jsonl`` with NO DynamoDB env block. The + code-vuln plane is JSONL-only (``report``/``gate``/``evaluate --category + code-vuln`` reject ``--storage-backend dynamodb`` and ``VulnerabilityJsonlStore`` + has no DynamoDB schema), so the vuln units must never point at the + ``security_scanner_personal`` table. +2. A long scan cadence (daily / multi-hour) rather than the 5-min incr-poll or + 10-min freshness cadence, because semgrep-compatible SAST is a full + directory-tree HEAD scan. + +The units are DRAFTS (not for enable); these tests guard their shape so that a +later single-owner review and Phase 1/2 code changes have a stable contract. +""" + +from __future__ import annotations + +import configparser +from pathlib import Path + +import pytest + +USER_SYSTEMD_DIR = Path(__file__).resolve().parents[1] / "deploy" / "systemd" / "user" + +SLICE = USER_SYSTEMD_DIR / "securityscanner.slice" + +# (service_name, timer_name, on_calendar) for each drafted vuln periodic unit. +VULN_PERIODIC_UNITS = { + "vuln-scan": ( + "security-scanner-personal-vuln-scan.service", + "security-scanner-personal-vuln-scan.timer", + "*-*-* 03:30:00", + "scan-vuln", + ), + "vuln-freshness-eval": ( + "security-scanner-personal-vuln-freshness-eval.service", + "security-scanner-personal-vuln-freshness-eval.timer", + "*-*-* 00/6:00:00", + "vuln-freshness-eval", + ), +} + +VULN_SERVICE_FILES = [service for service, _, _, _ in VULN_PERIODIC_UNITS.values()] +VULN_TIMER_FILES = [timer for _, timer, _, _ in VULN_PERIODIC_UNITS.values()] + +# The secret baseline service is the structural template the vuln units mirror. +SECRET_BASELINE_SERVICE = USER_SYSTEMD_DIR / "security-scanner-personal-baseline.service" + + +def _parse_unit(path: Path) -> configparser.ConfigParser: + parser = configparser.ConfigParser(strict=False, interpolation=None) + parser.optionxform = str # type: ignore[assignment] + parser.read_string(path.read_text(encoding="utf-8")) + return parser + + +def test_all_vuln_unit_files_exist() -> None: + for name in (*VULN_SERVICE_FILES, *VULN_TIMER_FILES): + path = USER_SYSTEMD_DIR / name + assert path.is_file(), f"missing drafted vuln unit: {name}" + + +@pytest.mark.parametrize( + "service_name,timer_name,calendar,subcommand", + VULN_PERIODIC_UNITS.values(), + ids=list(VULN_PERIODIC_UNITS.keys()), +) +def test_vuln_service_mirrors_personal_oneshot_conventions( + service_name: str, timer_name: str, calendar: str, subcommand: str +) -> None: + parser = _parse_unit(USER_SYSTEMD_DIR / service_name) + + # Required sections present. + assert parser.has_section("Unit") + assert parser.has_section("Service") + assert parser.has_section("Install") + + service = dict(parser.items("Service")) + # Same oneshot / slice / scheduling-class conventions as the secret units. + assert service["Type"] == "oneshot" + assert service["Slice"] == "securityscanner.slice" + assert service["IOSchedulingClass"] == "idle" + # Vuln runs are deliberately lower priority (heavier) than the secret units. + assert int(service["Nice"]) >= 15 + assert service["EnvironmentFile"] == ( + "-%h/.config/security-scanner/personal-prod.env" + ) + assert service["WorkingDirectory"] == "%h/security-scanner" + + # ExecStart calls uv + the security-scanner CLI with the intended subcommand. + exec_start = service["ExecStart"] + assert "uv run security-scanner" in exec_start + assert subcommand in exec_start + + # User-level install target. + assert parser.get("Install", "WantedBy") == "default.target" + + +@pytest.mark.parametrize( + "service_name,timer_name,calendar,subcommand", + VULN_PERIODIC_UNITS.values(), + ids=list(VULN_PERIODIC_UNITS.keys()), +) +def test_vuln_timer_is_long_cadence_and_wired_to_service( + service_name: str, timer_name: str, calendar: str, subcommand: str +) -> None: + parser = _parse_unit(USER_SYSTEMD_DIR / timer_name) + + assert parser.has_section("Timer") + assert parser.has_section("Install") + + timer = dict(parser.items("Timer")) + # The timer must drive its paired service exactly. + assert timer["Unit"] == service_name + assert timer["OnCalendar"] == calendar + assert timer["Persistent"] == "true" + # Vuln cadence must NOT reuse the 5-min / 10-min secret cadences. + assert timer["OnCalendar"] not in {"*:0/5:00", "*:0/10:00"} + + assert parser.get("Install", "WantedBy") == "timers.target" + + +@pytest.mark.parametrize("service_name", VULN_SERVICE_FILES) +def test_vuln_services_are_jsonl_backed_not_dynamodb(service_name: str) -> None: + """code-vuln plane is JSONL-only; vuln units must never touch the table.""" + text = (USER_SYSTEMD_DIR / service_name).read_text(encoding="utf-8") + assert "SECURITY_SCANNER_STORAGE_BACKEND=jsonl" in text + # The DynamoDB env block that the secret units carry must be absent here. + assert "SECURITY_SCANNER_STORAGE_BACKEND=dynamodb" not in text + assert "SECURITY_SCANNER_DYNAMO_ENDPOINT" not in text + # The personal table must never be configured for the vuln plane. (The bare + # table name may appear in an explanatory comment; only the live directive is + # forbidden.) + assert "SECURITY_SCANNER_DYNAMO_TABLE" not in text + assert "SECURITY_SCANNER_DYNAMO_TABLE=security_scanner_personal" not in text + + +@pytest.mark.parametrize("service_name", VULN_SERVICE_FILES) +def test_vuln_services_isolate_clone_cache(service_name: str) -> None: + text = (USER_SYSTEMD_DIR / service_name).read_text(encoding="utf-8") + assert ( + "SECURITY_SCANNER_CACHE_ROOT=%h/.cache/security-scanner-personal/repos" in text + ) + + +@pytest.mark.parametrize("service_name", VULN_SERVICE_FILES) +def test_vuln_services_have_no_docker_or_execstartpre_mutation( + service_name: str, +) -> None: + text = (USER_SYSTEMD_DIR / service_name).read_text(encoding="utf-8") + assert "docker compose" not in text + assert "docker run" not in text + assert "ExecStartPre" not in text + + +@pytest.mark.parametrize( + "path", + [USER_SYSTEMD_DIR / name for name in (*VULN_SERVICE_FILES, *VULN_TIMER_FILES)], + ids=[*VULN_SERVICE_FILES, *VULN_TIMER_FILES], +) +def test_vuln_artifacts_avoid_machine_local_paths_and_accounts(path: Path) -> None: + text = path.read_text(encoding="utf-8") + forbidden = ("/home/", "/Users/", "/srv/", "/var/", "pureliture") + for marker in forbidden: + assert marker not in text, f"{path.name} contains forbidden marker {marker!r}" + + +def test_vuln_units_share_the_secret_resource_slice() -> None: + """The shared slice must exist and stay under the same aggregate caps.""" + parser = _parse_unit(SLICE) + slice_section = dict(parser.items("Slice")) + assert slice_section["CPUQuota"] == "150%" + assert slice_section["MemoryMax"] == "3G" + assert int(slice_section["TasksMax"]) >= 1024 + assert slice_section["IOWeight"] == "100" + + # Every vuln service joins that same slice. + for service_name in VULN_SERVICE_FILES: + service = dict(_parse_unit(USER_SYSTEMD_DIR / service_name).items("Service")) + assert service["Slice"] == "securityscanner.slice" + + +def test_vuln_services_match_secret_baseline_env_and_workdir_conventions() -> None: + """Parity check: vuln services reuse the secret template's stable seams. + + Anything the secret baseline service pins (EnvironmentFile location, + WorkingDirectory, PATH prefix, cache-root isolation, oneshot type, slice, + idle IO class) must be identical on the vuln services so the only intended + drift is the storage backend and cadence. + """ + baseline = dict(_parse_unit(SECRET_BASELINE_SERVICE).items("Service")) + shared_keys = ( + "Type", + "Slice", + "IOSchedulingClass", + "WorkingDirectory", + "EnvironmentFile", + ) + for service_name in VULN_SERVICE_FILES: + service_path = USER_SYSTEMD_DIR / service_name + service = dict(_parse_unit(service_path).items("Service")) + for key in shared_keys: + assert service[key] == baseline[key], ( + f"{service_name} diverges from secret baseline on {key}: " + f"{service.get(key)!r} != {baseline[key]!r}" + ) + # Same PATH prefix convention (uv lives under %h/.local/bin). The unit + # carries several Environment= directives, so assert against raw text + # rather than configparser's last-wins value for the duplicate key. + text = service_path.read_text(encoding="utf-8") + assert "Environment=PATH=%h/.local/bin:" in text From f2247ba3e41501ae1ef7e309daddeb04f57d435c Mon Sep 17 00:00:00 2001 From: pureliture Date: Mon, 22 Jun 2026 00:17:22 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(vuln-rollout):=20catalog=20=EC=97=B0?= =?UTF-8?q?=EC=86=8D=20=EC=8A=A4=EC=BA=94=20+=20freshness=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=9C=BC=EB=A1=9C=20enable=20=EC=84=A0=EA=B2=B0=20?= =?UTF-8?q?=EC=B6=A9=EC=A1=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit draft unit이 참조하던 미존재 CLI를 실제 구현해 production enable 차단 요인 해소. - runtime/vulnerability_scan.py: run_catalog_vulnerability_scan — INCLUDED 카탈로그 read-only 반복, repo별 per-scan_run_id no-clobber JSONL 아티팩트, repo 단위 fetch/scan 오류 격리, semgrep preflight(binary-missing ≠ scanned-clean). - runtime/vulnerability_freshness.py: run_vuln_freshness_eval — 아티팩트 walk + 카탈로그 INCLUDED 집합으로 per-repo recency/coverage gap(테이블 write 없음, report-only). - cli/commands/vulnerability.py: scan-vuln --from-catalog/--artifact-dir 등 + vuln-freshness-eval 서브커맨드. exit 0/2/3(clean/error/preflight-fail). - deploy/systemd/user vuln-scan·freshness-eval.service: ExecStart를 실제 CLI에 맞춤, 카탈로그 read 위해 jsonl override 제거(EnvironmentFile의 dynamo 설정 사용). - tests: catalog scan/freshness/CLI 동작 테스트 추가, unit 테스트 갱신. proof: uv run pytest 1354 passed/4 skipped; fake-semgrep end-to-end로 SARIF→JSONL (redaction 적용) 1 finding 생성; preflight binary-missing 구분 확인. real semgrep·live dynamo 불필요. durable 스키마/secret seam/host 무변경. Co-Authored-By: Claude Opus 4.8 --- ...anner-personal-vuln-freshness-eval.service | 24 +- ...ecurity-scanner-personal-vuln-scan.service | 37 ++- .../cli/commands/vulnerability.py | 208 +++++++++++++- .../runtime/vulnerability_freshness.py | 143 ++++++++++ .../runtime/vulnerability_scan.py | 250 +++++++++++++++- tests/test_cli.py | 1 + tests/test_cli_scan_vuln_catalog.py | 267 ++++++++++++++++++ tests/test_vuln_catalog_scan.py | 243 ++++++++++++++++ tests/test_vuln_freshness_eval.py | 135 +++++++++ tests/test_vuln_rollout_systemd_units.py | 64 +++-- 10 files changed, 1320 insertions(+), 52 deletions(-) create mode 100644 src/security_scanner/runtime/vulnerability_freshness.py create mode 100644 tests/test_cli_scan_vuln_catalog.py create mode 100644 tests/test_vuln_catalog_scan.py create mode 100644 tests/test_vuln_freshness_eval.py diff --git a/deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.service b/deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.service index 355fd9e..fbb1c68 100644 --- a/deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.service +++ b/deploy/systemd/user/security-scanner-personal-vuln-freshness-eval.service @@ -2,18 +2,19 @@ Description=security-scanner personal code-vuln freshness/coverage eval Documentation=https://github.com/source-security-dev/security-scanner -# ROLLOUT DRAFT (not for enable). Authored under .worktrees/vuln-rollout-prep -# per the JSONL-scheduled vuln-rollout DECISION (Phase 2). Artifact-enumeration -# freshness: this evaluator walks the per-run vuln JSONL artifacts under -# SECURITY_SCANNER_CACHE_ROOT / the vuln-artifacts dir and the INCLUDED CATALOG -# set, then reports per-repo last-vuln-scan recency and coverage gap. It does -# NOT read or write vuln findings to the DynamoDB table; durable REPO_HEALTH / -# BREACH_COUNTER for vuln is H-track (human-gated) work, not this draft. +# ROLLOUT DRAFT (not for autonomous enable). Authored under +# .worktrees/vuln-rollout-prep per the catalog-driven vuln-rollout DECISION. +# The `vuln-freshness-eval` subcommand NOW EXISTS on this branch: it reads the +# INCLUDED org catalog (read-only), enumerates the per-run vuln JSONL artifacts +# under --artifact-dir, and reports per-repo last-vuln-scan recency plus a +# fresh/stale/never-scanned coverage rollup. It is a report-only observability +# timer, not a gate, and always exits 0. # -# REQUIRED CODE CHANGE (Phase 2): the `vuln-freshness-eval` subcommand does not -# exist yet. It is the artifact-walking evaluator described in the DECISION -# (Phase 2) and is recorded as a followup. Until it lands this ExecStart fails -# argparse; that is intentional for a draft. +# It does NOT read or write vuln findings to the durable table; durable +# REPO_HEALTH / BREACH_COUNTER for the vuln plane is separate human-gated work, +# not this draft. The catalog read uses the store provided via EnvironmentFile +# (the personal-prod env file supplies storage backend, endpoint, table, region, +# and cache root). [Service] Type=oneshot @@ -24,7 +25,6 @@ TasksMax=128 WorkingDirectory=%h/security-scanner EnvironmentFile=-%h/.config/security-scanner/personal-prod.env Environment=PATH=%h/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/bin -Environment=SECURITY_SCANNER_STORAGE_BACKEND=jsonl Environment=SECURITY_SCANNER_CACHE_ROOT=%h/.cache/security-scanner-personal/repos ExecStart=%h/.local/bin/uv run security-scanner vuln-freshness-eval \ --artifact-dir %h/.local/state/security-scanner/vuln-artifacts \ diff --git a/deploy/systemd/user/security-scanner-personal-vuln-scan.service b/deploy/systemd/user/security-scanner-personal-vuln-scan.service index 21668b9..accaa69 100644 --- a/deploy/systemd/user/security-scanner-personal-vuln-scan.service +++ b/deploy/systemd/user/security-scanner-personal-vuln-scan.service @@ -2,18 +2,23 @@ Description=security-scanner personal code-vuln (SAST) scheduled scan Documentation=https://github.com/source-security-dev/security-scanner -# ROLLOUT DRAFT (not for enable). Authored under .worktrees/vuln-rollout-prep -# per the JSONL-scheduled vuln-rollout DECISION (Phase 0). DO NOT `systemctl -# enable` this unit: the host checkout is behind origin/main, the catalog-driven -# scan-vuln entrypoint referenced below is a Phase 1 code change that does not -# exist yet, and semgrep is not installed on the host. See -# docs/runbooks/vuln-rollout-enable-checklist.md before any enable. +# ROLLOUT DRAFT (not for autonomous enable). Authored under +# .worktrees/vuln-rollout-prep per the catalog-driven vuln-rollout DECISION. +# The catalog-driven scan-vuln entrypoint NOW EXISTS on this branch: +# `scan-vuln --from-catalog` reads the INCLUDED org catalog (read-only) and +# writes one no-clobber per-repo/per-run JSONL artifact under --artifact-dir. # -# Storage divergence from the secret units: the code-vuln plane is JSONL-only. -# report/gate/evaluate --category code-vuln reject --storage-backend dynamodb, -# and VulnerabilityJsonlStore has no DynamoDB schema. This unit therefore sets -# SECURITY_SCANNER_STORAGE_BACKEND=jsonl and writes artifacts under -# SECURITY_SCANNER_CACHE_ROOT, not into the security_scanner_personal table. +# Remaining blockers before any `systemctl enable`: +# (a) the host checkout must contain THIS branch (origin/main may be behind); +# (b) a pinned semgrep-compatible binary must exist at %h/.local/bin/semgrep +# (the runtime preflight returns a distinct binary-missing exit otherwise); +# (c) this is NOT cleared for autonomous enable — single-owner review first. +# See docs/runbooks/vuln-rollout-enable-checklist.md before any enable. +# +# Catalog read uses the store provided via EnvironmentFile (the personal-prod +# env file supplies the storage backend, endpoint, table, region, cache root, +# and GH token). This unit does NOT write vuln findings to the durable table; +# per-repo artifacts live under --artifact-dir / the isolated cache root. [Service] Type=oneshot @@ -24,22 +29,14 @@ TasksMax=256 WorkingDirectory=%h/security-scanner EnvironmentFile=-%h/.config/security-scanner/personal-prod.env Environment=PATH=%h/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/bin -Environment=SECURITY_SCANNER_STORAGE_BACKEND=jsonl Environment=SECURITY_SCANNER_CACHE_ROOT=%h/.cache/security-scanner-personal/repos -# REQUIRED CODE CHANGE (Phase 1): `scan-vuln` today accepts only `--root DIR` -# (single local checkout) and writes a single artifact via write_all (mode 'w'). -# A catalog-driven, per-repo, no-clobber form does not exist yet. The flags -# below (--from-catalog, --artifact-dir, --semgrep-binary, --notification-log) -# describe the intended Phase 1 entrypoint and are recorded as a followup. Until -# that lands this ExecStart will fail argparse; that is intentional for a draft. ExecStart=%h/.local/bin/uv run security-scanner scan-vuln \ --from-catalog \ --artifact-dir %h/.local/state/security-scanner/vuln-artifacts \ --semgrep-binary %h/.local/bin/semgrep \ --semgrep-config auto \ --timeout-seconds 1800 \ - --path-policy redacted \ - --notification-log %h/.local/state/security-scanner/personal-vuln-scan.log.jsonl + --path-policy redacted [Install] WantedBy=default.target diff --git a/src/security_scanner/cli/commands/vulnerability.py b/src/security_scanner/cli/commands/vulnerability.py index 5088858..081ecf6 100644 --- a/src/security_scanner/cli/commands/vulnerability.py +++ b/src/security_scanner/cli/commands/vulnerability.py @@ -1,17 +1,66 @@ -"""SARIF-native code vulnerability subcommands.""" +"""SARIF-native code vulnerability subcommands. + +Besides the single-checkout ``import-sarif`` / ``scan-vuln`` paths, this module +owns the catalog-driven continuous scan (``scan-vuln --from-catalog``) and the +report-only ``vuln-freshness-eval`` observability timer. Both read the org +catalog from the DynamoDB-compatible store (read-only); neither writes vuln data +to the durable table. +""" from __future__ import annotations import argparse +import json +import secrets import sys +from datetime import datetime, timezone +from pathlib import Path +from security_scanner.cli._store import dynamodb_config_from_args +from security_scanner.runtime.vulnerability_freshness import ( + VulnFreshnessRequest, + run_vuln_freshness_eval, +) from security_scanner.runtime.vulnerability_scan import ( + CatalogScanRequest, ImportSarifRequest, ScanVulnerabilityRequest, + run_catalog_vulnerability_scan, run_import_sarif, run_vulnerability_scan, ) from security_scanner.scanners.semgrep_compatible import SemgrepExecutionError +from security_scanner.storage.factory import create_finding_store + + +def _add_dynamodb_args(parser: argparse.ArgumentParser) -> None: + """Add the three DynamoDB connection args used by catalog reads.""" + parser.add_argument( + "--dynamodb-endpoint-url", + metavar="URL", + default=None, + help="DynamoDB-compatible endpoint URL " + "(default: SECURITY_SCANNER_DYNAMO_ENDPOINT or http://localhost:4567).", + ) + parser.add_argument( + "--dynamodb-table", + metavar="NAME", + default=None, + help="DynamoDB-compatible table name " + "(default: SECURITY_SCANNER_DYNAMO_TABLE or SecurityScannerLocal).", + ) + parser.add_argument( + "--dynamodb-region", + metavar="REGION", + default=None, + help="DynamoDB-compatible region " + "(default: SECURITY_SCANNER_AWS_REGION or us-west-2).", + ) + + +def _generate_scan_run_id() -> str: + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + return f"{stamp}-{secrets.token_hex(3)}" def register(subparsers) -> None: @@ -94,8 +143,84 @@ def register(subparsers) -> None: "paths for private proof (default: redacted)." ), ) + scan_parser.add_argument( + "--from-catalog", + action="store_true", + help=( + "Scan every INCLUDED org catalog repo (read-only catalog) instead of " + "a single --root checkout, writing one no-clobber per-repo artifact." + ), + ) + scan_parser.add_argument( + "--artifact-dir", + metavar="DIR", + default=None, + help=( + "Root for per-repo/per-run JSONL artifacts " + "(required with --from-catalog; ignored otherwise)." + ), + ) + scan_parser.add_argument( + "--cache-root", + metavar="DIR", + default=None, + help="Override the clone/fetch cache root for catalog scans.", + ) + scan_parser.add_argument( + "--scan-run-id", + metavar="ID", + default=None, + help="Override the generated scan_run_id (catalog scans only).", + ) + _add_dynamodb_args(scan_parser) scan_parser.set_defaults(func=cmd_scan_vuln) + freshness_parser = subparsers.add_parser( + "vuln-freshness-eval", + help=( + "Report per-repo code-vuln scan freshness/coverage from on-disk " + "artifacts (read-only; report-only observability timer)." + ), + ) + freshness_parser.add_argument( + "--artifact-dir", + metavar="DIR", + required=True, + help="Root of per-repo/per-run JSONL artifacts to enumerate.", + ) + freshness_parser.add_argument( + "--scan-cadence-hours", + type=int, + default=24, + metavar="N", + help="Expected scan cadence in hours (default: 24).", + ) + freshness_parser.add_argument( + "--margin-hours", + type=int, + default=6, + metavar="N", + help="Grace margin in hours before a repo is stale (default: 6).", + ) + freshness_parser.add_argument( + "--backlog-alert-threshold", + type=int, + default=10, + metavar="N", + help=( + "Flag alerting when stale+never-scanned repos reach this count " + "(default: 10)." + ), + ) + freshness_parser.add_argument( + "--notification-log", + metavar="FILE", + default=None, + help="Append one JSON line summarizing this eval pass (parent dir created).", + ) + _add_dynamodb_args(freshness_parser) + freshness_parser.set_defaults(func=cmd_vuln_freshness_eval) + def cmd_import_sarif(args: argparse.Namespace) -> int: try: @@ -117,6 +242,8 @@ def cmd_import_sarif(args: argparse.Namespace) -> int: def cmd_scan_vuln(args: argparse.Namespace) -> int: + if args.from_catalog: + return _cmd_scan_vuln_catalog(args) try: result = run_vulnerability_scan( ScanVulnerabilityRequest( @@ -137,3 +264,82 @@ def cmd_scan_vuln(args: argparse.Namespace) -> int: f"code-vuln finding(s) -> {result.output_path}" ) return 0 + + +def _cmd_scan_vuln_catalog(args: argparse.Namespace) -> int: + if not args.artifact_dir: + print( + "error: scan-vuln --from-catalog requires --artifact-dir", + file=sys.stderr, + ) + return 2 + + scan_run_id = args.scan_run_id or _generate_scan_run_id() + cfg = dynamodb_config_from_args(args) + store = create_finding_store("dynamodb", dynamodb_config=cfg) + + result = run_catalog_vulnerability_scan( + CatalogScanRequest( + artifact_dir=args.artifact_dir, + scan_run_id=scan_run_id, + cache_root=args.cache_root, + semgrep_binary=args.semgrep_binary, + semgrep_config=args.semgrep_config, + timeout_seconds=args.timeout_seconds, + path_policy=args.path_policy, + ), + store=store, + ) + + if not result.preflight_ok: + print(f"error: {result.preflight_detail}", file=sys.stderr) + return 3 + + print( + f"scan_run_id={result.scan_run_id} scanned {result.scanned_count} " + f"findings {result.total_findings} errors {result.error_count} " + f"binary_missing {result.binary_missing_count}" + ) + if result.error_count or result.binary_missing_count: + return 2 + return 0 + + +def cmd_vuln_freshness_eval(args: argparse.Namespace) -> int: + cfg = dynamodb_config_from_args(args) + store = create_finding_store("dynamodb", dynamodb_config=cfg) + + result = run_vuln_freshness_eval( + VulnFreshnessRequest( + artifact_dir=args.artifact_dir, + scan_cadence_hours=args.scan_cadence_hours, + margin_hours=args.margin_hours, + backlog_alert_threshold=args.backlog_alert_threshold, + ), + store=store, + ) + + print( + f"fresh {result.fresh_count} stale {result.stale_count} " + f"never-scanned {result.never_scanned_count} " + f"alerting {str(result.alerting).lower()}" + ) + + if args.notification_log: + _append_freshness_notification(args.notification_log, result) + + return 0 + + +def _append_freshness_notification(log_path: str, result) -> None: + path = Path(log_path) + path.parent.mkdir(parents=True, exist_ok=True) + record = { + "evaluated_at": result.evaluated_at.isoformat(), + "fresh_count": result.fresh_count, + "stale_count": result.stale_count, + "never_scanned_count": result.never_scanned_count, + "alerting": result.alerting, + } + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record, sort_keys=True) + "\n") diff --git a/src/security_scanner/runtime/vulnerability_freshness.py b/src/security_scanner/runtime/vulnerability_freshness.py new file mode 100644 index 0000000..3272113 --- /dev/null +++ b/src/security_scanner/runtime/vulnerability_freshness.py @@ -0,0 +1,143 @@ +"""Artifact-based freshness/coverage evaluator for the code-vuln plane (Phase 2). + +This is a report-only evaluator: it reads the INCLUDED org catalog (read-only) +and enumerates the per-run vuln JSONL artifacts on disk to compute, per repo, the +most recent scan timestamp and a fresh/stale/never-scanned classification. It +NEVER writes to the durable store and NEVER reads/writes vuln findings to any +table; the catalog read is the only durable interaction. The scan timestamp is +parsed from each artifact's FILE NAME stem (the ``scan_run_id``), never from the +artifact body. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from security_scanner.runtime.vulnerability_scan import _safe_repo_dir + +_SCAN_RUN_TS_FORMAT = "%Y%m%dT%H%M%SZ" + + +@dataclass(frozen=True) +class VulnFreshnessRequest: + """Inputs for one freshness/coverage evaluation pass. + + A repo is ``fresh`` when its newest artifact is within + ``scan_cadence_hours + margin_hours`` of ``now``. ``backlog_alert_threshold`` + is the count of (stale + never-scanned) repos at or above which the pass + flags ``alerting``. + """ + + artifact_dir: str | Path + scan_cadence_hours: int = 24 + margin_hours: int = 6 + backlog_alert_threshold: int = 10 + + +@dataclass(frozen=True) +class RepoFreshness: + """One repo's freshness classification. + + ``status`` is one of ``{"fresh", "stale", "never-scanned"}``. + ``last_scanned_at`` is None only for ``never-scanned``. + """ + + repo_id: str + last_scanned_at: datetime | None + status: str + + +@dataclass(frozen=True) +class VulnFreshnessResult: + """Aggregate result of one freshness/coverage evaluation pass.""" + + evaluated_at: datetime + repos: list[RepoFreshness] = field(default_factory=list) + fresh_count: int = 0 + stale_count: int = 0 + never_scanned_count: int = 0 + alerting: bool = False + + +def _parse_scan_run_timestamp(stem: str) -> datetime | None: + """Parse the leading ``%Y%m%dT%H%M%SZ`` timestamp from an artifact stem. + + The stem is ```` optionally followed by ``-`` (the + ``secrets.token_hex`` suffix). Returns a tz-aware UTC datetime, or None when + the leading token does not parse as a timestamp. + """ + leading = stem.split("-", 1)[0] + try: + parsed = datetime.strptime(leading, _SCAN_RUN_TS_FORMAT) + except ValueError: + return None + return parsed.replace(tzinfo=timezone.utc) + + +def _latest_scan_for_repo(artifact_dir: Path, repo_id: str) -> datetime | None: + repo_dir = artifact_dir / _safe_repo_dir(repo_id) + if not repo_dir.is_dir(): + return None + latest: datetime | None = None + for artifact in repo_dir.glob("*.jsonl"): + scanned_at = _parse_scan_run_timestamp(artifact.stem) + if scanned_at is None: + continue + if latest is None or scanned_at > latest: + latest = scanned_at + return latest + + +def run_vuln_freshness_eval( + request: VulnFreshnessRequest, + *, + store, + now: datetime | None = None, +) -> VulnFreshnessResult: + """Evaluate per-repo vuln-scan freshness from on-disk artifacts. + + Reading the catalog (``store.read_all_catalog_entries``) is the only durable + interaction and is read-only. Excluded repos are skipped. The freshness window + is ``scan_cadence_hours + margin_hours``. Report-only: no table writes. + """ + evaluated_at = now or datetime.now(timezone.utc) + artifact_dir = Path(request.artifact_dir) + freshness_window = timedelta( + hours=request.scan_cadence_hours + request.margin_hours + ) + + included = [e for e in store.read_all_catalog_entries() if e.included] + repos: list[RepoFreshness] = [] + fresh_count = 0 + stale_count = 0 + never_scanned_count = 0 + for entry in included: + last_scanned_at = _latest_scan_for_repo(artifact_dir, entry.repo_id) + if last_scanned_at is None: + status = "never-scanned" + never_scanned_count += 1 + elif (evaluated_at - last_scanned_at) <= freshness_window: + status = "fresh" + fresh_count += 1 + else: + status = "stale" + stale_count += 1 + repos.append( + RepoFreshness( + repo_id=entry.repo_id, + last_scanned_at=last_scanned_at, + status=status, + ) + ) + + alerting = (stale_count + never_scanned_count) >= request.backlog_alert_threshold + return VulnFreshnessResult( + evaluated_at=evaluated_at, + repos=repos, + fresh_count=fresh_count, + stale_count=stale_count, + never_scanned_count=never_scanned_count, + alerting=alerting, + ) diff --git a/src/security_scanner/runtime/vulnerability_scan.py b/src/security_scanner/runtime/vulnerability_scan.py index 1f93e6b..67c26e2 100644 --- a/src/security_scanner/runtime/vulnerability_scan.py +++ b/src/security_scanner/runtime/vulnerability_scan.py @@ -1,14 +1,35 @@ -"""Runtime use cases for SARIF-native code vulnerability scanning.""" +"""Runtime use cases for SARIF-native code vulnerability scanning. + +Besides the single-checkout ``run_vulnerability_scan`` / ``run_import_sarif`` +use cases, this module hosts the catalog-driven continuous scan +(``run_catalog_vulnerability_scan``): it reads the INCLUDED org catalog +(read-only), fetches each repo, and writes one no-clobber per-repo/per-run JSONL +artifact. It never writes vuln data to the durable store; the catalog read is the +only durable interaction. A preflight distinguishes "no semgrep binary" from +"scanned, zero findings". +""" from __future__ import annotations +import os +import re +import shutil import tempfile -from dataclasses import dataclass +from collections.abc import Callable +from dataclasses import dataclass, field from pathlib import Path from security_scanner.core.vulnerability.sarif import import_sarif_file -from security_scanner.scanners.semgrep_compatible import SemgrepCompatibleRunner +from security_scanner.scanners.semgrep_compatible import ( + SemgrepCompatibleRunner, + SemgrepExecutionError, +) +from security_scanner.storage.base import CatalogEntry from security_scanner.storage.vulnerability_jsonl_store import VulnerabilityJsonlStore +from security_scanner.targets.fetcher import FetchError, fetch_or_clone + +_DETAIL_LIMIT = 240 +_SAFE_REPO_DIR_FALLBACK = "repo" @dataclass(frozen=True) @@ -101,3 +122,226 @@ def _import_scan_sarif( path_policy=request.path_policy, ) ) + + +# --------------------------------------------------------------------------- +# Catalog-driven continuous scan (Phase 1) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class CatalogScanRequest: + """Inputs for one catalog-driven continuous vuln scan pass (Phase 1). + + ``artifact_dir`` is the root under which per-repo/per-run JSONL artifacts are + written (``//.jsonl``). ``cache_root`` + overrides the fetch cache; when None, ``fetch_or_clone`` falls back to its + env/default. The scan never writes to the durable store. + """ + + artifact_dir: str | Path + scan_run_id: str + cache_root: str | Path | None = None + semgrep_binary: str = "semgrep" + semgrep_config: str = "auto" + timeout_seconds: int = 1800 + path_policy: str = "redacted" + + +@dataclass(frozen=True) +class RepoScanOutcome: + """Per-repo result of one catalog scan pass. + + ``status`` is one of ``{"scanned", "fetch-error", "scan-error", + "binary-missing"}``. ``detail`` carries a short, sanitized error summary for + the non-scanned statuses. + """ + + repo_id: str + repo_url: str + status: str + artifact_path: Path | None = None + finding_count: int = 0 + detail: str | None = None + + +@dataclass(frozen=True) +class CatalogScanResult: + """Aggregate result of one catalog scan pass. + + ``preflight_ok`` is False only when the local semgrep-compatible binary could + not be resolved (no injected runner); that case is deliberately DISTINCT from + "scanned, zero findings" so a missing analyzer never looks like a clean scan. + """ + + scan_run_id: str + preflight_ok: bool + outcomes: list[RepoScanOutcome] = field(default_factory=list) + preflight_detail: str | None = None + + @property + def scanned_count(self) -> int: + return sum(1 for o in self.outcomes if o.status == "scanned") + + @property + def error_count(self) -> int: + errors = {"fetch-error", "scan-error"} + return sum(1 for o in self.outcomes if o.status in errors) + + @property + def binary_missing_count(self) -> int: + return sum(1 for o in self.outcomes if o.status == "binary-missing") + + @property + def total_findings(self) -> int: + return sum(o.finding_count for o in self.outcomes) + + +def resolve_semgrep_binary(binary: str) -> str | None: + """Resolve a semgrep-compatible binary to an executable path or None. + + Resolution order: ``shutil.which`` on PATH, then an existing absolute path. + Returns the resolved path, or None when nothing usable is found. + """ + resolved = shutil.which(binary) + if resolved is not None: + return resolved + candidate = Path(binary) + if candidate.is_absolute() and candidate.exists(): + return str(candidate) + return None + + +def _safe_repo_dir(repo_id: str) -> str: + """Map a repo id to a single safe directory-name component. + + Deterministic: replace path separators, ``..``, and whitespace so the result + is a single path segment. Freshness eval applies this same function to catalog + repo_ids to recover repo identity by matching, so the mapping must be stable. + """ + slug = repo_id.strip().replace(os.sep, "__") + if os.altsep: + slug = slug.replace(os.altsep, "__") + slug = slug.replace("..", "__") + slug = re.sub(r"\s+", "_", slug) + slug = re.sub(r"[^A-Za-z0-9._-]", "_", slug) + slug = slug.strip("._") + return slug or _SAFE_REPO_DIR_FALLBACK + + +def _short_detail(value: object) -> str: + text = str(value).strip() or "no detail" + if len(text) > _DETAIL_LIMIT: + return text[: _DETAIL_LIMIT - 1].rstrip() + "…" + return text + + +def run_catalog_vulnerability_scan( + request: CatalogScanRequest, + *, + store, + fetcher: Callable[..., Path] = fetch_or_clone, + runner: SemgrepCompatibleRunner | None = None, +) -> CatalogScanResult: + """Run a catalog-driven continuous vuln scan over INCLUDED org repos. + + Reading the catalog (``store.read_all_catalog_entries``) is the ONLY durable + interaction and is read-only. Excluded repos are skipped entirely. Each + included repo yields one ``RepoScanOutcome``; a single repo's fetch/scan + failure never aborts the whole pass. + + When ``runner`` is None a preflight resolves the semgrep-compatible binary; if + it cannot be resolved the pass returns ``preflight_ok=False`` with no outcomes + and no artifacts. When ``runner`` is provided (tests), preflight is skipped. + """ + if runner is None: + resolved = resolve_semgrep_binary(request.semgrep_binary) + if resolved is None: + return CatalogScanResult( + scan_run_id=request.scan_run_id, + preflight_ok=False, + outcomes=[], + preflight_detail=( + f"semgrep-compatible binary not found: {request.semgrep_binary}" + ), + ) + + artifact_root = Path(request.artifact_dir) + cache_root = Path(request.cache_root) if request.cache_root is not None else None + + included = [e for e in store.read_all_catalog_entries() if e.included] + outcomes: list[RepoScanOutcome] = [] + for entry in included: + outcomes.append( + _scan_one_catalog_repo( + entry, + request=request, + artifact_root=artifact_root, + cache_root=cache_root, + fetcher=fetcher, + runner=runner, + ) + ) + + return CatalogScanResult( + scan_run_id=request.scan_run_id, + preflight_ok=True, + outcomes=outcomes, + ) + + +def _scan_one_catalog_repo( + entry: CatalogEntry, + *, + request: CatalogScanRequest, + artifact_root: Path, + cache_root: Path | None, + fetcher: Callable[..., Path], + runner: SemgrepCompatibleRunner | None, +) -> RepoScanOutcome: + try: + cache_path = fetcher(entry.repo_url, cache_root) + except FetchError as exc: + return RepoScanOutcome( + repo_id=entry.repo_id, + repo_url=entry.repo_url, + status="fetch-error", + detail=_short_detail(exc), + ) + + artifact_path = ( + artifact_root / _safe_repo_dir(entry.repo_id) / f"{request.scan_run_id}.jsonl" + ) + if artifact_path.exists(): + raise ValueError( + f"refusing to clobber existing vuln artifact: {artifact_path}" + ) + + try: + result = run_vulnerability_scan( + ScanVulnerabilityRequest( + root=cache_path, + output_path=artifact_path, + semgrep_binary=request.semgrep_binary, + semgrep_config=request.semgrep_config, + timeout_seconds=request.timeout_seconds, + path_policy=request.path_policy, + ), + runner=runner, + ) + except SemgrepExecutionError as exc: + status = "binary-missing" if "binary not found" in str(exc) else "scan-error" + return RepoScanOutcome( + repo_id=entry.repo_id, + repo_url=entry.repo_url, + status=status, + detail=_short_detail(exc), + ) + + return RepoScanOutcome( + repo_id=entry.repo_id, + repo_url=entry.repo_url, + status="scanned", + artifact_path=result.output_path, + finding_count=result.finding_count, + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 311db7b..402ffb3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -717,6 +717,7 @@ def test_subcommand_registration_order_is_stable(): "scan-all", "import-sarif", "scan-vuln", + "vuln-freshness-eval", "scan-health", "freshness-eval", "report", diff --git a/tests/test_cli_scan_vuln_catalog.py b/tests/test_cli_scan_vuln_catalog.py new file mode 100644 index 0000000..3ce6b08 --- /dev/null +++ b/tests/test_cli_scan_vuln_catalog.py @@ -0,0 +1,267 @@ +"""CLI wiring tests for ``scan-vuln --from-catalog`` and ``vuln-freshness-eval``. + +These never hit a real store: ``create_finding_store`` in the command module is +monkeypatched to return a sentinel, and the runtime entrypoints are monkeypatched +to capture the request and drive exit codes. No semgrep, no live DynamoDB. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from security_scanner.cli import main +from security_scanner.cli.app import build_parser + + +def test_parser_accepts_from_catalog_flags() -> None: + parser = build_parser() + args = parser.parse_args( + [ + "scan-vuln", + "--from-catalog", + "--artifact-dir", + "/tmp/artifacts", + "--cache-root", + "/tmp/cache", + "--scan-run-id", + "run-xyz", + "--dynamodb-endpoint-url", + "http://localhost:4567", + "--dynamodb-table", + "SecurityScannerLocal", + "--dynamodb-region", + "us-west-2", + ] + ) + assert args.from_catalog is True + assert args.artifact_dir == "/tmp/artifacts" + assert args.cache_root == "/tmp/cache" + assert args.scan_run_id == "run-xyz" + assert args.func.__name__ == "cmd_scan_vuln" + + +def test_parser_accepts_vuln_freshness_eval_args() -> None: + parser = build_parser() + args = parser.parse_args( + [ + "vuln-freshness-eval", + "--artifact-dir", + "/tmp/artifacts", + "--scan-cadence-hours", + "12", + "--margin-hours", + "3", + "--backlog-alert-threshold", + "5", + "--notification-log", + "/tmp/alerts.jsonl", + "--dynamodb-table", + "SecurityScannerLocal", + ] + ) + assert args.artifact_dir == "/tmp/artifacts" + assert args.scan_cadence_hours == 12 + assert args.margin_hours == 3 + assert args.backlog_alert_threshold == 5 + assert args.notification_log == "/tmp/alerts.jsonl" + assert args.func.__name__ == "cmd_vuln_freshness_eval" + + +_SENTINEL_STORE = object() + + +def _stub_store(monkeypatch): + monkeypatch.setattr( + "security_scanner.cli.commands.vulnerability.create_finding_store", + lambda *a, **k: _SENTINEL_STORE, + ) + + +class _CatalogResult: + def __init__(self, *, preflight_ok, scanned, errors, binary_missing, findings): + self.scan_run_id = "run1" + self.preflight_ok = preflight_ok + self.preflight_detail = ( + None if preflight_ok else "semgrep-compatible binary not found: semgrep" + ) + self.scanned_count = scanned + self.error_count = errors + self.binary_missing_count = binary_missing + self.total_findings = findings + + +def test_scan_vuln_catalog_wires_request_and_returns_zero(monkeypatch, capsys): + _stub_store(monkeypatch) + captured = {} + + def fake_run(request, *, store): + captured["request"] = request + captured["store"] = store + return _CatalogResult( + preflight_ok=True, scanned=2, errors=0, binary_missing=0, findings=3 + ) + + monkeypatch.setattr( + "security_scanner.cli.commands.vulnerability.run_catalog_vulnerability_scan", + fake_run, + ) + + rc = main( + [ + "scan-vuln", + "--from-catalog", + "--artifact-dir", + "/tmp/artifacts", + "--scan-run-id", + "run1", + "--semgrep-binary", + "/opt/semgrep", + "--timeout-seconds", + "1800", + ] + ) + assert rc == 0 + req = captured["request"] + assert req.artifact_dir == "/tmp/artifacts" + assert req.scan_run_id == "run1" + assert req.semgrep_binary == "/opt/semgrep" + assert req.timeout_seconds == 1800 + assert captured["store"] is _SENTINEL_STORE + out = capsys.readouterr().out + assert "scan_run_id=run1" in out + assert "scanned 2" in out + assert "findings 3" in out + + +def test_scan_vuln_catalog_preflight_fail_returns_three(monkeypatch, capsys): + _stub_store(monkeypatch) + + monkeypatch.setattr( + "security_scanner.cli.commands.vulnerability.run_catalog_vulnerability_scan", + lambda request, *, store: _CatalogResult( + preflight_ok=False, scanned=0, errors=0, binary_missing=0, findings=0 + ), + ) + + rc = main( + ["scan-vuln", "--from-catalog", "--artifact-dir", "/tmp/artifacts"] + ) + assert rc == 3 + err = capsys.readouterr().err + assert "error:" in err + assert "not found" in err + + +def test_scan_vuln_catalog_errors_return_two(monkeypatch): + _stub_store(monkeypatch) + + monkeypatch.setattr( + "security_scanner.cli.commands.vulnerability.run_catalog_vulnerability_scan", + lambda request, *, store: _CatalogResult( + preflight_ok=True, scanned=1, errors=1, binary_missing=0, findings=0 + ), + ) + + rc = main( + ["scan-vuln", "--from-catalog", "--artifact-dir", "/tmp/artifacts"] + ) + assert rc == 2 + + +def test_scan_vuln_catalog_binary_missing_returns_two(monkeypatch): + _stub_store(monkeypatch) + + monkeypatch.setattr( + "security_scanner.cli.commands.vulnerability.run_catalog_vulnerability_scan", + lambda request, *, store: _CatalogResult( + preflight_ok=True, scanned=0, errors=0, binary_missing=2, findings=0 + ), + ) + + rc = main( + ["scan-vuln", "--from-catalog", "--artifact-dir", "/tmp/artifacts"] + ) + assert rc == 2 + + +def test_scan_vuln_catalog_missing_artifact_dir_returns_two(monkeypatch, capsys): + _stub_store(monkeypatch) + # run should never be called when --artifact-dir is missing. + called = [] + monkeypatch.setattr( + "security_scanner.cli.commands.vulnerability.run_catalog_vulnerability_scan", + lambda *a, **k: called.append(True), + ) + + rc = main(["scan-vuln", "--from-catalog"]) + assert rc == 2 + assert called == [] + assert "--artifact-dir" in capsys.readouterr().err + + +class _FreshnessResult: + def __init__(self): + self.evaluated_at = datetime(2026, 6, 22, 12, 0, 0, tzinfo=timezone.utc) + self.fresh_count = 4 + self.stale_count = 1 + self.never_scanned_count = 2 + self.alerting = False + self.repos = [] + + +def test_vuln_freshness_eval_wires_request_and_returns_zero( + monkeypatch, tmp_path, capsys +): + _stub_store(monkeypatch) + captured = {} + + def fake_eval(request, *, store): + captured["request"] = request + captured["store"] = store + return _FreshnessResult() + + monkeypatch.setattr( + "security_scanner.cli.commands.vulnerability.run_vuln_freshness_eval", + fake_eval, + ) + + log_path = tmp_path / "nested" / "alerts.jsonl" + rc = main( + [ + "vuln-freshness-eval", + "--artifact-dir", + "/tmp/artifacts", + "--scan-cadence-hours", + "12", + "--margin-hours", + "3", + "--backlog-alert-threshold", + "5", + "--notification-log", + str(log_path), + ] + ) + assert rc == 0 + req = captured["request"] + assert req.artifact_dir == "/tmp/artifacts" + assert req.scan_cadence_hours == 12 + assert req.margin_hours == 3 + assert req.backlog_alert_threshold == 5 + assert captured["store"] is _SENTINEL_STORE + + out = capsys.readouterr().out + assert "fresh 4" in out + assert "stale 1" in out + assert "never-scanned 2" in out + + # Notification log: exactly one JSON line, parent dir created. + assert log_path.is_file() + lines = log_path.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 1 + import json + + record = json.loads(lines[0]) + assert record["fresh_count"] == 4 + assert record["stale_count"] == 1 + assert record["never_scanned_count"] == 2 + assert record["alerting"] is False diff --git a/tests/test_vuln_catalog_scan.py b/tests/test_vuln_catalog_scan.py new file mode 100644 index 0000000..cf6b357 --- /dev/null +++ b/tests/test_vuln_catalog_scan.py @@ -0,0 +1,243 @@ +"""Catalog-driven continuous vuln scan (Phase 1) tests. + +These run WITHOUT a real semgrep binary (inject a FakeRunner that writes SARIF) +and WITHOUT a live DynamoDB (inject a FakeStore implementing +``read_all_catalog_entries``). The fetcher is a callable returning a tmp path. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from security_scanner.runtime.vulnerability_scan import ( + CatalogScanRequest, + run_catalog_vulnerability_scan, +) +from security_scanner.storage.base import CatalogEntry +from security_scanner.targets.fetcher import FetchError + + +def _sarif_payload() -> dict: + return { + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Semgrep OSS", + "rules": [ + { + "id": "python.lang.security.audit.sql-injection", + "properties": { + "precision": "high", + "security-severity": "8.2", + }, + } + ], + } + }, + "results": [ + { + "ruleId": "python.lang.security.audit.sql-injection", + "message": {"text": "Potential SQL injection."}, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": "src/app.py"}, + "region": {"startLine": 12}, + } + } + ], + } + ], + } + ], + } + + +class FakeStore: + def __init__(self, entries: list[CatalogEntry]) -> None: + self._entries = entries + + def read_all_catalog_entries(self) -> list[CatalogEntry]: + return list(self._entries) + + +class FakeRunner: + """Writes minimal valid SARIF instead of invoking a real semgrep binary.""" + + def run(self, root_path, *, sarif_path): + Path(sarif_path).write_text( + json.dumps(_sarif_payload()), encoding="utf-8" + ) + return Path(sarif_path).read_text(encoding="utf-8") + + +def _entry(repo_id: str, *, included: bool, url: str | None = None) -> CatalogEntry: + return CatalogEntry( + repo_id=repo_id, + repo_url=url or f"https://github.com/org/{repo_id}", + included=included, + first_seen="2026-06-01T00:00:00Z", + last_reconciled="2026-06-20T00:00:00Z", + ) + + +def _make_fetcher(tmp_path: Path): + def fetcher(url: str, cache_root): + # One distinct checkout dir per URL; create it so it exists. + slug = url.rstrip("/").rsplit("/", 1)[-1] + repo = tmp_path / "checkouts" / slug + repo.mkdir(parents=True, exist_ok=True) + return repo + + return fetcher + + +def test_excluded_repos_skipped_and_one_artifact_per_included_repo(tmp_path): + store = FakeStore( + [ + _entry("alpha", included=True), + _entry("beta", included=False), + _entry("gamma", included=True), + ] + ) + artifact_dir = tmp_path / "artifacts" + + result = run_catalog_vulnerability_scan( + CatalogScanRequest(artifact_dir=artifact_dir, scan_run_id="run1"), + store=store, + fetcher=_make_fetcher(tmp_path), + runner=FakeRunner(), + ) + + assert result.preflight_ok is True + # Excluded "beta" produced no outcome. + scanned_ids = {o.repo_id for o in result.outcomes} + assert scanned_ids == {"alpha", "gamma"} + assert result.scanned_count == 2 + assert result.error_count == 0 + assert result.binary_missing_count == 0 + + for repo_id in ("alpha", "gamma"): + artifact = artifact_dir / repo_id / "run1.jsonl" + assert artifact.is_file() + assert not (artifact_dir / "beta").exists() + + +def test_finding_count_is_recorded(tmp_path): + store = FakeStore([_entry("alpha", included=True)]) + result = run_catalog_vulnerability_scan( + CatalogScanRequest(artifact_dir=tmp_path / "artifacts", scan_run_id="run1"), + store=store, + fetcher=_make_fetcher(tmp_path), + runner=FakeRunner(), + ) + outcome = result.outcomes[0] + assert outcome.status == "scanned" + assert outcome.finding_count == 1 + assert result.total_findings == 1 + + +def test_two_runs_distinct_scan_run_ids_do_not_clobber(tmp_path): + store = FakeStore([_entry("alpha", included=True)]) + artifact_dir = tmp_path / "artifacts" + fetcher = _make_fetcher(tmp_path) + + run_catalog_vulnerability_scan( + CatalogScanRequest(artifact_dir=artifact_dir, scan_run_id="run1"), + store=store, + fetcher=fetcher, + runner=FakeRunner(), + ) + run_catalog_vulnerability_scan( + CatalogScanRequest(artifact_dir=artifact_dir, scan_run_id="run2"), + store=store, + fetcher=fetcher, + runner=FakeRunner(), + ) + + assert (artifact_dir / "alpha" / "run1.jsonl").is_file() + assert (artifact_dir / "alpha" / "run2.jsonl").is_file() + + +def test_reusing_same_scan_run_id_raises_no_clobber_guard(tmp_path): + store = FakeStore([_entry("alpha", included=True)]) + artifact_dir = tmp_path / "artifacts" + fetcher = _make_fetcher(tmp_path) + + run_catalog_vulnerability_scan( + CatalogScanRequest(artifact_dir=artifact_dir, scan_run_id="run1"), + store=store, + fetcher=fetcher, + runner=FakeRunner(), + ) + with pytest.raises(ValueError, match="clobber"): + run_catalog_vulnerability_scan( + CatalogScanRequest(artifact_dir=artifact_dir, scan_run_id="run1"), + store=store, + fetcher=fetcher, + runner=FakeRunner(), + ) + + +def test_fetch_failure_for_one_repo_does_not_abort_run(tmp_path): + store = FakeStore( + [ + _entry("alpha", included=True), + _entry("broken", included=True), + _entry("gamma", included=True), + ] + ) + artifact_dir = tmp_path / "artifacts" + ok_fetcher = _make_fetcher(tmp_path) + + def fetcher(url: str, cache_root): + if "broken" in url: + raise FetchError("git clone failed with exit code 128: not found") + return ok_fetcher(url, cache_root) + + result = run_catalog_vulnerability_scan( + CatalogScanRequest(artifact_dir=artifact_dir, scan_run_id="run1"), + store=store, + fetcher=fetcher, + runner=FakeRunner(), + ) + + by_id = {o.repo_id: o for o in result.outcomes} + assert by_id["broken"].status == "fetch-error" + assert by_id["broken"].detail is not None + assert by_id["alpha"].status == "scanned" + assert by_id["gamma"].status == "scanned" + assert result.scanned_count == 2 + assert result.error_count == 1 + # The two healthy repos still produced artifacts. + assert (artifact_dir / "alpha" / "run1.jsonl").is_file() + assert (artifact_dir / "gamma" / "run1.jsonl").is_file() + assert not (artifact_dir / "broken").exists() + + +def test_preflight_fails_without_runner_and_bogus_binary(tmp_path): + store = FakeStore([_entry("alpha", included=True)]) + artifact_dir = tmp_path / "artifacts" + + result = run_catalog_vulnerability_scan( + CatalogScanRequest( + artifact_dir=artifact_dir, + scan_run_id="run1", + semgrep_binary="/nonexistent/path/to/semgrep-does-not-exist", + ), + store=store, + fetcher=_make_fetcher(tmp_path), + runner=None, + ) + + assert result.preflight_ok is False + assert result.outcomes == [] + assert result.preflight_detail is not None + assert "not found" in result.preflight_detail + # No artifacts written when preflight fails. + assert not artifact_dir.exists() diff --git a/tests/test_vuln_freshness_eval.py b/tests/test_vuln_freshness_eval.py new file mode 100644 index 0000000..e9496e6 --- /dev/null +++ b/tests/test_vuln_freshness_eval.py @@ -0,0 +1,135 @@ +"""Artifact-based vuln freshness/coverage evaluator (Phase 2) tests. + +No live DynamoDB (inject a FakeStore). No semgrep. Artifacts are bare files named +with the ``%Y%m%dT%H%M%SZ`` scan_run_id stem (optionally ``-``). +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from security_scanner.runtime.vulnerability_freshness import ( + VulnFreshnessRequest, + run_vuln_freshness_eval, +) +from security_scanner.runtime.vulnerability_scan import _safe_repo_dir +from security_scanner.storage.base import CatalogEntry + + +class FakeStore: + def __init__(self, entries: list[CatalogEntry]) -> None: + self._entries = entries + + def read_all_catalog_entries(self) -> list[CatalogEntry]: + return list(self._entries) + + +def _entry(repo_id: str, *, included: bool) -> CatalogEntry: + return CatalogEntry( + repo_id=repo_id, + repo_url=f"https://github.com/org/{repo_id}", + included=included, + first_seen="2026-06-01T00:00:00Z", + last_reconciled="2026-06-20T00:00:00Z", + ) + + +def _write_artifact(artifact_dir: Path, repo_id: str, scanned_at: datetime) -> None: + repo_dir = artifact_dir / _safe_repo_dir(repo_id) + repo_dir.mkdir(parents=True, exist_ok=True) + stem = scanned_at.strftime("%Y%m%dT%H%M%SZ") + (repo_dir / f"{stem}-ab12cd.jsonl").write_text("", encoding="utf-8") + + +def test_fresh_stale_and_never_scanned_classification(tmp_path): + now = datetime(2026, 6, 22, 12, 0, 0, tzinfo=timezone.utc) + artifact_dir = tmp_path / "artifacts" + + # fresh: scanned 2h ago (well within 24+6h window) + _write_artifact(artifact_dir, "fresh-repo", now - timedelta(hours=2)) + # stale: scanned 40h ago (> 30h window) + _write_artifact(artifact_dir, "stale-repo", now - timedelta(hours=40)) + # never-scanned: included but no artifacts on disk + + store = FakeStore( + [ + _entry("fresh-repo", included=True), + _entry("stale-repo", included=True), + _entry("never-repo", included=True), + _entry("excluded-repo", included=False), + ] + ) + + result = run_vuln_freshness_eval( + VulnFreshnessRequest(artifact_dir=artifact_dir), + store=store, + now=now, + ) + + by_id = {r.repo_id: r for r in result.repos} + assert set(by_id) == {"fresh-repo", "stale-repo", "never-repo"} + assert by_id["fresh-repo"].status == "fresh" + assert by_id["stale-repo"].status == "stale" + assert by_id["never-repo"].status == "never-scanned" + assert by_id["never-repo"].last_scanned_at is None + + assert result.fresh_count == 1 + assert result.stale_count == 1 + assert result.never_scanned_count == 1 + assert result.alerting is False # 2 < default threshold 10 + + +def test_latest_artifact_wins_for_a_repo(tmp_path): + now = datetime(2026, 6, 22, 12, 0, 0, tzinfo=timezone.utc) + artifact_dir = tmp_path / "artifacts" + _write_artifact(artifact_dir, "alpha", now - timedelta(hours=40)) + _write_artifact(artifact_dir, "alpha", now - timedelta(hours=1)) + + store = FakeStore([_entry("alpha", included=True)]) + result = run_vuln_freshness_eval( + VulnFreshnessRequest(artifact_dir=artifact_dir), + store=store, + now=now, + ) + assert result.repos[0].status == "fresh" + assert result.repos[0].last_scanned_at == now - timedelta(hours=1) + + +def test_unparseable_artifact_names_are_ignored(tmp_path): + now = datetime(2026, 6, 22, 12, 0, 0, tzinfo=timezone.utc) + artifact_dir = tmp_path / "artifacts" + repo_dir = artifact_dir / _safe_repo_dir("alpha") + repo_dir.mkdir(parents=True, exist_ok=True) + (repo_dir / "not-a-timestamp.jsonl").write_text("", encoding="utf-8") + (repo_dir / "README.jsonl").write_text("", encoding="utf-8") + + store = FakeStore([_entry("alpha", included=True)]) + result = run_vuln_freshness_eval( + VulnFreshnessRequest(artifact_dir=artifact_dir), + store=store, + now=now, + ) + # No parseable artifact -> never-scanned. + assert result.repos[0].status == "never-scanned" + assert result.never_scanned_count == 1 + + +def test_alerting_threshold_triggers(tmp_path): + now = datetime(2026, 6, 22, 12, 0, 0, tzinfo=timezone.utc) + artifact_dir = tmp_path / "artifacts" + # 3 never-scanned included repos; threshold 2 -> alerting. + store = FakeStore( + [ + _entry("a", included=True), + _entry("b", included=True), + _entry("c", included=True), + ] + ) + result = run_vuln_freshness_eval( + VulnFreshnessRequest(artifact_dir=artifact_dir, backlog_alert_threshold=2), + store=store, + now=now, + ) + assert result.never_scanned_count == 3 + assert result.alerting is True diff --git a/tests/test_vuln_rollout_systemd_units.py b/tests/test_vuln_rollout_systemd_units.py index 0cdba27..bce2cb1 100644 --- a/tests/test_vuln_rollout_systemd_units.py +++ b/tests/test_vuln_rollout_systemd_units.py @@ -8,17 +8,23 @@ The vuln units intentionally diverge from the secret units in two ways that are asserted here as first-class invariants: -1. ``SECURITY_SCANNER_STORAGE_BACKEND=jsonl`` with NO DynamoDB env block. The - code-vuln plane is JSONL-only (``report``/``gate``/``evaluate --category - code-vuln`` reject ``--storage-backend dynamodb`` and ``VulnerabilityJsonlStore`` - has no DynamoDB schema), so the vuln units must never point at the - ``security_scanner_personal`` table. +1. NO ``SECURITY_SCANNER_STORAGE_BACKEND=jsonl`` override on the unit. The + catalog-driven scan and the freshness evaluator both READ the org catalog + (read-only) from the DynamoDB-compatible store supplied via the + ``EnvironmentFile`` (``personal-prod.env``), so the unit must not force the + jsonl backend. Vuln FINDINGS are never written to the durable table — they + land in per-repo JSONL artifacts under ``--artifact-dir`` / the isolated cache + root — but the catalog read needs the dynamodb store. The unit therefore + carries no inline ``SECURITY_SCANNER_DYNAMO_*`` directive of its own; those + keys come from the EnvironmentFile, not the unit text. 2. A long scan cadence (daily / multi-hour) rather than the 5-min incr-poll or 10-min freshness cadence, because semgrep-compatible SAST is a full directory-tree HEAD scan. -The units are DRAFTS (not for enable); these tests guard their shape so that a -later single-owner review and Phase 1/2 code changes have a stable contract. +The units are DRAFTS (not for autonomous enable); these tests guard their shape +so that a later single-owner review has a stable contract. The Phase 1/2 code +(catalog-driven ``scan-vuln --from-catalog`` and ``vuln-freshness-eval``) now +exists on this branch, so the ExecStart assertions match the real CLI. """ from __future__ import annotations @@ -52,7 +58,9 @@ VULN_TIMER_FILES = [timer for _, timer, _, _ in VULN_PERIODIC_UNITS.values()] # The secret baseline service is the structural template the vuln units mirror. -SECRET_BASELINE_SERVICE = USER_SYSTEMD_DIR / "security-scanner-personal-baseline.service" +SECRET_BASELINE_SERVICE = ( + USER_SYSTEMD_DIR / "security-scanner-personal-baseline.service" +) def _parse_unit(path: Path) -> configparser.ConfigParser: @@ -129,18 +137,42 @@ def test_vuln_timer_is_long_cadence_and_wired_to_service( @pytest.mark.parametrize("service_name", VULN_SERVICE_FILES) -def test_vuln_services_are_jsonl_backed_not_dynamodb(service_name: str) -> None: - """code-vuln plane is JSONL-only; vuln units must never touch the table.""" +def test_vuln_services_have_no_inline_storage_backend_override( + service_name: str, +) -> None: + """Catalog read needs the dynamodb store from the EnvironmentFile. + + The unit must NOT force the jsonl backend (that would break the catalog read) + and must NOT carry its own inline DynamoDB directive (those keys come from the + EnvironmentFile, never the unit text). + """ text = (USER_SYSTEMD_DIR / service_name).read_text(encoding="utf-8") - assert "SECURITY_SCANNER_STORAGE_BACKEND=jsonl" in text - # The DynamoDB env block that the secret units carry must be absent here. + # No jsonl-backend override on these two services anymore. + assert "SECURITY_SCANNER_STORAGE_BACKEND=jsonl" not in text + # No inline DynamoDB directive of any kind; those come from the EnvironmentFile. assert "SECURITY_SCANNER_STORAGE_BACKEND=dynamodb" not in text assert "SECURITY_SCANNER_DYNAMO_ENDPOINT" not in text - # The personal table must never be configured for the vuln plane. (The bare - # table name may appear in an explanatory comment; only the live directive is - # forbidden.) assert "SECURITY_SCANNER_DYNAMO_TABLE" not in text - assert "SECURITY_SCANNER_DYNAMO_TABLE=security_scanner_personal" not in text + + +def test_vuln_scan_execstart_is_catalog_driven() -> None: + """The scan service runs the real catalog-driven entrypoint.""" + text = ( + USER_SYSTEMD_DIR / "security-scanner-personal-vuln-scan.service" + ).read_text(encoding="utf-8") + assert "scan-vuln --from-catalog" in " ".join(text.split()) + assert "--artifact-dir" in text + assert "--semgrep-binary %h/.local/bin/semgrep" in text + + +def test_vuln_freshness_eval_execstart_runs_subcommand() -> None: + """The freshness service runs the real report-only evaluator.""" + text = ( + USER_SYSTEMD_DIR / "security-scanner-personal-vuln-freshness-eval.service" + ).read_text(encoding="utf-8") + assert "vuln-freshness-eval" in text + assert "--artifact-dir" in text + assert "--notification-log" in text @pytest.mark.parametrize("service_name", VULN_SERVICE_FILES) From b13309b8438e80f0ace8cab112cda3a53da53716 Mon Sep 17 00:00:00 2001 From: pureliture Date: Mon, 22 Jun 2026 07:31:06 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(vuln-rollout):=20enable=20=EB=9F=B0?= =?UTF-8?q?=EB=B6=81=EC=9D=84=20=EC=8B=A4=ED=86=A0=ED=8F=B4=EB=A1=9C?= =?UTF-8?q?=EC=A7=80=C2=B7=EC=A0=95=ED=99=95=20argv=EB=A1=9C=20turnkey=20?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 코드 미존재 경고 → 구현 완료(83ede4b, 1354 passed) 반영. - 호스트 실상태 반영: branch codex/ragflow-continuous-idle-freshness @8ed5b9e, 미푸시 커밋 2개(8ed5b9e/b8f6b69) → checkout 전진 전 push/merge 보존 단계 추가. - canonical remote=github(source-security-dev, main=ec98702), host origin=stale 번들 명시. - units가 jsonl-only라는 오기 정정: findings는 --artifact-dir JSONL이나 카탈로그 read는 dynamo(read-only) 필요, jsonl backend override 없음. - precondition --help 플래그를 실제 CLI에 맞춤(scan은 --notification-log 없음, freshness에 있음). Co-Authored-By: Claude Opus 4.8 --- .../runbooks/vuln-rollout-enable-checklist.md | 77 +++++++++++++------ 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/docs/runbooks/vuln-rollout-enable-checklist.md b/docs/runbooks/vuln-rollout-enable-checklist.md index 6da1d2c..4580402 100644 --- a/docs/runbooks/vuln-rollout-enable-checklist.md +++ b/docs/runbooks/vuln-rollout-enable-checklist.md @@ -1,7 +1,7 @@ # Runbook: Vulnerability/SAST continuous-scanning — production enable checklist -- Status: Draft (branch-local). **NOT yet executable.** -- Date: 2026-06-21 +- Status: Draft (branch-local). **NOT yet executable** — gated on host-side preconditions below. +- Date: 2026-06-21 (revised 2026-06-22 with implemented code + real host topology) - Scope: single-owner production enable on host `ragflow-ubuntu` of the JSONL-scheduled vuln rollout drafted in worktree `.worktrees/vuln-rollout-prep`. - Related ADR: [`docs/adr/20260621-vuln-continuous-scanning-rollout.md`](../adr/20260621-vuln-continuous-scanning-rollout.md) @@ -14,17 +14,21 @@ > **READ THIS FIRST.** Every `systemctl --user enable`/`start` and every host-side > filesystem mutation in this document is a **SINGLE-OWNER** step. Do **not** run any > of them now. They are recorded here so that the single owner can execute them later, -> in order, after the preconditions below are all green. At the time of writing: +> in order, after the preconditions below are all green. State as of 2026-06-22: > -> - `semgrep` is **not installed** on the host and a global install is **forbidden**. -> - The host checkout is **behind `origin/main`** and is missing both the PR #59 vuln -> substrate and this rollout. -> - The Phase 1/Phase 2 code changes that the drafted units invoke -> (`scan-vuln --from-catalog ...`, `vuln-freshness-eval ...`) **do not exist yet**. -> -> Because of the last point, enabling these units today would only produce argparse -> failures every tick. This checklist assumes the "Required code changes before enable" -> section at the bottom has already landed and reached the host. +> - **Code changes DONE (branch-local, committed `83ede4b`):** `scan-vuln --from-catalog`, +> `vuln-freshness-eval`, the semgrep preflight, and the no-clobber per-run artifacts +> are implemented and tested (1354 passed; fake-semgrep end-to-end proof). They live on +> branch `claude/vuln-rollout-prep` and are **NOT yet merged to canonical `main`** and +> **NOT yet on the host**. +> - `semgrep` is **not installed** on the host; a global install is **forbidden** (stage a +> pinned per-user binary at `~/.local/bin/semgrep`). +> - The host checkout is on branch `codex/ragflow-continuous-idle-freshness` at `8ed5b9e`, +> which carries **two UNPUSHED local commits** (`8ed5b9e`, `b8f6b69`) that exist on **no +> remote**. Advancing/switching the host checkout before those are pushed/merged would +> **destroy that work** — see §1.1. This is a hard blocker outside the rollout's scope. +> - A live **secret** `scan-worker --daemon` is running on the host (normal production). +> The vuln units are additive and do not stop it. --- @@ -39,24 +43,51 @@ (`baseline` / `freshness-eval` / `incr-poll` / `lease-reaper` / `scan-worker@`) is already running on this host. **Do not disturb it.** None of the steps below touch the secret units, the secret timers, or the `security_scanner_personal` table contents. -- The vuln plane is **JSONL-only** by design. These units set - `SECURITY_SCANNER_STORAGE_BACKEND=jsonl` and write artifacts under the cache/state - dirs — they do **not** write vuln findings into the DynamoDB-compatible table. +- Vuln **findings** are JSONL-only by design: the units write finding artifacts under the + state dir via `--artifact-dir`, and never write vuln findings into the + `security_scanner_personal` table. **However**, the catalog-driven scan must **read** the + org repo set (`read_all_catalog_entries`) from the durable store, so the units rely on + the `SECURITY_SCANNER_DYNAMO_*` keys in `personal-prod.env` for a **read-only** catalog + query. They do **not** set a `jsonl` backend override (an earlier draft did; that was + wrong — it would break the catalog read). No durable writes, no new item kinds. --- ## 1. Preconditions (verify ALL before any enable) -### 1.1 Host checkout must advance from `66aa165` to a commit containing PR #59 + this rollout +### 1.1 PRESERVE the host's unpushed work, THEN advance the checkout + +As of 2026-06-22 the host checkout is on branch `codex/ragflow-continuous-idle-freshness` +at `8ed5b9e`, working tree clean, with **two commits that are on no remote**: + +``` +8ed5b9e 리뷰 지적 idle freshness 초기화 경로 보정 +b8f6b69 지속 스캔 idle freshness 전진 보정 +``` + +Canonical remote is `github` → `github.com/source-security-dev/security-scanner` +(`main` = `ec98702`). The host's `origin` is a stale local bundle (`~/ss-e2e.bundle`) — +ignore it; use `github`. + +**STOP — do not advance the checkout until these two commits are preserved.** A +`git checkout main` / `reset` now would orphan them. The single owner must FIRST push or +merge them, e.g.: + +```bash +# On the host (single owner), preserve the unpushed idle-freshness work first: +git -C ~/security-scanner push github codex/ragflow-continuous-idle-freshness +# …then open/merge its PR on canonical main as appropriate. +``` -The host checkout was last seen at `66aa165` (PR #54), which predates the PR #59 vuln -substrate. It must be advanced to a commit that contains **both**: +Only after that, advance to a commit that contains **both**: 1. The PR #59 vuln substrate (M1–M3 commits `305de47`, `c1716e8`, `75550d0`), and -2. This rollout (the four drafted vuln units + the Phase 1/Phase 2 CLI subcommands). +2. This rollout merged to canonical `main` (the four vuln units + the Phase 1/Phase 2 + CLI subcommands implemented on `claude/vuln-rollout-prep`, commit `83ede4b`). -The advance is a **single-owner** action. **No one but the single owner runs -`git pull` / `git reset` / `git checkout` on the host.** +The advance is a **single-owner** action: `git -C ~/security-scanner fetch github` then +`git checkout main && git reset --hard github/main` (or fast-forward). **No one but the +single owner runs `git pull` / `git reset` / `git checkout` on the host.** Verify (read-only; safe for anyone to run): @@ -88,8 +119,8 @@ ship today. Confirm they exist before enabling: ```bash cd ~/security-scanner -uv run security-scanner scan-vuln --help # must accept --from-catalog, --artifact-dir, --semgrep-binary, --notification-log -uv run security-scanner vuln-freshness-eval --help # subcommand must exist (artifact-walking evaluator) +uv run security-scanner scan-vuln --help # must accept --from-catalog, --artifact-dir, --cache-root, --scan-run-id, --semgrep-binary +uv run security-scanner vuln-freshness-eval --help # subcommand must exist; accepts --artifact-dir, --scan-cadence-hours, --margin-hours, --backlog-alert-threshold, --notification-log ``` If either `--help` errors with "invalid choice" / "unrecognized arguments", **STOP** —