Skip to content
Merged
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,14 @@ VERIFY_SECRETS=false
FOLLOW_SOURCE_MAPS=true
# Cap on source maps fetched per scan.
MAX_SOURCE_MAPS=40
# Decode a source map's embedded original source (sourcesContent) and scan it as
# real code — better per-file attribution, catches secrets escaped in the raw
# .map JSON, and avoids the map's high-entropy "mappings" blob. (R5)
SCAN_SOURCEMAP_CONTENT=true
# Cap on original source files decoded per source map.
MAX_SOURCEMAP_SOURCES=200
#
# Passive HTTP security-posture check (R8): analyse the target root's own
# response for missing/weak security headers, version disclosure, and insecure
# cookies. Pure analysis of a response the target already serves.
SCAN_HTTP_POSTURE=true
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# SecretNode — developer & operator shortcuts
.PHONY: help setup test lint run docker clean
.PHONY: help setup test lint bench run docker clean

help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
Expand All @@ -16,6 +16,9 @@ test: ## Run the full test suite
lint: ## Run the ruff correctness lint
ruff check backend/

bench: ## Measure detection-layer precision/recall on the labelled corpus (R2)
cd backend && SECRETNODE_API_KEY=bench python -m bench.run_bench

run: ## Start the server (requires .env with SECRETNODE_API_KEY)
cd backend && uvicorn main:app --host 0.0.0.0 --port 8000 --loop uvloop

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Pipeline: **browser-like spider (+ source-map mining) → regex (54 patterns) +

