From ebb066bf0a962ca914bd59912ba20e747117ea76 Mon Sep 17 00:00:00 2001 From: "Md. Azmol Haque Rony" Date: Wed, 29 Jul 2026 19:23:59 +0000 Subject: [PATCH 1/8] Add AI/ML provider detectors and fix duplicate findings (v2.7.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E --- CHANGELOG.md | 26 +++++++++ README.md | 2 +- backend/scanner.py | 101 ++++++++++++++++++++++++++++++++- backend/tests/test_patterns.py | 97 +++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 5 files changed, 224 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0efd5..fcdb1a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,32 @@ All notable changes to SecretNode are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## [2.7.2] — AI/ML provider detectors + duplicate-finding fix + +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 reported as an untyped +MEDIUM *and* double-counted. Both problems are fixed. + +### Added +- **AI/ML provider detector pack (9 new patterns, 54 → 63).** Modern AI stacks leak keys + constantly because a frontend calls the provider directly instead of proxying through a + backend. New structural detectors: **ElevenLabs, Groq, Hugging Face, Replicate, Perplexity, + xAI (Grok), OpenRouter, LangSmith, Pinecone**. Each is prefix + fixed-length, so it stays + high-precision without the generic entropy gate. ElevenLabs ships provider-specific + remediation (revoke in dashboard, proxy TTS server-side). +13 tests, incl. near-miss + precision guards and a ReDoS timing check. + +### Fixed +- **One credential no longer reported twice.** `RawFinding.fingerprint` includes `secret_type`, + so a value matched by both a provider detector *and* the generic catch-all produced two + findings — double-counting the exposure in client reports, spending a second AI-validation + call on the same string, and leaving two conflicting severities (HIGH + MEDIUM) for one + credential. `_collapse_generic_duplicates()` now collapses per `(source_url, raw_match)`, + keeping the typed detector (accurate severity + provider remediation) and dropping the + generic claim. The catch-all still fires normally for credentials no detector types. +3 tests. + +Suite **282 → 285**, all green; ruff clean. + ## [2.7.1] — Gemini validation-engine model refresh Tracks Google's current Gemini lineup for the two-tier AI validation engine, with no diff --git a/README.md b/README.md index 7db7711..43ad68e 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ http://:8000 --- -## Secret Patterns Detected (44) +## Secret Patterns Detected (63) Every pattern carries a **severity** and a **CWE** id, and only fires after passing a Shannon-entropy filter (so obvious placeholders like `YOUR_API_KEY_HERE` are dropped before the AI stage). diff --git a/backend/scanner.py b/backend/scanner.py index ab3e95e..19e5e4b 100644 --- a/backend/scanner.py +++ b/backend/scanner.py @@ -246,6 +246,11 @@ def _meta(self) -> "SecretPattern": # Secret Pattern Registry # ───────────────────────────────────────────────────────────────────────────── +# The loose keyword=value catch-all. Named here because the de-duplication pass +# needs to know which detector is the fallback: when a provider-specific detector +# has already typed a credential, the generic claim on the same value is dropped. +GENERIC_SECRET_TYPE = "Generic High-Entropy Secret" + SECRET_PATTERNS: list[SecretPattern] = [ SecretPattern( name="AWS Access Key", @@ -337,7 +342,7 @@ def _meta(self) -> "SecretPattern": severity="HIGH", ), SecretPattern( - name="Generic High-Entropy Secret", + name=GENERIC_SECRET_TYPE, regex=re.compile( r"(?i)(?:api[_-]?key|secret|token|password|passwd|auth)\s*[=:]\s*['\"]([A-Za-z0-9\-_.~+/]{20,80})['\"]" ), @@ -382,6 +387,71 @@ def _meta(self) -> "SecretPattern": description="Anthropic (Claude) API key", severity="CRITICAL", ), + # ── AI / ML provider keys ──────────────────────────────────────────────── + # Modern AI stacks leak these constantly: the key is shipped to the browser + # because a frontend calls the provider directly instead of proxying through + # a backend. Each pattern below is structural (distinctive prefix + fixed + # length), so it stays high-precision without the generic entropy gate. + SecretPattern( + name="ElevenLabs API Key", + regex=re.compile(r"\b(sk_[a-f0-9]{48})\b"), + description="ElevenLabs text-to-speech API key", + severity="HIGH", + remediation=( + "Revoke this key in the ElevenLabs dashboard and issue a replacement. A leaked key " + "lets anyone consume the account's character quota and credits, generate audio, and " + "reach private/cloned voice models. Never ship it to the browser: proxy " + "text-to-speech calls through your own backend so the key stays server-side." + ), + ), + SecretPattern( + name="Groq API Key", + regex=re.compile(r"\b(gsk_[A-Za-z0-9]{52})\b"), + description="Groq inference API key", + severity="CRITICAL", + ), + SecretPattern( + name="Hugging Face Access Token", + regex=re.compile(r"\b(hf_[A-Za-z0-9]{34,40})\b"), + description="Hugging Face user access token", + severity="CRITICAL", + ), + SecretPattern( + name="Replicate API Token", + regex=re.compile(r"\b(r8_[A-Za-z0-9]{37,45})\b"), + description="Replicate model-inference API token", + severity="CRITICAL", + ), + SecretPattern( + name="Perplexity API Key", + regex=re.compile(r"\b(pplx-[A-Za-z0-9]{32,64})\b"), + description="Perplexity AI API key", + severity="CRITICAL", + ), + SecretPattern( + name="xAI API Key", + regex=re.compile(r"\b(xai-[A-Za-z0-9]{64,100})\b"), + description="xAI (Grok) API key", + severity="CRITICAL", + ), + SecretPattern( + name="OpenRouter API Key", + regex=re.compile(r"\b(sk-or-v1-[a-f0-9]{64})\b"), + description="OpenRouter aggregated-inference API key", + severity="CRITICAL", + ), + SecretPattern( + name="LangSmith API Key", + regex=re.compile(r"\b(lsv2_(?:pt|sk)_[a-f0-9]{32}_[a-f0-9]{10})\b"), + description="LangSmith / LangChain tracing API key", + severity="HIGH", + ), + SecretPattern( + name="Pinecone API Key", + regex=re.compile(r"\b(pcsk_[A-Za-z0-9_]{40,90})\b"), + description="Pinecone vector-database API key", + severity="CRITICAL", + ), SecretPattern( name="Slack Token", regex=re.compile(r"\b(xox[baprs]-[0-9A-Za-z\-]{12,72})\b"), @@ -1323,7 +1393,34 @@ def extract_secrets( continue seen.add(fp) unique.append(f) - return unique + return _collapse_generic_duplicates(unique) + + +def _collapse_generic_duplicates(findings: list[RawFinding]) -> list[RawFinding]: + """Collapse one credential matched by several detectors into a single finding. + + The generic keyword=value catch-all matches loosely and therefore also fires on + secrets a provider-specific detector already typed precisely. Reporting both is + wrong three ways: it double-counts the exposure in client reports, spends a + second AI-validation call on the same string, and leaves two conflicting + severities for one credential. + + Identity here is (source_url, raw_match) — the same value at the same location. + When several detectors claim it, the typed/structural one wins over the generic + catch-all, because it carries the accurate severity and provider-specific + remediation. Order is otherwise preserved. + """ + typed_locations = { + (f.source_url, f.raw_match) + for f in findings + if f.secret_type != GENERIC_SECRET_TYPE + } + return [ + f + for f in findings + if f.secret_type != GENERIC_SECRET_TYPE + or (f.source_url, f.raw_match) not in typed_locations + ] # ───────────────────────────────────────────────────────────────────────────── diff --git a/backend/tests/test_patterns.py b/backend/tests/test_patterns.py index cfc82f1..afd3fce 100644 --- a/backend/tests/test_patterns.py +++ b/backend/tests/test_patterns.py @@ -142,3 +142,100 @@ def test_extract_secrets_deduplicates_by_fingerprint(): fs = [f for f in scanner.extract_secrets("s", "https://t", "https://t/a.js", body) if f.secret_type == "GitLab Personal Access Token"] assert len(fs) == 1 + + +# ── v2.7.2 — AI/ML provider detector pack ──────────────────────────────────── +# Grounded in a real authorized-scope finding: an ElevenLabs key shipped in a +# client-side EnvConfig.js was previously caught only by the generic +# high-entropy catch-all (MEDIUM, untyped). These structural detectors type it +# correctly and carry provider-specific remediation. + +def _hex(n: int) -> str: + return "".join(secrets.choice("abcdef0123456789") for _ in range(n)) + + +@pytest.mark.parametrize( + "expected,body", + [ + ("ElevenLabs API Key", f'window.REACT_APP_ELEVENLABS_API_KEY = "sk_{_hex(48)}";'), + ("Groq API Key", f'k="gsk_{_rnd(52)}"'), + ("Hugging Face Access Token", f'k="hf_{_rnd(37)}"'), + ("Replicate API Token", f'k="r8_{_rnd(40)}"'), + ("Perplexity API Key", f'k="pplx-{_rnd(48)}"'), + ("xAI API Key", f'k="xai-{_rnd(80)}"'), + ("OpenRouter API Key", f'k="sk-or-v1-{_hex(64)}"'), + ("LangSmith API Key", f'k="lsv2_pt_{_hex(32)}_{_hex(10)}"'), + ("Pinecone API Key", f'k="pcsk_{_rnd(60)}"'), + ], +) +def test_ai_provider_patterns_detected(expected: str, body: str) -> None: + assert expected in _hits(body), f"{expected} not detected in: {body[:70]}" + + +def test_elevenlabs_is_typed_not_generic() -> None: + """The real-world case: a typed detector must win over the generic catch-all.""" + body = f'window.REACT_APP_ELEVENLABS_API_KEY = "sk_{_hex(48)}";' + hits = _hits(body) + assert "ElevenLabs API Key" in hits + + +def test_ai_provider_patterns_reject_near_misses() -> None: + """Wrong length / wrong alphabet must not match — precision guard.""" + negatives = [ + f'k="sk_{_hex(20)}"', # ElevenLabs: too short + f'k="sk_{_rnd(48)}XYZ"', # ElevenLabs: not pure hex, over length + f'k="gsk_{_rnd(10)}"', # Groq: too short + f'k="hf_{_rnd(5)}"', # HF: too short + f'k="r8_{_rnd(4)}"', # Replicate: too short + 'k="sk-or-v1-nothex"', # OpenRouter: not hex + ] + for body in negatives: + typed = _hits(body) - {"Generic High-Entropy Secret"} + assert not typed, f"false positive on {body!r}: {typed}" + + +def test_ai_provider_regexes_are_redos_safe() -> None: + """A hostile minified bundle must not stall the new detectors.""" + import time + ai_names = { + "ElevenLabs API Key", "Groq API Key", "Hugging Face Access Token", + "Replicate API Token", "Perplexity API Key", "xAI API Key", + "OpenRouter API Key", "LangSmith API Key", "Pinecone API Key", + } + hostile = "sk_" + "a" * 5000 + "!" + "gsk_" + "0" * 5000 + for p in scanner.SECRET_PATTERNS: + if p.name in ai_names: + t0 = time.monotonic() + p.regex.search(hostile) + assert time.monotonic() - t0 < 0.5, f"{p.name} too slow (possible ReDoS)" + + +def test_typed_detector_collapses_generic_duplicate() -> None: + """One credential must yield ONE finding, typed — not a typed + generic pair. + + Regression guard: the fingerprint includes secret_type, so before v2.7.2 the + same value at the same URL was reported twice (HIGH typed + MEDIUM generic), + double-counting the exposure and spending two AI-validation calls on it. + """ + body = f'window.REACT_APP_ELEVENLABS_API_KEY = "sk_{_hex(48)}";' + findings = scanner.extract_secrets("s", "https://t", "https://t/EnvConfig.js", body) + assert len(findings) == 1, [f.secret_type for f in findings] + assert findings[0].secret_type == "ElevenLabs API Key" + + +def test_generic_survives_when_no_typed_detector_matches() -> None: + """The catch-all must still fire for credentials no provider detector knows.""" + body = f'api_key = "{_rnd(44)}"' + hits = _hits(body) + assert hits == {scanner.GENERIC_SECRET_TYPE}, hits + + +def test_generic_kept_for_a_different_value_in_same_file() -> None: + """Collapsing is per-value, not per-file: an untyped second secret survives.""" + body = ( + f'window.REACT_APP_ELEVENLABS_API_KEY = "sk_{_hex(48)}";\n' + f'window.OTHER_TOKEN = "{_rnd(44)}";' + ) + hits = _hits(body) + assert "ElevenLabs API Key" in hits + assert scanner.GENERIC_SECRET_TYPE in hits diff --git a/pyproject.toml b/pyproject.toml index 6a0741b..fa35f70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "secretnode" -version = "2.7.1" +version = "2.7.2" description = "Real-time passive attack-surface scanner for credential-leak detection, with a two-tier Gemini AI validation engine (google-genai), live verification, source-map mining, WAF-resilient fetching, SARIF export, and a live dashboard." readme = "README.md" requires-python = ">=3.11" From babe518240d47d19ee3d91e0724dcc2b1aba6e23 Mon Sep 17 00:00:00 2001 From: "Md. Azmol Haque Rony" Date: Wed, 29 Jul 2026 19:27:57 +0000 Subject: [PATCH 2/8] Complete R1: impact-rich verification for AI/ML keys (v2.7.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E --- CHANGELOG.md | 27 ++++++ backend/tests/test_verifier.py | 98 ++++++++++++++++++++ backend/verifier.py | 135 ++++++++++++++++++++++++++++ docs/TECHNICAL-AUDIT-AND-ROADMAP.md | 8 +- pyproject.toml | 2 +- 5 files changed, 266 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcdb1a0..2308d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,33 @@ All notable changes to SecretNode are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## [2.7.3] — R1 complete: impact-rich verification for AI/ML keys + +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. + +### Added +- **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 count + 22 → **29 secret types**. +- **Billing-surface as the impact signal.** 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` +- **Blocked-key handling.** xAI reports disabled keys with HTTP 200; the verifier reads + `api_key_blocked` / `api_key_disabled` and correctly 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/generation calls** (which would bill +the victim's account), the secret never stored or returned, and fail-closed on any error. Still +OFF by default behind `VERIFY_SECRETS=true` — authorized-scope only. + ++9 tests (identity parsing, dead-key, blocked-key, fail-closed, and a guard that every AI detector +ships with a verifier). Suite **285 → 294**, all green; ruff clean. + ## [2.7.2] — AI/ML provider detectors + duplicate-finding fix Grounded in a real authorized-scope scan: an ElevenLabs key shipped in a client-side diff --git a/backend/tests/test_verifier.py b/backend/tests/test_verifier.py index c13409c..def1323 100644 --- a/backend/tests/test_verifier.py +++ b/backend/tests/test_verifier.py @@ -171,3 +171,101 @@ async def test_figma_and_digitalocean_identity(): "DigitalOcean PAT", "do", _MockClient(_Resp(200, {"account": {"email": "ops@acme.io"}}))) assert fig.status == "verified" and "ada" in fig.detail assert do.status == "verified" and "ops@acme.io" in do.detail + + +# ── v2.7.3 — AI/ML provider verifiers ──────────────────────────────────────── +# Each pairs with a v2.7.2 detector. The impact signal for an AI key is usually +# the billing surface it exposes (tier + remaining quota), so the assertions +# check that the identity label actually carries that detail. + +@pytest.mark.asyncio +async def test_elevenlabs_reports_tier_and_quota(): + payload = {"subscription": {"tier": "creator", + "character_count": 12345, "character_limit": 100000}} + client = _MockClient(_Resp(200, payload)) + res = await verifier.verify_finding_detailed("ElevenLabs API Key", "sk_x", client) + assert res.status == "verified" + assert "creator tier" in res.detail + assert "quota 12,345/100,000" in res.detail + assert client.calls[0][1] == "https://api.elevenlabs.io/v1/user" + # the key must go to its own issuer as a header, never to the scan target + assert client.calls[0][2]["headers"]["xi-api-key"] == "sk_x" + + +@pytest.mark.asyncio +async def test_elevenlabs_dead_key_is_unverified(): + client = _MockClient(_Resp(401)) + res = await verifier.verify_finding_detailed("ElevenLabs API Key", "sk_x", client) + assert res.status == "unverified" + assert res.detail == "" + + +@pytest.mark.asyncio +async def test_huggingface_reports_user_role_and_orgs(): + payload = {"name": "acme-bot", "orgs": [{"name": "acme"}, {"name": "acme-labs"}], + "auth": {"accessToken": {"role": "write"}}} + res = await verifier.verify_finding_detailed( + "Hugging Face Access Token", "hf_x", _MockClient(_Resp(200, payload))) + assert res.status == "verified" + assert "@acme-bot" in res.detail + assert "role: write" in res.detail + assert "2 org(s)" in res.detail + + +@pytest.mark.asyncio +async def test_openrouter_reports_credit_exposure(): + payload = {"data": {"label": "prod-key", "usage": 42, "limit": 500}} + res = await verifier.verify_finding_detailed( + "OpenRouter API Key", "sk-or-v1-x", _MockClient(_Resp(200, payload))) + assert "key: prod-key" in res.detail + assert "quota 42/500" in res.detail + + +@pytest.mark.asyncio +async def test_replicate_reports_account(): + payload = {"username": "acme", "type": "organization"} + res = await verifier.verify_finding_detailed( + "Replicate API Token", "r8_x", _MockClient(_Resp(200, payload))) + assert "@acme" in res.detail and "organization" in res.detail + + +@pytest.mark.asyncio +async def test_xai_blocked_key_counts_as_unverified(): + """A 200 that reports the key as blocked must not be called live.""" + payload = {"name": "old-key", "api_key_blocked": True} + res = await verifier.verify_finding_detailed( + "xAI API Key", "xai-x", _MockClient(_Resp(200, payload))) + assert res.status == "unverified" + + +@pytest.mark.asyncio +async def test_groq_and_pinecone_verify(): + groq = await verifier.verify_finding_detailed( + "Groq API Key", "gsk_x", _MockClient(_Resp(200, {"data": [{"id": "a"}, {"id": "b"}]}))) + assert groq.status == "verified" and "2 models" in groq.detail + pc = await verifier.verify_finding_detailed( + "Pinecone API Key", "pcsk_x", _MockClient(_Resp(200, {"indexes": [{"name": "i"}]}))) + assert pc.status == "verified" and "1 index" in pc.detail + + +@pytest.mark.asyncio +async def test_ai_verifiers_fail_closed_on_exception(): + class _Boom: + async def get(self, *a, **kw): + raise RuntimeError("network down") + + for stype, val in [("ElevenLabs API Key", "sk_x"), ("Groq API Key", "gsk_x"), + ("OpenRouter API Key", "sk-or-x"), ("Pinecone API Key", "pcsk_x")]: + res = await verifier.verify_finding_detailed(stype, val, _Boom()) + assert res.status == "unverified", stype + + +def test_every_ai_detector_has_a_verifier_or_is_documented(): + """Guard: a new AI detector should ship with a verifier where one is safe.""" + import scanner + ai = {"ElevenLabs API Key", "Groq API Key", "Hugging Face Access Token", + "Replicate API Token", "OpenRouter API Key", "xAI API Key", "Pinecone API Key"} + names = {p.name for p in scanner.SECRET_PATTERNS} + assert ai <= names, "detector missing from scanner" + for t in ai: + assert verifier.is_supported(t), f"{t} has no verifier" diff --git a/backend/verifier.py b/backend/verifier.py index f0a0590..c86f6e6 100644 --- a/backend/verifier.py +++ b/backend/verifier.py @@ -300,6 +300,133 @@ async def _doppler(token: str, client: Any) -> tuple[bool, str]: return True, _detail(f"Doppler {who}" if who else "") +# ── AI / ML provider verifiers (v2.7.3) ────────────────────────────────────── +# Same contract as above: ONE read-only identity call to the credential's own +# issuer, no writes, no inference/generation requests (which would bill the +# victim's account). For AI keys the strongest impact signal is usually the +# billing surface a live key exposes — plan tier and remaining quota — because +# that is the concrete, quantified loss a leaked key enables. + +def _quota(used: Any, limit: Any) -> str: + """Render a spend/quota pair only when both look like real numbers.""" + if isinstance(used, (int, float)) and isinstance(limit, (int, float)) and limit: + return f"quota {int(used):,}/{int(limit):,}" + return "" + + +async def _elevenlabs(token: str, client: Any) -> tuple[bool, str]: + r = await client.get( + "https://api.elevenlabs.io/v1/user", + headers={"xi-api-key": token}, timeout=VERIFY_TIMEOUT, + ) + if r.status_code != 200: + return False, "" + sub = _safe_json(r).get("subscription") + sub = sub if isinstance(sub, dict) else {} + tier = sub.get("tier", "") + return True, _detail( + "ElevenLabs", + f"{tier} tier" if tier else "", + _quota(sub.get("character_count"), sub.get("character_limit")), + ) + + +async def _groq(token: str, client: Any) -> tuple[bool, str]: + r = await client.get( + "https://api.groq.com/openai/v1/models", + headers={"Authorization": f"Bearer {token}"}, timeout=VERIFY_TIMEOUT, + ) + if r.status_code != 200: + return False, "" + data = _safe_json(r).get("data") + n = len(data) if isinstance(data, list) else 0 + return True, _detail("Groq", f"{n} models reachable" if n else "") + + +async def _huggingface(token: str, client: Any) -> tuple[bool, str]: + r = await client.get( + "https://huggingface.co/api/whoami-v2", + headers={"Authorization": f"Bearer {token}"}, timeout=VERIFY_TIMEOUT, + ) + if r.status_code != 200: + return False, "" + j = _safe_json(r) + who = j.get("name", "") + orgs = j.get("orgs") + n_orgs = len(orgs) if isinstance(orgs, list) else 0 + auth = j.get("auth") if isinstance(j.get("auth"), dict) else {} + tok = auth.get("accessToken") if isinstance(auth.get("accessToken"), dict) else {} + role = tok.get("role", "") + return True, _detail( + f"Hugging Face @{who}" if who else "Hugging Face", + f"role: {role}" if role else "", + f"{n_orgs} org(s)" if n_orgs else "", + ) + + +async def _replicate(token: str, client: Any) -> tuple[bool, str]: + r = await client.get( + "https://api.replicate.com/v1/account", + headers={"Authorization": f"Bearer {token}"}, timeout=VERIFY_TIMEOUT, + ) + if r.status_code != 200: + return False, "" + j = _safe_json(r) + who = j.get("username", "") + kind = j.get("type", "") + return True, _detail(f"Replicate @{who}" if who else "Replicate", + kind if kind else "") + + +async def _openrouter(token: str, client: Any) -> tuple[bool, str]: + r = await client.get( + "https://openrouter.ai/api/v1/key", + headers={"Authorization": f"Bearer {token}"}, timeout=VERIFY_TIMEOUT, + ) + if r.status_code != 200: + return False, "" + d = _safe_json(r).get("data") + d = d if isinstance(d, dict) else {} + label = d.get("label", "") + return True, _detail( + "OpenRouter", + f"key: {label}" if label else "", + _quota(d.get("usage"), d.get("limit")), + "unlimited credit" if d.get("limit") is None and d.get("usage") is not None else "", + ) + + +async def _xai(token: str, client: Any) -> tuple[bool, str]: + r = await client.get( + "https://api.x.ai/v1/api-key", + headers={"Authorization": f"Bearer {token}"}, timeout=VERIFY_TIMEOUT, + ) + if r.status_code != 200: + return False, "" + j = _safe_json(r) + name = j.get("name", "") + blocked = j.get("api_key_blocked") or j.get("api_key_disabled") + if blocked: + return False, "" + acl = j.get("acls") + n_acl = len(acl) if isinstance(acl, list) else 0 + return True, _detail("xAI", f"key: {name}" if name else "", + f"{n_acl} ACL(s)" if n_acl else "") + + +async def _pinecone(token: str, client: Any) -> tuple[bool, str]: + r = await client.get( + "https://api.pinecone.io/indexes", + headers={"Api-Key": token, "X-Pinecone-API-Version": "2024-07"}, + timeout=VERIFY_TIMEOUT, + ) + if r.status_code != 200: + return False, "" + idx = _safe_json(r).get("indexes") + n = len(idx) if isinstance(idx, list) else 0 + return True, _detail("Pinecone", f"{n} index(es) readable" if n else "") + + # secret_type (from scanner.SECRET_PATTERNS) -> verifier VERIFIERS: dict[str, Verifier] = { "GitHub Personal Access Token": _github, @@ -325,6 +452,14 @@ async def _doppler(token: str, client: Any) -> tuple[bool, str]: "Figma Personal Access Token": _figma, "Postman API Key": _postman, "Doppler Token": _doppler, + # v2.7.3 — AI/ML provider verifiers, paired with the v2.7.2 detector pack. + "ElevenLabs API Key": _elevenlabs, + "Groq API Key": _groq, + "Hugging Face Access Token": _huggingface, + "Replicate API Token": _replicate, + "OpenRouter API Key": _openrouter, + "xAI API Key": _xai, + "Pinecone API Key": _pinecone, } diff --git a/docs/TECHNICAL-AUDIT-AND-ROADMAP.md b/docs/TECHNICAL-AUDIT-AND-ROADMAP.md index aa06285..ec048fa 100644 --- a/docs/TECHNICAL-AUDIT-AND-ROADMAP.md +++ b/docs/TECHNICAL-AUDIT-AND-ROADMAP.md @@ -26,9 +26,11 @@ repo scanning; win the *web-surface* niche. never silently dropped, SSRF guard, same-scope restriction, auth, redaction-before-dispatch. ## Honest gaps vs. 2026 SOTA (grounded in the code) -1. **Verification depth.** Verifiers return only `verified/unverified`. TruffleHog surfaces the - *identity + scopes* a live key maps to (which account, what permissions). That detail is the - strongest possible "impact" statement — and Cindrasec sells impact. +1. ~~**Verification depth.**~~ ✅ **CLOSED.** R1 shipped in v2.6.0 (identity/scope capture) and was + completed in **v2.7.3**, which paired a verifier with every AI/ML detector from the v2.7.2 pack. + Verifiers now return the *identity + scopes + billing surface* a live key maps to — e.g. + "ElevenLabs · creator tier · quota 12,345/100,000" — the strongest impact statement available, + and the one Cindrasec reports are built to sell. 29 secret types now have verifiers. 2. **No FP/FN measurement harness.** There's good FP *handling* but no labeled corpus + precision/recall report, so changes aren't measured. Industrial tools track precision/recall on a benchmark. 3. **Regex robustness.** No ReDoS/catastrophic-backtracking audit or regex timeout; a hostile minified diff --git a/pyproject.toml b/pyproject.toml index fa35f70..debc2b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "secretnode" -version = "2.7.2" +version = "2.7.3" description = "Real-time passive attack-surface scanner for credential-leak detection, with a two-tier Gemini AI validation engine (google-genai), live verification, source-map mining, WAF-resilient fetching, SARIF export, and a live dashboard." readme = "README.md" requires-python = ">=3.11" From 93124a0ecd68372bc160c52b63c111ac44053262 Mon Sep 17 00:00:00 2001 From: "Md. Azmol Haque Rony" Date: Wed, 29 Jul 2026 19:36:20 +0000 Subject: [PATCH 3/8] Make the live dashboard usable on phones and tablets (v2.7.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01V6XK23ZKmYtCovRte9a73E --- CHANGELOG.md | 36 +++++++++++++ frontend/index.html | 127 ++++++++++++++++++++++++++++++++++++++++---- pyproject.toml | 2 +- 3 files changed, 155 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2308d76..8d4fd81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,42 @@ All notable changes to SecretNode are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## [2.7.4] — Mobile & tablet UX for the live dashboard + +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 — were hidden off-screen behind horizontal +scrolling. This release makes the dashboard genuinely usable on touch devices without changing +the desktop layout at all. + +### Changed +- **Findings table becomes cards below 900px.** Each row renders as a self-contained card and + every cell is prefixed with its column name (driven by 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-sized buttons. The breakpoint is 900px rather than the phone + breakpoint because a nine-column table is cramped well above phone width — this covers tablets + in portrait too. +- **Export toolbar reflows** from one cramped row into a two-column grid on small screens. + +### Added +- **Touch sizing keyed to `pointer: coarse`, not screen width.** Touch capability is a property + of the input device, not the viewport, so a tablet gets thumb-sized targets even at 900px while + a mouse-driven desktop keeps its compact controls. Brings every interactive control to the + ~40-46px range on touch. +- **iOS auto-zoom fix.** Text inputs render at 16px on touch layouts; below that Safari zooms the + whole page when an input is focused, which previously left the dashboard mis-scrolled after + typing a target. +- **Safe-area insets** so the header and main content clear the notch and home indicator on + modern phones. + +### Verified +Measured 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 layout confirmed byte-identical in behaviour (`thead` still +`table-header-group`, all nine columns intact). The wide table on large tablets stays contained +in its existing `overflow-x:auto` wrapper rather than breaking the page. + ## [2.7.3] — R1 complete: impact-rich verification for AI/ML keys Closes roadmap item **R1 (verification depth)**. A verified credential no longer reports a bare diff --git a/frontend/index.html b/frontend/index.html index 99f7f68..950bd48 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -61,6 +61,115 @@ .stat-card { padding:14px 12px; } .header-arch, .header-sep { display:none; } .hex-decoration { display:none; } + + /* Toolbar: let the export buttons breathe instead of cramming one line. */ + .findings-toolbar { align-items:stretch; } + .findings-toolbar > div:last-child { + display:grid; grid-template-columns:1fr 1fr; gap:8px; width:100%; + justify-content:stretch !important; + } + .findings-toolbar > div:last-child > * { + width:100%; min-height:40px; font-size:9px !important; + } + .findings-toolbar > div:last-child > select { grid-column:1 / -1; } + + /* Comfortable tap targets on the scan controls. Apple/WCAG guidance is + ~44px; anything under that is a mis-tap on a real phone. */ + #verify-toggle, #deep-toggle { min-height:44px; } + .control-row .btn-scan, .control-row .btn-stop { min-height:46px; } + /* Secondary panel actions (terminal COPY/CLR, toolbar chips) are tiny by + design on desktop — give them a real hit area on touch. */ + .panel button { min-height:32px; } + #modal-box { padding:20px 18px; width:94%; max-height:88vh; } + } + + + /* Card layout covers tablets in portrait too — a 9-column table is + cramped well above phone width, so the breakpoint is wider than the + phone-specific tweaks above. */ + @media (max-width: 900px) { + /* ── Findings: table → cards ─────────────────────────────────────────── + A 9-column table cannot be read on a phone; horizontal scrolling hides + the severity and the action buttons, which are the two things an + operator actually needs. Below 680px each row becomes a self-contained + card and every cell is prefixed with its column name via data-label, + so no information is lost and nothing scrolls sideways. */ + .findings-table thead { display:none; } + .findings-table, + .findings-table tbody, + .findings-table tr, + .findings-table td { display:block; width:100%; } + + .findings-table tr { + border:1px solid #1a2e1f; border-radius:4px; + background:#0d1410; margin-bottom:10px; padding:10px 12px; + } + .findings-table tr:hover td { background:transparent; } + + .findings-table td { + border-bottom:none; padding:5px 0; + display:flex; align-items:flex-start; gap:10px; + max-width:none !important; width:auto !important; + white-space:normal !important; + } + /* Column name, rendered from the cell's data-label. */ + .findings-table td[data-label]::before { + content:attr(data-label); + flex:0 0 82px; + font-family:'Orbitron',monospace; font-size:8px; letter-spacing:0.1em; + color:#4a7c59; text-transform:uppercase; padding-top:3px; + } + /* Row number is redundant once rows are cards. */ + .findings-table td.col-idx { display:none; } + /* Long values wrap instead of being clipped by an ellipsis. */ + .findings-table td.col-source > div, + .findings-table td.col-reason { + white-space:normal !important; overflow:visible !important; + text-overflow:clip !important; word-break:break-word; + } + .findings-table td.col-reason { display:flex; } + .code-snippet { + max-width:100%; white-space:pre-wrap !important; word-break:break-all; + } + .findings-table td.col-conf .confidence-bar { width:100%; } + /* Actions get real, thumb-sized targets. */ + .findings-table td.col-action { + gap:8px; padding-top:9px; margin-top:4px; border-top:1px solid #12210f; + } + .findings-table td.col-action button { + flex:1 1 auto; min-height:40px; font-size:10px !important; + margin-left:0 !important; + } + .findings-table td.col-action::before { display:none; } + + } + + + /* Touch sizing is a property of the input device, not the screen width: + a tablet needs thumb-sized targets even at 900px. */ + @media (pointer: coarse) { + .panel button, .findings-toolbar button, .findings-toolbar select { min-height:36px; } + .findings-table td.col-action button { min-height:40px; } + .control-row .btn-scan, .control-row .btn-stop { min-height:46px; } + #verify-toggle, #deep-toggle { min-height:44px; } + .scan-input, #crawl-pages-input { font-size:16px !important; } + } + + /* iOS zooms the whole page when a focused input is under 16px. Keeping the + text inputs at 16px on touch layouts avoids that jarring auto-zoom. */ + @media (max-width: 680px) { + .scan-input, #crawl-pages-input { font-size:16px !important; } + } + + /* Respect the notch / home indicator on modern phones. */ + @supports (padding: max(0px)) { + @media (max-width: 680px) { + .app-header { padding-left:max(14px, env(safe-area-inset-left)); + padding-right:max(14px, env(safe-area-inset-right)); } + .app-main { padding-left:max(13px, env(safe-area-inset-left)); + padding-right:max(13px, env(safe-area-inset-right)); + padding-bottom:max(14px, env(safe-area-inset-bottom)); } + } } @media (max-width: 380px) { .stats-grid { grid-template-columns:1fr 1fr; } @@ -867,28 +976,28 @@ : 'NEW'; tr.innerHTML = ` - ${index + 1} - + ${index + 1} + ${sev.toUpperCase()}${newBadge}
${finding.secret_type}
- +
${sourceShort}
- ${snippet} - + ${snippet} + ${finding.entropy.toFixed(2)} bits - +
${finding.confidence}%
- ${reason} - ${time} - + ${reason} + ${time} +