- Python 55.9%
- Typst 25.5%
- Shell 16.4%
- Just 1.8%
- Mermaid 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
## Summary
The report described the WireGuard unit's software and role but never said its installation is declared and owned by configuration, and it carried the unit's scanner hits without saying where they come from or how to read them. This change states the dse-ansible declaration beside the verified host state, names the 2021 scanner entries together with the vendor's own explanation of why a correctly patched host still shows them, and dismisses CVE-2021-3695 in the scanner through a committed VEX document — while keeping every dismissal visible in both derivation lanes and in a new report table.
## What changed
- `reports/defender-triage.typ`: state the IaC declaration (pinned `githubixx.ansible_role_wireguard` 19.2.0, `playbooks/playbook-wireguard.yaml` with wg0 / 51820 / endpoint / 10.15.5.1/24 / ULA / MTU 1280 with MSS clamping, peers in `host_vars`, cutover runbook) beside the verified host state, and contrast it with the unowned `packages-microsoft-com-prod`; add the WireGuard Trivy passage (findings come from Trivy, Red Hat's FAQ as the mechanism authority, the mirror-image reading, the verified grub2 position, the epoch root cause, the checked-not-waved discipline, the kernel-exposure guard); add the data-driven «Abgewiesene Fundstellen (VEX)» table.
- `config/vex/alma-backported-fixes.vex.json`: the committed VEX fallback for CVE-2021-3695 on the eight grub2 purls, status `fixed`, each statement carrying the erratum and the review horizon. The stale-advisory reading is scoped to the individually checked entries and states explicitly that it says nothing about the unit's wider Trivy set.
- `scripts/scan_trivy.sh`: add `--vex <file>`, `--vuln-severity-source redhat` and `--show-suppressed`; record the dismissed findings (status, source, and the document's `impact_statement`) in the per-unit payload; document the VEX obstacle and the epoch root cause in the header; key the cache on a `scanConfig` signature (with a `format=` token) so a stale cache is re-scanned instead of silently reused.
- `scripts/derive_findings.py` and `src/dse_security_reports/derive.py`: carry `vexDismissed` per evidence unit and the payload-level `vex` aggregate in both lanes, and expose the VEX/severity source in `scanMeta`.
- `tests/test_derive.py`: cover the dismissal join, the `vex` aggregate and the new scan metadata.
- `AGENTS.md`: record the scan-lane VEX/severity mechanism, the VEX obstacle, the epoch root cause and the anti-invisibility rule.
## Behaviour change
The scan no longer reports CVE-2021-3695 on the WireGuard unit as a live finding; it records it as a dismissed entry, and the report shows that dismissal with its VEX status, its source document and the erratum that backs it. Severities follow Red Hat where Red Hat has an opinion. Nothing suppressed can become invisible: the dismissal survives the scan cache, both derivations and the render.
## Evidence
**The instructed vendor-VEX route does not work, and the obstacle was proven rather than assumed** (the header of `scripts/scan_trivy.sh` records all of it):
- `--vex repo` (aquasecurity/vexhub) parses and fetches, but holds no statement matching our `pkg:rpm/alma/...` products — 866 findings unchanged — and it writes its config to `~/.trivy/vex/repository.yaml`, i.e. outside the worktree.
- Red Hat's real VEX document for the CVE (`security.access.redhat.com/data/csaf/v2/vex/2021/cve-2021-3695.json`, category `csaf_vex`, fixed 194 / known_affected 22 / known_not_affected 58) is parsed but cannot match: its `product_identification_helper` carries **neither purls nor CPEs**, only Red Hat-internal product ids.
- AlmaLinux publishes no CSAF/VEX feed (all probed endpoints 404).
So the VEX source is **a stated fallback, not vendor data**: it encodes the vendor's own decision (RHSA-2022:5099 fixed grub2 for RHEL 9 on 2022-06-16) against the exact installed purls, and pins them, so a grub2 upgrade stops matching and the finding returns for review.
**Before/after on CVE-2021-3695** (`ASP-DSE-PROD-WIREGUARD`, offline, cached DB):
- without VEX: 866 detections, the CVE present as HIGH on all eight grub2 packages;
- with VEX: 858 detections, the CVE absent from the findings and present as eight `fixed` dismissals (`config/vex/alma-backported-fixes.vex.json`);
- the same statement applies on the three other el9 units that share the build (4 dismissals each on DEV-MISC, 8 each on DEV-WORKBENCH and PROD-WORKBENCH); the four el10 units: 0.
**Vendor severity source** (`--vuln-severity-source redhat`): CVE-2021-3695 HIGH → MEDIUM; the unit's severity mix moves from 461 HIGH / 358 MEDIUM / 47 LOW to 224 HIGH / 471 MEDIUM / 163 LOW.
**The root cause is ours, and it was found while proving the dismissal:** `scripts/build_cyclonedx_sbom.py` builds the purl from `rpm -qa`, whose version string carries **no RPM epoch**, while the Alma fixed version does (`1:2.06-27.el9_0.7.alma`). trivy therefore compares epoch 0 against epoch 1 and reports the package although the installed release (126) is far newer than the fixed one (27). Suffixing the eight grub2 purls with `?epoch=1` removes **all 200 grub2 findings of the unit with no suppression at all** (866 → 666; all 200 removed entries are grub2, 8 packages × 25 CVEs, no additions). Carrying the epoch is the durable fix and needs `scripts/ssh_evidence.sh` to collect `%{EPOCHNUM}` — deliberately not done here, because it re-baselines the whole scan lane and needs a fresh evidence round.
**Status of the other entries examined under the same reasoning** (checked, **not** added to the VEX document — no blanket suppression):
| CVE | packages | installed | trivy "fixed" | fix present with epoch honoured? |
|---|---|---|---|---|
| CVE-2021-25220 | bind-libs, bind-license, bind-utils | 9.16.23-40.el9_8.12 | 32:9.16.23-5.el9_1 | yes (40 > 5) |
| CVE-2021-25220 | dhcp-client, dhcp-common | 4.4.2-20.b1.el9 | 12:4.4.2-17.b1.el9 | yes (20 > 17) |
| CVE-2021-3507 / CVE-2021-3611 | qemu-guest-agent | 10.1.0-17.el9_8.5.alma.1 | 17:7.0.0-13.el9 | yes (10.1.0 > 7.0.0) |
Every one of them has the installed build above the vendor's fixed version once the epoch counts, so the same reading holds — but the decision to suppress them belongs to the captain, not to this change.
**Validation** (all on the final tree): `just all` ✓ (fixture lane; the empty-dismissal branch renders), `just repro` ✓ (byte-identical PDFs for both reports), `just test` ✓ (39 passed), `just lint` ✓, `just py-crosscheck` ✓ (both derivation lanes byte-identical). The fixture lane stays offline; the re-scan ran offline against the cached DB (`--skip-db-update`). The PDFs were re-rendered and re-published (`defender-triage-2026-10-28-1517.pdf` plus the bare alias, likewise for findings); previous copies were moved to `artifacts/superseded/`; the 2026-10-26 set was not touched, and the republished files are byte-identical to a fresh render from this tree.
## Scope note: the instructed premise was corrected by the check
The brief asked the report to say that the unit's Trivy findings *all* date from 2021 and that CVE-2021-3695 was *the only* HIGH. The check disproved both, which is why the passage is scoped the way it is:
- The cache read for this change (`ASP-DSE-PROD-WIREGUARD`, the pre-change copy under `.local/scans-prev28/`) holds **866 findings over 277 CVEs, 461 of them HIGH**, by year 2021:39, 2022:175, 2023:128, 2024:112, 2025:109, 2026:303.
- **24 of the 39 2021 entries are HIGH** — CVE-2021-3695, CVE-2021-3696 and CVE-2021-3697 on the same grub2 packages among them.
- Firstmate's independent count of the same file agreed on the decisive part (2021:39 findings, 24 HIGH, matching this check exactly) while reading a different DB state for 2026 (their totals 998 findings / 295 CVEs / 597 HIGH), so the 2026 difference is advisory-data drift between the two checkouts, not a disagreement about the 2021 set.
So the report names the checked entries, says each was verified individually rather than assumed to belong to a class, and states that the unit's wider Trivy set is large and largely unexamined — never that it has been dismissed.
## Open items for the captain
1. **Durable epoch fix** (recommended): collect `%{EPOCHNUM}` in `scripts/ssh_evidence.sh` and emit the `?epoch=` qualifier in `scripts/build_cyclonedx_sbom.py`. That removes the whole class of false positives at the root and needs a fresh evidence round.
2. **The committed claim «ihre Trivy-Funde sind durchgehend mittelschwer»** (PR 26/27, two places) does not survive the data: the unit's Trivy set holds 224 HIGH entries after the vendor-severity change, 461 before it. Flagged, not changed here — the wording is the captain's.
3. Whether to extend the dismissal to the bind/dhcp/qemu entries in the table above.
Instructions-PR: none
Reviewed-on: #28
|
||
| config | ||
| reports | ||
| scripts | ||
| src/dse_security_reports | ||
| tests | ||
| .gitignore | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| Justfile | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
dse-security-reports
Deterministic, data-driven Typst reports over Microsoft Defender vulnerability exports (Alliance Swiss Pass DSE).
The pipeline follows the deterministic approach of asp/cli-cloud-bi: a Justfile drives the recipes, data-driven .typ templates read derived JSON, and typst compile --root . --creation-timestamp 0 renders byte-identical PDFs.
Sensitive data handling
All real Defender exports, triage annotations (triage.csv sidecar), SSH evidence, trivy scans, SBOMs and everything derived from them live under .local/ (gitignored; *.pdf is ignored as well).
Nothing real is ever committed; the committed pipeline runs entirely on a synthetic, deterministic fixture (just fixture).
Real exports carry the capture date in their filename (ASP_Defender_*_YYYY-MM-DD.csv) and are placed in .local/real/ for just ingest; artifacts copied out for the captain's records keep that date in their name (see "Regenerating the captain's record artifacts").
Pipeline
The Justfile is the reference; this is the map.
just fixture— writes small synthetic CSVs of both export shapes plus a triage sidecar to.local/samples/(fakeSYNTH-*devices, impossibleCVE-0000-*ids, clearly marked synthetic).just ingest— installs exactly oneASP_Defender_Critical_Findings_*.csvand oneASP_Defender_CVSS_*.csvfrom.local/real/as the active samples and derives from them.just derive— deterministic stdlib derivation (scripts/derive_findings.py) from CSV to canonical JSON in.local/derived/; joins the evidence, scan, CVE-link and systems caches when present, and keeps evidence-covered units that have no Defender rows as explicit coverage gaps.just cve-links— manual, once: fetches the Red Hat/AlmaLinux CVE link cache into.local/cve-cache/, so later derives stay offline.just evidence— manual, once: collects SSH evidence per reachable Linux unit into.local/evidence/<machine>.json(running kernel, installed kernel packages, pip inventories over application venvs, dnf timers, pending security updates with failure reason, the mdatp sensor state incl. its health, and the root-only Ricochet deployment stores); endpoint and account come fromconfig/systems.json.just backfill-verified— manual, once per restored round: merges the verified 2026-10-28 probe results (Ricochet-store facts, mdatp health, security-check failure reasons) into a restored 2026-10-27 evidence cache; idempotent, andscripts/ssh_evidence.shwrites the same shapes in future rounds so the bridge retires itself.just ssh-<short>— interactive SSH shorthand per Linux unit (short = lowercased machine name without theASP-prefix, e.g.just ssh-dse-prod-wireguard); the endpoint resolves at run time via the same shared resolution asjust evidence(scripts/ssh_common.sh), so nothing connection-related lives in the Justfile.just ssh-list— lists every SSH shorthand with its resolved endpoint and the command that would run, without connecting.just scan— manual, once: runs trivy over the evidence inventories into.local/scans/<machine>.json(vulnerability DB cached once under.local/trivy/), joined by derive into the scanner-agreement dimension.just sbom— manual, once: emits a deterministic CycloneDX 1.7 SBOM per unit into.local/sbom/<machine>.jsonfrom the same cached DB.just report— rendersreports/defender-findings.typto.local/out/defender-findings.pdf.just triage— rendersreports/defender-triage.typto.local/out/defender-triage.pdf; all its tables are generated from the derived JSONs.just az-refresh— manual, once: refreshes the systems mapping live viaaz vm listinto.local/systems-az.json, merged field-wise over the committed baseline during derive.just all— fixture + derive + both reports on the synthetic fixture.just repro— derives and renders both reports twice and compares the PDF bytes (cmp).just local— lists the local (gitignored) pipeline artifacts.
Requirements: just, python3, typst (standard on the DSE workstation profile); uv for the Python data lane; trivy only for the optional scan/SBOM stages.
Python data lane (polars)
src/dse_security_reports mirrors the stdlib derivation byte-for-byte on the fixture, under uv with a committed uv.lock.
just install syncs the environment, just test runs the pytest suite, just lint / just fmt run ruff on src/tests only.
just py-derive derives into .local/derived-polars/, just py-repro checks its determinism, and just py-crosscheck compares both lanes for identical JSON bytes.
Unit registry (config/systems.json)
One entry per match key carries resource group, location, category, subcategory (cli-cloud-bi style), domain (DSE vs ASP) and an SSH endpoint (sshAlias: an ssh.cfg alias or a raw address).
Derive resolves exact match first, then longest prefix on the short machine name.
just az-refresh merges live values over that baseline; a match key the baseline lacks crashes the merge, so add the baseline entry first when a genuinely new Azure VM appears.
The Windows on-prem units are SSH-unreachable and simply lack evidence.
Application lock pins (config/app-locks.json)
The committed pins of the deployed application projects (tracking-quality on QMS, myride_simulation on MYRIDE): per lock file (root uv.lock and the deployed src/app/uv.lock) which package is pinned at which version, read when, last committed when, and which member lock the deployment resolves from.
The derive joins these pins against the Ricochet store sweeps into the payload-level ricochet aggregate; the triage report renders it as the deployed-vs-pinned table.
A refused or un-swept store stays a stated gap, never an absence.
Reports and theme
Two German Typst reports render from the derived JSON: the findings report and the triage report, whose curated problem rows stay in the template while every cell value is derived.
Both use the standard library only, no @preview imports, and share the theme of the captain's cost report in asp/cli-cloud-bi/cost-overview/asp-cost-report.typ.
The reports state that the two exports use different filters and are never a temporal comparison, and that Defender findings may be false positives, so they are an analysis and triage aid, never a verdict.
Regenerating the captain's record artifacts
Real-data runs happen in the primary checkout; the disposable worktrees only ever see the synthetic fixture.
After a real-data run, the sensitive deliverables are copied into the private firstmate artifacts dir (~/firstmate/data/dse-security-reports-triage/artifacts/, outside the repo and never committed), with date-stamped PDF names:
# render both reports from the active real derivation
just report
just triage
# copy the PDFs (and, on a fresh evidence/scan round, the caches) into the private artifacts dir
cp .local/out/defender-findings.pdf ~/firstmate/data/dse-security-reports-triage/artifacts/
cp .local/out/defender-triage.pdf ~/firstmate/data/dse-security-reports-triage/artifacts/defender-triage-YYYY-MM-DD.pdf
The committed reports/defender-triage.typ is the only source of truth for the triage report; do not keep hand-edited template copies in the artifacts dir.
Planned follow-ups (out of scope of the pipeline)
- Steer parallel analysis agents on the derived dispatch structure (per system and per CVE), each producing a verifiable ~10-simple-sentence analysis hint per problem.
- Challenge the existing
dse-ansibleanddse-opentofusetups against the findings;azCLI and the Azure pi extensions are approved for these analysis stages (the pipeline itself stays offline and deterministic). - Optional Azure API integration for fetching the exports.