> **v2.3.0 — ASM-industry alignment: verification-first & CI-native**
> Grounded in 2025–2026 ASM / secret-scanning practice (verification-first detection + CI-native gating).
> - **Optional live verification** (`VERIFY_SECRETS` / `?verify=true`) — read-only "is this key still active?" checks against each secret's own provider (GitHub, GitLab, Stripe, SendGrid, OpenAI, Slack, npm, Mailgun, Telegram), à la TruffleHog `--only-verified`. **Off by default**, fails closed, never touches the scan target. Each finding gains a `verified` status, and `only_verified` mode drops dead-key noise.
> - **Optional live verification** (`VERIFY_SECRETS` / `?verify=true`) — read-only "is this key still active?" checks against each secret's own provider (GitHub, GitLab, Stripe, SendGrid, OpenAI, Slack, npm, Mailgun, Telegram, Cloudflare, DigitalOcean, Datadog, Notion, Linear, Figma, Postman, Doppler), à la TruffleHog `--only-verified`. **Off by default**, fails closed, never touches the scan target. Each finding gains a `verified` status, and `only_verified` mode drops dead-key noise.
> - **Base64 decoding** of encoded blobs + **example/placeholder allowlisting** (e.g. AWS's `AKIAIOSFODNN7EXAMPLE`) — fewer false positives, more real catches.
> - **CLI (`backend/cli.py`) + GitHub Action (`action.yml`)** — scan and emit SARIF from CI with `--fail-on-findings` as a build gate.
> - **Registry now 44 patterns** (added Slack app-level, GitHub server/refresh, OpenAI service-account, New Relic, Grafana, HCP Terraform).
Expand Down Expand Up @@ -245,7 +245,7 @@ Square · Postman · Databricks · Telegram Bot · Discord Bot · Datadog · Fir

**MEDIUM** — Stripe Publishable Key · Bearer Token · Generic High-Entropy Secret

Matches are also checked against **base64-decoded** content and filtered through an **example/placeholder allowlist**. Many types (GitHub, GitLab, Stripe, SendGrid, OpenAI, Slack, npm, Mailgun, Telegram) can be **live-verified** (see below). New patterns land with a `severity`, `cwe`, and `remediation` — see [`CONTRIBUTING.md`](CONTRIBUTING.md).
Matches are also checked against **base64-decoded** content and filtered through an **example/placeholder allowlist**. Many types (GitHub, GitLab, Stripe, SendGrid, OpenAI, Slack, npm, Mailgun, Telegram, Cloudflare, DigitalOcean, Datadog, Notion, Linear, Figma, Postman, Doppler) can be **live-verified** (see below). New patterns land with a `severity`, `cwe`, and `remediation` — see [`CONTRIBUTING.md`](CONTRIBUTING.md).

---

Expand Down Expand Up @@ -311,7 +311,7 @@ false-positive fatigue.

- **Off by default.** Enable per scan (`{"verify": true}` / `--verify`) or globally (`VERIFY_SECRETS=true`).
- **Read-only.** One "whoami"-style call to the secret's **own provider** (never the scan target):
GitHub, GitLab, Stripe, SendGrid, OpenAI, Slack, npm, Mailgun, Telegram. Fails closed on any error.
GitHub, GitLab, Stripe, SendGrid, OpenAI, Slack, npm, Mailgun, Telegram, Cloudflare, DigitalOcean, Datadog, Notion, Linear, Figma, Postman, Doppler. Fails closed on any error.
- Each finding gets a `verified` status: `verified` (active), `unverified` (dead / unconfirmed),
`unsupported` (no safe auto-check — verify manually).
- **`only_verified`** drops confirmed-inactive findings so a pipeline only fails on live secrets.
Expand Down
Empty file added backend/bench/__init__.py
Empty file.
116 changes: 116 additions & 0 deletions backend/bench/corpus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""
SecretNode — bench/corpus.py (R2: FP/FN benchmark corpus)

A small, labelled corpus for measuring the deterministic detection layer
(regex + placeholder allowlist + entropy gate + base64 pass — everything in
extract_secrets(), no network, no AI). It answers, reproducibly:

• RECALL — of real secrets planted in code, how many does the layer catch?
• PRECISION — of everything it flags, how much is a real secret vs noise/placeholder?

> IMPORTANT: every "secret" here is SYNTHETIC — deterministically generated,
> correctly *shaped* and high-entropy, but not a real credential. Nothing in this
> file is live. It exists only to hold the detector's precision/recall steady as
> the code changes.

Each sample: (id, text, expect) where `expect` is the secret_type that SHOULD be
detected, or None for a negative (nothing should be detected).
"""

from __future__ import annotations

import hashlib
from dataclasses import dataclass

_ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"


def _rand(n: int, seed: str, alphabet: str = _ALNUM) -> str:
"""Deterministic, high-entropy string of length n over `alphabet`.
Deterministic so the corpus is stable across runs; high-entropy so valid
positives clear the entropy gate (>= 3.5 bits/char)."""
out: list[str] = []
h = hashlib.sha256(seed.encode()).digest()
while len(out) < n:
h = hashlib.sha256(h).digest()
for b in h:
out.append(alphabet[b % len(alphabet)])
if len(out) >= n:
break
return "".join(out)


@dataclass(frozen=True)
class Sample:
id: str
text: str
expect: str | None # secret_type that must be detected, or None for a clean negative

@property
def is_positive(self) -> bool:
return self.expect is not None


# ── POSITIVES — a real (synthetic) secret is planted; the layer must catch it ──
_POSITIVES: list[Sample] = [
Sample("aws-access-key",
f'const AWS_KEY = "AKIA{_rand(16, "aws", _UPPER)}";',
"AWS Access Key"),
Sample("github-pat",
f'GITHUB_TOKEN=ghp_{_rand(36, "ghpat")}',
"GitHub Personal Access Token"),
Sample("stripe-secret",
f'stripe.setApiKey("sk_live_{_rand(24, "stripe")}");',
"Stripe Secret Key"),
Sample("google-api-key",
f'apiKey: "AIza{_rand(35, "goog")}",',
"Google Cloud API Key"),
Sample("slack-webhook",
f'https://hooks.slack.com/services/T{_rand(9, "sa", _UPPER)}/B{_rand(9, "sb", _UPPER)}/{_rand(24, "sc")}',
"Slack Webhook"),
Sample("jwt",
f'Authorization: Bearer eyJ{_rand(20, "j1")}.eyJ{_rand(30, "j2")}.{_rand(43, "j3")}',
"JWT Token"),
Sample("gitlab-pat",
f'GITLAB_TOKEN=glpat-{_rand(20, "glp")}',
"GitLab Personal Access Token"),
Sample("openai",
# real OpenAI keys carry the literal "T3BlbkFJ" infix; the detector
# requires it (rejects look-alikes) — so the corpus must include it.
f'OPENAI_API_KEY=sk-{_rand(20, "oai1")}T3BlbkFJ{_rand(20, "oai2")}',
"OpenAI API Key"),
Sample("sendgrid",
f'SG.{_rand(22, "sg1")}.{_rand(43, "sg2")}',
"SendGrid API Key"),
Sample("npm-token",
f'//registry.npmjs.org/:_authToken=npm_{_rand(36, "npm")}',
"npm Access Token"),
Sample("database-uri",
f'DATABASE_URL=postgres://dbuser:{_rand(16, "dbp")}@db.internal:5432/prod',
"Database Connection URI"),
Sample("basic-auth-url",
f'fetch("https://admin:{_rand(14, "bap")}@api.internal.example.com/v1/")',
"Basic-Auth URL Credentials"),
]

# ── NEGATIVES — placeholders / examples / noise; the layer must stay silent ──
_NEGATIVES: list[Sample] = [
Sample("aws-doc-example", 'key = "AKIAIOSFODNN7EXAMPLE"', None),
Sample("your-api-key", 'apiKey: "YOUR_API_KEY_HERE"', None),
Sample("stripe-placeholder", 'sk_live_your_secret_key_here', None),
Sample("github-xxxx", 'token = "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"', None),
Sample("google-zeros", 'apiKey: "AIzaSy00000000000000000000000000000000000"', None),
Sample("env-ref", 'const key = process.env.STRIPE_SECRET_KEY;', None),
Sample("angle-placeholder", 'Authorization: Bearer <YOUR_ACCESS_TOKEN>', None),
Sample("changeme", 'password = "changeme_changeme_changeme"', None),
Sample("redacted", 'secret = "redacted_for_security_reasons"', None),
Sample("git-sha", "const commit = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0';", None),
Sample("uuid", "id: '550e8400-e29b-41d4-a716-446655440000'", None),
Sample("low-entropy-aws", 'k = "AKIAAAAAAAAAAAAAAAAA"', None),
Sample("plain-prose", "This build ships no credentials; configure them via environment variables.", None),
Sample("semver-list", 'deps: ["1.2.3", "4.5.6", "7.8.9", "10.11.12"]', None),
Sample("css-color-hash", ".btn { color: #a3f2c1; background: #1b2e4d; border: #ff00aa; }", None),
]

CORPUS: list[Sample] = _POSITIVES + _NEGATIVES
100 changes: 100 additions & 0 deletions backend/bench/run_bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""
SecretNode — bench/run_bench.py (R2: precision/recall harness)

Runs the deterministic detection layer (extract_secrets: regex + placeholder
allowlist + entropy gate + base64 pass — no network, no AI) over the labelled
corpus and reports precision / recall / F1, plus every miss so a regression is
visible line-by-line.

Usage:
python -m bench.run_bench # from backend/ (human-readable report)
make bench # from repo root

Programmatic:
from bench.run_bench import evaluate
m = evaluate() # -> {"precision":…, "recall":…, "f1":…, "tp":…, …, "misses":[…]}

Definitions (sample-level; each positive plants exactly one secret, each
negative plants none):
TP positive sample that produced >=1 finding (planted secret caught)
FN positive sample that produced 0 findings (missed a real secret)
FP negative sample that produced >=1 finding (flagged noise/placeholder)
TN negative sample that produced 0 findings
precision = TP / (TP + FP) recall = TP / (TP + FN)
"""

from __future__ import annotations

from bench.corpus import CORPUS, Sample
from scanner import extract_secrets


def _detect(sample: Sample) -> list[str]:
findings = extract_secrets("bench", "https://bench.local", "https://bench.local/x.js", sample.text)
return [f.secret_type for f in findings]


def evaluate() -> dict:
tp = fn = fp = tn = 0
type_hits = 0 # positives that detected the EXACT expected type
misses: list[dict] = []

for s in CORPUS:
detected = _detect(s)
got = len(detected) > 0
if s.is_positive:
if got:
tp += 1
if s.expect in detected:
type_hits += 1
else:
misses.append({"id": s.id, "kind": "WRONG_TYPE",
"expected": s.expect, "detected": detected})
else:
fn += 1
misses.append({"id": s.id, "kind": "FALSE_NEGATIVE",
"expected": s.expect, "detected": []})
else:
if got:
fp += 1
misses.append({"id": s.id, "kind": "FALSE_POSITIVE",
"expected": None, "detected": detected})
else:
tn += 1

precision = tp / (tp + fp) if (tp + fp) else 1.0
recall = tp / (tp + fn) if (tp + fn) else 1.0
f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) else 0.0
positives = tp + fn
return {
"precision": round(precision, 4),
"recall": round(recall, 4),
"f1": round(f1, 4),
"type_accuracy": round(type_hits / positives, 4) if positives else 1.0,
"tp": tp, "fn": fn, "fp": fp, "tn": tn,
"positives": positives, "negatives": fp + tn,
"misses": misses,
}


