Skip to content

Artifact review: stop the credential at the API boundary (v2.8.0) - #5

Merged
azmolhaque merged 1 commit into
mainfrom
claude/new-session-thsmic
Jul 30, 2026
Merged

Artifact review: stop the credential at the API boundary (v2.8.0)#5
azmolhaque merged 1 commit into
mainfrom
claude/new-session-thsmic

Conversation

@azmolhaque

Copy link
Copy Markdown
Owner

Summary

Driven by reading the artifacts of a real v2.7.9 deep scan side by side — the dashboard modal, the Discord alert, and the SARIF/CSV/HTML exports of the same three findings. v2.7.9 fixed redaction in the reports; comparing the surfaces showed how much of that job was left undone everywhere else, and turned up two correctness bugs that had nothing to do with display.

The two that produce a wrong answer, not a wrong presentation

A re-scan of an unchanged site reported CLEAN while the credential was still exposed. The asset cache treated a 304 Not Modified on the root page as "unchanged and previously clean, skip" — but an HTML page is a link graph, not just something to grep. Skipping its body meant never parsing its <script> tags, so every JS bundle it referenced dropped out of the scan. Reproduced against a lab target with a planted key: scan 1 found it, scan 2 never requested the file holding it and reported no findings. Crawled pages now always come back with a body (allow_cache_skip=False); the conditional GET is still sent, so the bandwidth saving on unchanged terminal assets (JS bundles, source maps) is untouched.

Deep scans inherited asset-cache state from whatever ran before them in the same process. The cache is module-level and only the single-target endpoint primes it, so a deep scan could report a previous scan's cache hits as its own coverage — and act on a stale validator. Deep scans now start from an empty cache, and run_scan resets the hit tally alongside the throttle.

The credential-exposure set

  • The dashboard rendered the full credential. redact_secret() was added to report.py in v2.7.9 and nowhere else, so /api/scans/{id} and the WebSocket stream shipped raw_match verbatim and the finding-detail modal displayed it — under a heading reading "MATCHED VALUE (PARTIAL)" that a 51-character key comfortably fits inside. Redaction now happens at the API boundary (public_scan / public_event) using a mask_secret() with no opt-out. REPORT_FULL_SECRETS remains available for a report an operator deliberately generates, but no longer unmasks every dashboard session and WebSocket subscriber.
  • The scan_complete event carried both unmasked finding lists. Found by capturing a live WebSocket stream rather than by reading the code: per-finding events were being scrubbed, but the final frame of every scan ships the entire result dict.
  • The code snippet leaked the secret the matched-value field was hiding. context_snippet was stored and served verbatim. Now masked in ValidatedFinding.to_dict(), where the complete value is still available to match against — masking downstream from the 80-character raw_match cap would leave the tail of a longer secret exposed.
  • The JSON export was the one deliverable format still containing live keys. HTML, CSV and SARIF all redacted; format=json returned the stored record as-is.

The consistency set

  • The dashboard showed MEDIUM for findings the reports called HIGH. The frontend re-derived severity from a hardcoded list of 14 secret type names; every detector added since — the entire AI/ML provider family — fell through to the MEDIUM default. It now reads the severity field the backend already computes from the pattern registry. A missing .badge-low style meant LOW findings rendered unstyled.
  • Discord announced "SecretNode v2.4.0" for five releases, and coloured every embed from a per-type table covering 16 of 60+ detectors, so an ElevenLabs key and an AWS root key arrived looking identical. Version is single-sourced in backend/version.py; embed colour is keyed on effective severity, and severity is stated as a field.
  • Deep-scan reports lost every scan-level metric. DeepScanResult.to_dict() never emitted assets_fetched, which is why a SARIF from a 25-host run claimed assets_fetched: 0; the same omission dropped raw_findings, duration_seconds, posture findings and the verification counts. All rolled up, with duration measured as wall-clock rather than summed across concurrently scanned hosts.
  • A fully-cached re-scan reported "0 assets analysed" — true as a download count, wrong as coverage. Split into assets_fetched / assets_cached / assets_scanned; reports lead with coverage and name the cached portion.
  • Deep-scan HTML gains the redacted matched value, verification status and impact per finding, plus an asset-coverage stat. SARIF results carry matched_value_partial, found_at and the originating host so a triager can correlate against the CSV row and the Discord alert.

