Skip to content

v2.7.2–2.7.9: AI/ML detectors, impact-rich verification, mobile UX, resilience, caching — and eight bug fixes - #4

Merged
azmolhaque merged 8 commits into
mainfrom
claude/new-session-thsmic
Jul 30, 2026
Merged

v2.7.2–2.7.9: AI/ML detectors, impact-rich verification, mobile UX, resilience, caching — and eight bug fixes#4
azmolhaque merged 8 commits into
mainfrom
claude/new-session-thsmic

Conversation

@azmolhaque

Copy link
Copy Markdown
Owner

Summary

Eight sequenced releases taking SecretNode from v2.7.1 → v2.7.9. Each is a self-contained, tested slice rather than one sweeping rewrite — the working tool stayed green throughout.

The work was grounded in a real authorized-scope scan: an ElevenLabs key shipped in a client-side EnvConfig.js was caught only by the generic catch-all, so it was both mis-typed (untyped MEDIUM) and double-counted. Closing that one gap surfaced most of what follows.

Before After
Detectors 54 63
Verifiers 22 29
Tests 241 353
Bench corpus 27 45

Releases

Version Change
2.7.2 AI/ML provider detector pack (9 patterns) + duplicate-finding fix
2.7.3 R1 complete — impact-rich verification for AI/ML keys
2.7.4 Mobile & tablet UX for the live dashboard
2.7.5 Scan politeness & resilience
2.7.6 Inline SSR state decoding
2.7.7 R10 — asset caching for re-scans
2.7.8 Pre-release review: three bugs, quality gate made real
2.7.9 Deep QA: reports no longer leak the credential

Eight bugs fixed

Three were found by reviewing this session's own work, and the two most serious would not have been caught by any test:

  1. Client reports contained the full, live credential. The CSV column was named matched_value_partial but wrote raw_match verbatim; HTML did the same. A report is emailed, forwarded and archived — writing a working secret into one turns the deliverable itself into a second exposure, and contradicts the Rules of Engagement clients sign. Now sk_798…******…3fc4 (51 chars) — greppable enough to identify which key to rotate, not usable.
  2. Retry-After as an HTTP-date silently dropped the asset. RFC 7231 allows a date; a bare float() raised ValueError, the generic handler swallowed it, and the asset was abandoned — a false negative caused by the server behaving correctly.
  3. One credential reported twice. fingerprint includes secret_type, so a value matched by both a provider detector and the generic catch-all double-counted the exposure, spent a second AI-validation call on it, and left two conflicting severities.
  4. Inline-JSON extraction truncated at the first raw < — losing every secret after it in blobs containing prose like "a < b" or embedded HTML.
  5. A previously-dirty asset could be lost on 304 when RETRY_ATTEMPTS=1 — the refetch consumed a retry attempt, so the asset was dropped. This was the exact "a finding silently vanishes" failure the cache exists to prevent.
  6. An unprompted 304 burned a retry and could spin.
  7. xAI reports disabled keys with HTTP 200 — a naive verifier would call a dead key live.
  8. Dashboard version drift — footer and boot log hardcoded to v2.7.1, never touched by the runtime version sync.

The detection quality gate is now real

make bench previously printed a report and always exited 0, and CI never ran it — so detection quality was neither measured nor enforced, and nine new detectors shipped with zero corpus coverage.

  • Corpus 27 → 45, covering all nine AI/ML detectors, the inline-SSR path, and eight hard negatives chosen to be structurally confusable (sk_ + wrong-length hex, a 64-hex SHA digest resembling an OpenRouter key, provider-shaped placeholders).
  • make bench enforces BENCH_MIN_PRECISION / BENCH_MIN_RECALL, names offending samples, exits non-zero.
  • CI runs it, so a precision regression is release-blocking.

Proven end to end: deliberately loosening the ElevenLabs regex drops precision 1.000 → 0.917, names both false positives, and fails the build.

Verified in a running system, not only unit-tested

  • App boots; /api/health reports the right version; dashboard serves.
  • A real scan of a local target found both headline features working live: the ElevenLabs detector, and recovery of an Anthropic key \uXXXX-escaped inside __NEXT_DATA__ with a raw < in the blob (exercising the 2.7.8 fix).
  • Re-scan repopulated findings rather than losing them; asset_cache recorded both assets was_clean=False.
  • HTML / CSV / SARIF all generate with the correct version; SARIF carries all 63 rules. CLI verified.
  • SSRF guard confirmed — refused a localhost target until ALLOW_PRIVATE_TARGETS was explicitly set.
  • Dashboard JS passes node --check after the mobile-layout edits.
  • Mobile UX measured with Playwright across iPhone SE / 14 / Pixel 7 / iPad mini / iPad Pro / desktop: zero page-level horizontal overflow, no touch target under 32px, desktop layout confirmed unchanged.

Two things deliberately not built

  • Detection evasion. The resilience work (UA rotation, jittered backoff, adaptive per-host throttling) exists so authorized scans aren't blocked. Evasion would contradict the RoE promise to "test from an identifiable source you can whitelist", and unattributed scanning triggers a client's SOC. SECRETNODE_USER_AGENT still presents a client-approved agent string.
  • Probing unlinked paths (.env, .git/config, backups). That is active enumeration, not passive discovery, and would contradict the "Passive assessment" statement in every client report. Noted in the roadmap as requiring a separate, clearly-labelled opt-in mode if ever added.

Privacy note: the asset cache stores no response bodies — only validators, a truncated content hash, and a clean/dirty flag — because client JavaScript can contain live credentials. A test asserts no body content is ever persisted.

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) — 353 tests, all green
  • New detectors include severity, cwe, and remediation
  • No real credentials, live targets, or exploitation code introduced — all corpus/test secrets are synthetic; QA artifacts and the QA scan DB were deleted
  • Scanner remains passive (no use of discovered credentials, no writes to targets) — verifiers make one read-only identity call to the credential's own issuer, never to the scan target, and make no inference/generation calls that would bill a victim's account; still off by default behind VERIFY_SECRETS

🤖 Generated with Claude Code

https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E


Generated by Claude Code

azmolhaque and others added 8 commits July 30, 2026 08:30
Grounded in a real authorized-scope scan where an ElevenLabs key in a
client-side EnvConfig.js was caught only by the generic catch-all — so it
was both mis-typed (untyped MEDIUM) and double-counted.

Added: 9 structural AI/ML provider detectors (54 -> 63) — ElevenLabs, Groq,
Hugging Face, Replicate, Perplexity, xAI, OpenRouter, LangSmith, Pinecone.
Prefix + fixed-length shapes keep them high-precision without the generic
entropy gate; ElevenLabs carries provider-specific remediation.

Fixed: one credential matched by both a typed detector and the generic
catch-all produced two findings, because fingerprint includes secret_type.
That double-counted exposures in client reports, spent a second AI-validation
call per secret, and left two conflicting severities. _collapse_generic_
duplicates() now collapses per (source_url, raw_match), keeping the typed
detector. The catch-all still fires for untyped credentials.

Suite 282 -> 285, all green; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E
Closes roadmap item R1 (verification depth). A verified credential no longer
reports a bare "verified" — it reports what the key actually reaches, which is
the concrete blast radius a client report needs.

Adds seven AI/ML provider verifiers, pairing one with every safe detector from
the v2.7.2 pack: ElevenLabs, Groq, Hugging Face, Replicate, OpenRouter, xAI,
Pinecone. Verifier coverage 22 -> 29 secret types.

For AI keys the quantified loss is usually spend, so verifiers surface plan
tier and remaining quota where the provider exposes it:
  ElevenLabs · creator tier · quota 12,345/100,000
  Hugging Face @acme-bot · role: write · 2 org(s)
  OpenRouter · key: prod-key · quota 42/500

xAI reports disabled keys with HTTP 200; the verifier reads api_key_blocked /
api_key_disabled and returns unverified rather than claiming a dead key is live.