def format_report(m: dict) -> str:
lines = [
"SecretNode — detection-layer precision/recall (R2)",
"=" * 52,
f" corpus: {m['positives']} positives · {m['negatives']} negatives",
f" precision: {m['precision']:.3f} (TP={m['tp']} FP={m['fp']})",
f" recall: {m['recall']:.3f} (TP={m['tp']} FN={m['fn']})",
f" F1: {m['f1']:.3f}",
f" type accuracy: {m['type_accuracy']:.3f} (right type on caught positives)",
]
if m["misses"]:
lines.append(" misses:")
for x in m["misses"]:
lines.append(f" - [{x['kind']}] {x['id']}: expected={x['expected']} detected={x['detected']}")
else:
lines.append(" misses: none — clean sweep")
return "\n".join(lines)


if __name__ == "__main__": # pragma: no cover
print(format_report(evaluate()))
118 changes: 118 additions & 0 deletions backend/posture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""
SecretNode — posture.py (R8: passive attack-surface breadth)

A first step from "secret scanner" toward the fuller attack-surface scanner the
brand promises: analyse the target's own HTTP response for missing/weak security
headers and simple misconfigurations. This is **pure passive analysis of a
response the target already serves** — no exploitation, no third-party calls, no
writes. Each issue is a `PostureFinding` with severity / CWE / remediation, so it
flows into the same reports as credential findings but stays in its own category
(it never enters the secret-validation or live-verification pipeline).