Type of change

  • Bug fix
  • New secret pattern / detector
  • New feature
  • Docs / tooling

Checklist

  • ruff check backend/ passes
  • pytest passes (added/updated tests for the change) — 347 → 382
  • New detectors include severity, cwe, and remediation — n/a, no new detectors
  • No real credentials, live targets, or exploitation code introduced
  • Scanner remains passive (no use of discovered credentials, no writes to targets)

Verification

Beyond the unit suite, this was exercised against a local lab target with a planted, correctly-shaped key — which is how three of the eight bugs were found in the first place:

fetched cached scanned findings leaks
scan 1 (cold) 3 0 3 1 (HIGH) none
scan 2 (unchanged) 2 1 3 1 (HIGH) none
scan 3 (unchanged) 2 1 3 1 (HIGH) none

The finding survives re-scans (no false all-clear), the clean unchanged bundle is still skipped (the cache optimisation is intact), and the key appears in none of: the WebSocket stream, /api/scans/{id}, or the JSON/CSV/SARIF/HTML exports.

The leak guards are mutation-tested — each fix was reverted and the corresponding tests confirmed to fail — because a redaction test that passes with redaction switched off is testing nothing.

Detection benchmark: precision 1.000, recall 1.000, quality gate PASS.

🤖 Generated with Claude Code

https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E


Generated by Claude Code

Driven by reading the artifacts of a real v2.7.9 deep scan side by side — the
dashboard modal, the Discord alert, and the SARIF/CSV/HTML of the same three
findings. v2.7.9 fixed redaction in the reports; comparing the surfaces showed
how much of that job was left undone everywhere else, and turned up two
correctness bugs that had nothing to do with display.

A re-scan of an unchanged site reported CLEAN while the credential was still
exposed. The asset cache treated a 304 on the root page as "unchanged and
previously clean, skip", but a page is a link graph — skipping its body meant
never parsing its <script> tags, so every bundle it referenced dropped out of
the scan. Reproduced on a lab target: scan 1 found a planted key, scan 2 never
requested the file holding it. Crawled pages now always return a body; the
conditional GET is still sent, so unchanged terminal assets still skip.

The dashboard rendered the full credential. redact_secret() landed in report.py
only, so the REST API and WebSocket shipped raw_match verbatim and the modal
displayed it under a "MATCHED VALUE (PARTIAL)" heading a 51-character key fits
inside. Masking now happens at the API boundary with no opt-out;
REPORT_FULL_SECRETS still works for a report an operator deliberately generates.
Capturing a live WebSocket stream also caught scan_complete shipping both
unmasked finding lists in the final frame of every scan, and the code snippet
carrying the secret the matched-value field was hiding. The JSON export was the
one deliverable format still returning the stored record as-is.

The dashboard showed MEDIUM for findings the reports called HIGH: severity was
re-derived in the frontend from a hardcoded list of 14 type names, so every
detector added since — the whole AI/ML provider family — hit the MEDIUM default.
It now reads the field the backend already computes. Discord had the same shape
of bug twice: a version literal stale since 2.4.0, and a per-type colour table
covering 16 of 60+ detectors.

Deep-scan reports lost every scan-level metric, which is why a 25-host SARIF
claimed assets_fetched: 0. Rolled up, with duration as wall-clock rather than
summed across concurrent hosts. Asset counting now separates downloaded from
cached-but-covered, so a fully-cached re-scan no longer reports "0 assets
analysed". Deep scans also start from an empty asset cache instead of
inheriting module state from whatever ran before them.

Suite 347 -> 382, including a mutation check that the leak guards can fail and
a reproduction of the false all-clear. Lint clean, detection benchmark 1.00/1.00.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E
@azmolhaque
azmolhaque merged commit fd533b4 into main Jul 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants