Audit-hardening pass for secret and misconfiguration scanning (#977)

* fix(security): dedupe concurrent compose-stack scans

Track stack scans in scanningImages keyed stack:<nodeId>:<stackName>.
The /scan/stack route returns 409 when an in-flight scan exists, and
the service-side check is the real correctness barrier (the route
pre-check is a fast-path optimization that mirrors scanImage). The
dedup key release lives in a try/finally so failed scans free the
slot for retry.

Why: scanComposeStack had no equivalent of scanImage's scanningImages
guard, so two simultaneous calls for the same stack would both run
trivy config, both insert a vulnerability_scans row, and double-
process the result.

* feat(security): acknowledge misconfig findings

Adds a parallel acknowledgement system for Trivy misconfig findings
that mirrors cve_suppressions: a new misconfig_acknowledgements table,
read-time enrichment via the new misconfig-ack-filter utility, REST
CRUD endpoints, fleet-sync replication from control to replicas, a
Settings panel, and an Acknowledge button on the Misconfigs tab.

Schema and behavior parity with cve_suppressions:
  - UNIQUE(rule_id, COALESCE(stack_pattern, '')) so fleet-wide acks
    collide as expected
  - blockIfReplica on every write
  - Audit-log entries name the scope (rule_id, stack_pattern) but
    never the reason text
  - replicated_from_control flag controls UI delete affordance and
    drives clearReplicatedRows on demote/reanchor
  - Validators reused: validateStackPatternForRedos for glob safety,
    sanitizeForLog for log fragments

SARIF export emits an external/accepted suppression entry per
acknowledged misconfig, matching the CVE pattern.

Per-row Acknowledge dialog prefills stack_pattern with the scan's
stack_context so the default scope is "rule + this stack only" and an
operator must broaden explicitly.

Tests: misconfig-ack-filter (15) and misconfig-ack-routes (23)
including the duplicate-409 case for both pinned and fleet-wide acks.

* fix(security): reap orphaned trivy tmp dirs at startup

When the buildEnv path writes a per-scan DOCKER_CONFIG dir under
os.tmpdir() and the process crashes before the finally block runs,
the dir leaks. Mirrors GitSourceService.sweepStaleTempDirs:
exported sweepStaleTrivyTempDirs is fire-and-forget at boot,
removes prefix-matching dirs older than 1 hour, swallows
permission/race failures, logs a single line if any were reaped.

* perf(security): emit per-batch summary for scanAllNodeImages

Adds one diag() line at the end of scanAllNodeImages summarising
unique image count, scanned, skipped, failed, violation count, and
elapsed time. Per-image diag inside scanImage stays useful for
debugging individual scans; the summary gives operators a single
fleet-level checkpoint when developer_mode is on.

* perf(security): cap SARIF export at 5000 findings per type

Replace the unbounded fetchAllPages walk on /scans/:id/sarif with a
hard limit of 5000 findings per type. When any type trips the cap,
emit run-level properties.truncated=true plus row_limit and per-type
totals so downstream tooling can flag the export as partial.
Console-warns for ops visibility.

A scan with 50k vulns previously streamed every row into memory
before serialising; the cap bounds memory and serialisation time at
the cost of completeness on pathological scans.

* docs(env): document TRIVY_BIN host-binary override

The env var is honored by TrivyService.detectTrivy as a fallback when
no managed install is present, but it was undocumented in
.env.example. Adds the var with a comment explaining precedence
(managed > TRIVY_BIN > PATH).

* test(security): cover scanComposeStack failure modes

Two new cases drive the existing try/catch through real failure
paths:
  - Malformed Trivy stdout: row flips to status='failed' with the
    parser error preserved on `error`.
  - execFile rejection: row flips to status='failed' with a string
    error message.

Pairs with the existing dedup tests so the failure path now also
verifies the scan row state, not just the thrown exception.

* test(e2e): security scanner + misconfig acknowledgement flow

Seven Playwright tests covering the scanner UI and the new
acknowledgement system end-to-end:
  - Trivy availability gate (skips suite when binary absent so CI
    without Trivy can opt out via E2E_SKIP_TRIVY=1)
  - Stack config scan completes and records misconfig findings
  - Concurrent stack scan returns 409 from the dedup gate
  - Misconfig ack POST creates and lists on Settings
  - Duplicate (rule_id, stack_pattern) returns 409
  - Malformed rule_id (shell metacharacters) returns 400
  - Misconfigs tab renders against a real stack scan

Tests drive the API for behaviour assertions and the UI only for
shell-rendering checks; the visual snapshot suite owns screenshots.

* docs(features): add misconfig acknowledgement workflow and SARIF cap

Refreshes vulnerability-scanning.mdx with:
  - Misconfig acknowledgements section covering the per-row dialog,
    Settings panel, scope/matching rules, and SARIF emission
  - Tier table row for the new feature
  - SARIF section note on the 5000 row-per-type cap and the
    properties.truncated marker for partial exports
  - Troubleshooting entries: SARIF cap, hidden Acknowledge button,
    findings resurfacing after delete, Trivy DB phone-home, and
    409 on concurrent compose-stack scans

* fix(ci): clear backend lint and CodeQL alerts

- Remove the dead fetchAllPages helper in routes/security.ts. It lost
  its callers when the SARIF endpoint switched to direct paged reads
  for the truncation cap. ESLint flagged it as unused.
- Switch the trivy-tmp-cleanup test helper to fs.mkdtempSync. Building
  paths under os.tmpdir() with predictable names tripped CodeQL's
  js/insecure-temporary-file rule (high severity), which warns about
  symlink-pre-creation attacks even in test code. mkdtempSync appends
  a process-random suffix and creates the dir atomically; the
  sencho-trivy- prefix is preserved so the production sweep still
  matches the test fixtures.
This commit is contained in:
Anso
2026-05-07 19:23:11 -04:00
committed by GitHub
parent 4b1de35dda
commit 3b650523c1
20 changed files with 2376 additions and 162 deletions
+59
View File
@@ -30,6 +30,7 @@ The Trivy CLI must be available on the machine running Sencho. Trivy is not bund
| Compose file misconfiguration scanning | ✓ | ✓ | ✓ |
| Scan history and comparison | ✓ | ✓ | ✓ |
| CVE suppressions | ✓ | ✓ | ✓ |
| Misconfig acknowledgements | ✓ | ✓ | ✓ |
| Scheduled fleet scans (all images on a node) | | ✓ | ✓ |
| Scan policies with `block_on_deploy` enforcement | | ✓ | ✓ |
| SBOM generation (SPDX, CycloneDX) | | ✓ | ✓ |
@@ -263,6 +264,41 @@ From any stack page, click **Scan config** next to the Deploy controls. Sencho r
Config scans are stored in the same history as image scans with an `image_ref` of `stack:<name>`, so they appear on the Scan history page and can be exported as CSV.
## Misconfig acknowledgements
Some misconfigurations are intentional. A reverse-proxy stack legitimately needs root to bind privileged ports; a network monitor might require host networking; an `--privileged` Docker socket mount might be exactly what your janitor service expects. Sencho lets admins acknowledge a rule so it stops triggering alerts without lowering the policy bar for every other stack.
Acknowledgements never modify stored finding rows. They are applied at read time, so deleting an acknowledgement immediately resurfaces the finding wherever it appears.
### Acknowledging from a scan result
1. Open a stack config scan that contains the finding.
2. Click the shield-check icon at the right edge of the misconfig row. The dialog opens with the rule id prefilled and the stack pattern set to the current stack name (so a single click acknowledges *only this stack* — narrowest possible scope by default).
3. Add a reason explaining why the misconfiguration is accepted. The reason is stored locally and replicated fleet-wide; it never appears in audit-log summaries to avoid leaking incident-tracker IDs or vendor secrets.
4. Optionally set an expiry in days. After expiry the acknowledgement stops applying and the finding resurfaces.
The acknowledged row renders dimmed with a strikethrough title; hovering surfaces the acknowledgement reason.
### Managing acknowledgements
**Settings > Security** has a panel listing every acknowledgement on this control: rule id, optional stack pattern (glob), creator, expiry date, and a delete button. The same `replicated` badge that appears on CVE suppressions appears here for rows pushed from the control to a replica.
Replicas show the panel read-only — write operations return 403 with a "managed by control" message so configuration drift cannot accumulate on the leaf nodes.
### Scope and matching
An acknowledgement matches a misconfig finding when:
- `rule_id` equals the finding's `rule_id` (exact match), and
- `stack_pattern` is null **or** matches the scan's stack name via the same glob syntax used elsewhere (e.g. `traefik`, `web-*`), and
- the acknowledgement has not expired.
When more than one acknowledgement could match, Sencho picks the most specific: a stack-pinned ack beats a fleet-wide ack for the same rule.
### SARIF emission
Acknowledged misconfigs are emitted in the SARIF export with a `suppressions` entry of kind `external` and status `accepted`, mirroring CVE suppressions. Code-scanning dashboards that respect SARIF suppressions will dismiss them with the recorded justification.
## SARIF export
<Note>
@@ -279,6 +315,9 @@ What the export contains:
- **Secrets**: rule IDs are namespaced as `SECRET:<rule>`. Results point at the file and line number where the match was found.
- **Misconfigs**: rule IDs are namespaced as `MISCONFIG:<rule>`. Results point at the Compose file that triggered the check.
- **Suppressions**: CVEs you have suppressed in Sencho are emitted with a SARIF `suppressions` entry of kind `external` and status `accepted`, so code-scanning dashboards can dismiss them with the justification you recorded.
- **Acknowledged misconfigs**: misconfigs you have acknowledged in Sencho are emitted with the same `suppressions` shape so dashboards apply the same dismissal logic.
The export caps each finding type at 5000 rows to bound memory and serialisation time on pathological scans. When any type trips the cap, the SARIF run carries `properties.truncated = true`, `properties.row_limit`, and a `properties.totals` object with the original counts so downstream tooling can flag the export as partial.
Typical upload flow for GitHub code scanning:
@@ -414,3 +453,23 @@ The scanner needs to locate a Compose file in the stack directory. If the stack
### SARIF download returns 409 "Scan not complete"
SARIF export requires a completed scan. If a scan failed, timed out, or is still running, the button downloads nothing and the server returns a 409. Trigger a fresh scan from the Resources Hub or the stack page, wait for the drawer to populate, then export again.
### SARIF download is missing findings I see in the drawer
Each finding type (vulnerabilities, secrets, misconfigs) is capped at 5000 rows in the SARIF export. When any type trips the cap, the SARIF run includes `properties.truncated: true` along with the original counts, and the server logs a warning. The drawer paginates beyond 5000 so it shows everything, but the export is bounded for memory safety. If you need every row, narrow the scope (per-stack scan, per-image scan) before exporting.
### Acknowledge button is missing on a misconfig finding
The button is admin-only and hidden on replica nodes. Replicas read acknowledgements from the control via fleet sync; mutations must happen on the control. If you are an admin on the control and still do not see the button, the row is likely already acknowledged — look for the dimmed/strikethrough rendering and hover for the recorded reason.
### Findings reappeared after deleting a CVE suppression or misconfig acknowledgement
Suppressions and acknowledgements are applied at read time and never modify the stored finding rows. Removing one immediately resurfaces the underlying finding everywhere it appears (drawer, compare sheet, badge counts, SARIF export). The behaviour is intentional: deleting an acknowledgement is meant to revert the operator decision, not to mask history.
### Outbound traffic to ghcr.io / aquasecurity from the scanner host
Trivy itself fetches its CVE and secret-rule database from public registries on first scan and refreshes periodically; that egress is required for vulnerability scanning to work. Sencho does not emit telemetry of its own. If your environment forbids egress, see Trivy's [air-gapped scanning guide](https://aquasecurity.github.io/trivy/latest/docs/advanced/air-gap/) for how to pre-seed the database and run scans with `--offline-scan`.
### Compose stack scan returns 409 "Already scanning this stack"
Sencho deduplicates concurrent scans of the same stack so two simultaneous calls cannot double-process the result. Wait for the in-flight scan to finish (its row appears in Scan history with status `in_progress` and flips to `completed` or `failed` shortly after) and trigger again.