Follow-ups (deferred, higher-risk / external network): CT-log subdomain
discovery (crt.sh), DNS resolution + dangling-CNAME takeover checks.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any

logger = logging.getLogger("secretnode.posture")


@dataclass
class PostureFinding:
name: str
severity: str # CRITICAL / HIGH / MEDIUM / LOW / INFO
cwe: str
evidence: str # what was observed on the response
remediation: str
category: str = "Security Posture"
found_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())

def to_dict(self) -> dict[str, Any]:
return {
"name": self.name, "severity": self.severity, "cwe": self.cwe,
"evidence": self.evidence, "remediation": self.remediation,
"category": self.category, "found_at": self.found_at,
}


def analyze_security_headers(headers: dict[str, Any] | None, final_url: str) -> list[PostureFinding]:
"""Inspect response headers for missing/weak security controls. Pure and
deterministic — unit-tested without any network."""
h = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
is_https = final_url.lower().startswith("https://")
out: list[PostureFinding] = []

def add(name: str, sev: str, cwe: str, evidence: str, remediation: str) -> None:
out.append(PostureFinding(name, sev, cwe, evidence, remediation))

if is_https and "strict-transport-security" not in h:
add("Missing HSTS", "MEDIUM", "CWE-319",
"No Strict-Transport-Security header on an HTTPS response.",
"Add 'Strict-Transport-Security: max-age=31536000; includeSubDomains'.")

csp = h.get("content-security-policy", "")
if not csp:
add("Missing Content-Security-Policy", "MEDIUM", "CWE-693",
"No Content-Security-Policy header — weaker XSS / data-injection defence.",
"Define a restrictive Content-Security-Policy.")

xcto = h.get("x-content-type-options", "")
if xcto.lower() != "nosniff":
add("Missing X-Content-Type-Options: nosniff", "LOW", "CWE-693",
f"X-Content-Type-Options is '{xcto or 'absent'}'.",
"Set 'X-Content-Type-Options: nosniff'.")

if not h.get("x-frame-options", "") and "frame-ancestors" not in csp.lower():
add("No clickjacking protection", "MEDIUM", "CWE-1021",
"Neither X-Frame-Options nor a CSP 'frame-ancestors' directive is present.",
"Set 'X-Frame-Options: DENY' or a CSP 'frame-ancestors' directive.")

if "referrer-policy" not in h:
add("Missing Referrer-Policy", "LOW", "CWE-200",
"No Referrer-Policy header — the referrer URL may leak to third parties.",
"Set e.g. 'Referrer-Policy: strict-origin-when-cross-origin'.")

if "permissions-policy" not in h:
add("Missing Permissions-Policy", "LOW", "CWE-693",
"No Permissions-Policy header — powerful browser features are not restricted.",
"Set a Permissions-Policy that disables features the site does not use.")

for hdr in ("server", "x-powered-by", "x-aspnet-version"):
val = h.get(hdr, "")
if val and any(c.isdigit() for c in val):
add(f"Version disclosure via {hdr}", "LOW", "CWE-200",
f"{hdr}: {val}",
f"Remove or obscure the '{hdr}' header so it does not reveal software versions.")

cookie = h.get("set-cookie", "")
if cookie:
low = cookie.lower()
if is_https and "secure" not in low:
add("Cookie without Secure flag", "MEDIUM", "CWE-614",
"A Set-Cookie on an HTTPS response lacks the 'Secure' attribute.",
"Add 'Secure' to cookies so they are never sent over plain HTTP.")
if "httponly" not in low:
add("Cookie without HttpOnly flag", "LOW", "CWE-1004",
"A Set-Cookie lacks the 'HttpOnly' attribute.",
"Add 'HttpOnly' to cookies that JavaScript does not need to read.")

return out


async def fetch_posture(client: Any, target_url: str) -> list[PostureFinding]:
"""One passive GET to the target root; analyse its response headers. Fully
defensive — any error yields no findings rather than failing the scan."""
try:
r = await client.get(target_url)
headers = dict(getattr(r, "headers", {}) or {})
final_url = str(getattr(r, "url", "") or target_url)
return analyze_security_headers(headers, final_url)
except Exception as exc: # noqa: BLE001 — best-effort, never break a scan
logger.warning("posture check failed for %s: %s", target_url, exc)
return []
Loading
Loading