Every verifier keeps the existing contract: exactly one read-only identity call
to the credential's own issuer (never the scan target), no writes, no inference
or generation calls (which would bill the victim's account), the secret never
stored or returned, fail-closed on error. Still OFF by default behind
VERIFY_SECRETS=true — authorized-scope only.

Also corrects docs/TECHNICAL-AUDIT-AND-ROADMAP.md, which still listed R1 as an
open gap after it shipped in v2.6.0.

Suite 285 -> 294, all green; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E
The dashboard is how a client first sees SecretNode, and it is increasingly
opened on a phone. The findings table had nine columns, so on a phone the two
things an operator actually needs — the severity badge and the DETAIL / FP
buttons — sat off-screen behind horizontal scrolling.

Findings table now becomes cards below 900px. Each row renders as a
self-contained card and every cell is prefixed with its column name via a
data-label attribute, so no information is lost and nothing scrolls sideways.
The redundant row-number column is hidden, long source URLs and AI reasoning
wrap instead of being ellipsis-clipped, and DETAIL / FP become full-width
thumb targets. 900px rather than the phone breakpoint because a nine-column
table is cramped well above phone width — this covers tablets in portrait.

Touch sizing is keyed to pointer: coarse rather than screen width, since touch
capability belongs to the input device, not the viewport: a tablet gets
thumb-sized targets even at 900px while a mouse-driven desktop keeps its
compact controls.

Also fixes iOS auto-zoom (inputs render at 16px on touch; below that Safari
zooms the page on focus and leaves the dashboard mis-scrolled), reflows the
export toolbar into a two-column grid on small screens, and adds safe-area
insets for notched phones.

Verified with Playwright across iPhone SE / iPhone 14 / Pixel 7 / iPad mini /
iPad Pro / desktop: zero page-level horizontal overflow at every width, no
interactive control under 32px on touch, and the desktop table confirmed
unchanged (thead still table-header-group, all nine columns intact).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E
Being a good guest on a client's infrastructure is part of the engagement: an
authorized scan that trips rate limiting looks like an attack to their SOC and
gets the scanner blocked mid-assessment.

Fixes a real bug first. RFC 7231 allows Retry-After to be either delta-seconds
or an HTTP-date. The header was parsed with a bare float(), so a spec-compliant
date raised ValueError, fell through to the generic exception handler, and made
the scanner abandon the asset outright — a false negative caused by the server
behaving correctly. _parse_retry_after() now handles both forms, clamps past
dates to zero, falls back on garbage, and never raises.

Adds jittered backoff. Retry delays used a deterministic RETRY_BACKOFF_BASE **
attempt, so every concurrent worker retried on the same tick — a thundering herd
against a host that had just asked for relief. Backoff now uses equal jitter
(half fixed, half random, capped), which de-synchronises workers while still
guaranteeing a minimum pause, unlike full jitter.

Adds an adaptive per-host throttle. On 429/503 SecretNode paces requests to that
host only, growing per signal up to a cap and decaying as the host recovers. One
fragile host no longer slows the rest of the engagement, and a healthy host costs
nothing — pacing starts at zero and only appears after the host complains.
Throttle state resets per scan so pacing learned from one target never penalises
the next.

This is resilience for authorized testing, not evasion. The identifiable-source
posture is unchanged: SECRETNODE_USER_AGENT still presents a client-approved
agent string, and scope, SSRF guard, passive-only behaviour and the authorization
gate are untouched.

Suite 294 -> 313, all green; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E
Server-rendered apps embed bootstrap state in the HTML (__NEXT_DATA__,
__NUXT__, __INITIAL_STATE__, __APOLLO_STATE__). That blob is built server-side,
so it regularly carries config a developer never meant to ship.

extract_inline_json_strings() finds inline <script> JSON, parses it, and scans
the decoded string values.

The roadmap listed inline JSON and HTML comments as uncovered surface. Measured
against the code, both were already caught — the whole response body goes
through the raw-text pass, so a plainly-embedded secret in a comment or JSON
blob has always been found. The only genuine miss was a value whose JSON
escaping breaks the credential's shape: a \uXXXX-escaped character mid-token,
as emitted by XSS-safe serializers (Next.js's htmlEscapeJsonString). The regex
sees sk-ant-… and no longer recognises it. Decoding recovers it — the
same rationale that already justifies decoding source-map sourcesContent. The
roadmap entry is corrected rather than left overstating the gap.

Purely local decoding: no additional requests, so the scan stays passive.
Bounded by a shared byte budget, non-greedy bounded regexes, defensive
throughout — a malformed blob is skipped, never fatal.

Deliberately not implemented: probing for unlinked paths (.env, .git/config,
backups). That is active enumeration, not passive discovery, and would
contradict the "passive assessment" statement in every client report. Noted in
the roadmap as requiring a separate opt-in mode if ever added.

Includes a mutation check: with SCAN_INLINE_JSON=false the recovery tests fail,
proving they exercise the decoder rather than passing incidentally.

Suite 313 -> 327, all green; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E
Closes roadmap item R10. Re-scanning a target refetched every asset from
scratch — wasted bandwidth on the client's servers and wasted CPU on ours,
since most assets neither change between engagements nor contain anything.

SecretNode now sends If-None-Match / If-Modified-Since from a per-target cache
of HTTP validators (asset_cache table, 24h TTL).

A 304 is acted on by history, not blindly:
  - asset was clean last scan -> skipped entirely (unchanged and previously
    clean means still clean), and never enters the scan text;
  - asset previously yielded a finding -> refetched unconditionally, so the
    finding is reproduced. A finding that disappeared from a report would read
    as "resolved", which is a dangerous lie to tell a client.

No response bodies are cached, deliberately. The obvious implementation stores
bodies so a 304 can still be scanned, but a client's JavaScript can contain live
credentials and caching it would leave a long-lived copy of their secrets on our
disk, which the engagement's confidentiality terms do not allow. The cache holds
only validators, a truncated content hash, and a clean/dirty flag — enough to
skip the overwhelming majority of assets. A test asserts no body content is ever
persisted.

Includes a mutation check (with ASSET_CACHE=false, five cache tests fail,
proving they exercise the real path) and a real-SQLite round-trip covering
upsert, per-target isolation and purge.

Suite 327 -> 337, all green; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E
A deliberate self-review of v2.7.2-v2.7.7 before opening the PR. It found three
genuine defects — two in the very code written to prevent that class of failure
— and one process gap that mattered more than any of them.

Fixed:
- Inline-JSON extraction truncated at the first raw "<". The [^<] class cut the
  blob short whenever it contained a literal "<" (prose like "a < b", embedded
  HTML, templates), losing every secret after that point. Now lazily matched to
  the </script> terminator: linear-time, verified ReDoS-safe at 0.18s on hostile
  input.
- A previously-dirty asset could be lost on 304 when RETRY_ATTEMPTS=1. The
  refetch used `continue`, consuming a retry attempt, so with one attempt
  configured the request was never re-issued and the asset was dropped — the
  exact "a finding silently vanishes" failure the cache exists to prevent. The
  unconditional refetch now happens inline.
- An unprompted 304 with no cache entry burned a retry. Now refetched
  immediately, and a server answering 304 even unconditionally terminates rather
  than spinning.

The quality gate is now real:
- Corpus 27 -> 45 samples, covering all nine v2.7.2 AI/ML detectors, the v2.7.6
  inline-SSR path, and eight hard negatives chosen to be structurally confusable
  with the new patterns (sk_ + wrong-length hex, a 64-hex SHA digest resembling
  an OpenRouter key, provider-shaped placeholders, benign inline JSON). Shipping
  nine detectors with zero corpus coverage meant the "measured precision" claim
  did not cover them.
- make bench previously printed a report and always exited 0, so it could not
  block anything. It now enforces BENCH_MIN_PRECISION / BENCH_MIN_RECALL,
  names the offending samples, and exits non-zero.
- CI runs it, so a precision regression is release-blocking. Verified by
  deliberately loosening the ElevenLabs regex: precision falls 1.000 -> 0.917,
  both false positives are named, build fails.

Suite 337 -> 349, all green; ruff clean; bench 1.000/1.000 on the larger corpus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E
A full pre-PR QA pass: booted the application, ran real end-to-end scans against
a local target with planted secrets, exercised every export format, the CLI, and
a re-scan to prove the cache. It surfaced one serious defect and one cosmetic
one.

Client reports contained the full, live credential. The CSV column was named
matched_value_partial but wrote raw_match verbatim, and the HTML report did the
same. A report is emailed, forwarded and archived — writing a working secret
into one turns the deliverable itself into a second exposure, and directly
contradicts the Rules of Engagement we ask clients to sign ("only the minimum
evidence needed to prove a finding, with sensitive data redacted"). Values are
now redacted to sk_798…******…3fc4 (51 chars): still greppable so a developer
can identify which key to rotate, but not usable. REPORT_FULL_SECRETS=true opts
back in when an operator deliberately needs the full value. SARIF was clean.

Version drift in the dashboard: the footer and boot log were hardcoded to v2.7.1
and never touched by the runtime /api/health sync, so a client demo showed a
stale version indefinitely. Footer is now synced, boot log no longer hardcodes a
version, and report.py's fallback constant was refreshed.

Verified end to end rather than only unit-tested: app boots and reports the
right version; a real scan found both of this session's headline features
working live (v2.7.2 ElevenLabs detector, and v2.7.6 recovery of an Anthropic
key \uXXXX-escaped inside __NEXT_DATA__ with a raw "<" in the blob, exercising
the v2.7.8 fix); a re-scan repopulated findings rather than losing them and
recorded both assets as was_clean=False; HTML/CSV/SARIF all generate with the
right version and 63 rules; the CLI works; the SSRF guard correctly refused a
localhost target until ALLOW_PRIVATE_TARGETS was set; dashboard JS passes
node --check after the mobile edits.

Suite 349 -> 353, all green; ruff clean; bench 1.000/1.000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E
@azmolhaque
azmolhaque force-pushed the claude/new-session-thsmic branch from 26bd903 to 744491d Compare July 30, 2026 08:30
@azmolhaque
azmolhaque merged commit 649d5c2 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.

1 participant