Passive Attack Surface Management scanner for detecting credential leaks in public-facing infrastructure. Pipeline: browser-like spider (+ source-map mining) → regex (63 patterns) + base64 decode → entropy filter → AI validation (Gemini) → optional live verification → Discord alerts, with a live dashboard, SQLite history, scan diffing, false-positive suppression, a CLI + GitHub Action, and SARIF / HTML / CSV / JSON report export. It scans a single target, or takes a whole domain and enumerates it — subdomain discovery, liveness probing, subdomain-takeover checks and historical-URL mining, then scans every live host concurrently and aggregates the result into one report. Runs anywhere Python 3.11+ runs — tuned for Raspberry Pi 5 (ARM64, 16 GB RAM).
⚠ Authorized use only. This is a passive, read-only tool for finding your own exposed credentials on infrastructure you own or are explicitly authorized to test. See
SECURITY.md.
v2.12.6 — the ledger existed; nothing called it · full changelog · releases
A whole-domain deep scan ran from the dashboard against a company with no Rules of Engagement on file — 123 subdomains enumerated, 80 hosts probed, 25 crawled, and live-verification enabled.
ops/ledger.pyshipped in v2.12.0 to make that impossible, and was never wired into a scan path. An authorization check a caller has to remember to make is not a control; it is a comment.
ledger.enforce()now gates both scan endpoints and the CLI, fails closed, and writes every allow and deny to an audit trail. Three tests read the entry points and fail if the call is removed. Two adjacent gaps closed with it:permit_deep_scanandpermit_verificationwere stored and read by nothing, so scope alone waved through whole-domain enumeration and live credential replay; andPOST /api/deep-scans, the larger of the two traffic generators, had no SSRF guard at all.python -m ops.ledgeris the write interface the ledger never had — much of why it stayed empty.The same report listed
console.log,stackoverflow.comand several developers' personal blogs as the target's "third-party / connected infrastructure": protocol- relative URL matching turned every//console.log(…)in a minified bundle into a hostname. Comments are now stripped before host and endpoint extraction — never on the secret-detection path, where credentials hide in comments — and the section is retitled to describe what it actually is.The three releases before it: v2.12.0 verified contact lookup — an address is returned only if it literally appears on a page that was actually fetched; v2.12.1/2.12.2 made the Pi self-check able to diagnose its own most likely failures, including separating cold model-load time from warm inference so a single misleading figure stops driving architecture decisions; v2.12.3 stopped every preloaded font being downloaded as a candidate JavaScript asset.
Release notes live in
CHANGELOG.md, which is the single source of truth — this README no longer keeps a second copy that can drift out of date.
┌─────────────────────────────────────────────────────────────────┐
│ Browser Dashboard (Vanilla JS, self-hosted CSS — no CDN) │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ Scan Control│ │ Live Terminal│ │ Verified Findings Table│ │
│ └──────┬──────┘ └──────┬───────┘ └────────────┬───────────┘ │
│ │ POST /api/scans │ WebSocket /ws/logs/{id}│ │
└─────────┼─────────────────┼────────────────────────┼────────────┘
│ │ │
┌─────────▼─────────────────▼─────────────────────────▼──────────┐
│ FastAPI (main.py) — uvicorn + uvloop │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ConnectionManager: per-scan WS fan-out + global feed │ │
│ │ ScanRegistry: asyncio.Task map + ScanState │ │
│ └──────────────────────────┬───────────────────────────────┘ │
└─────────────────────────────┼───────────────────────────────────┘
│ asyncio.create_task
┌─────────────────────────────▼───────────────────────────────────┐
│ scanner.py — Core Engine │
│ │
│ spider_target() │
│ └─ fetch_url() × N (asyncio.Semaphore(20), retry×3) │
│ └─ extract_js_urls() (regex HTML parse) │
│ │
│ extract_secrets() │
│ └─ 63 SECRET_PATTERNS (AWS, GCP, Slack, JWT, GitHub…) │
│ └─ shannon_entropy() (filter < 3.5 bits) │
│ │
│ validate_with_gemini() — two-tier engine (google-genai SDK) │
│ └─ Tier 1 pre-filter: gemini-3.5-flash-lite (thinking:min) │
│ └─ Tier 2 deep-valid.: gemini-3.6-flash (thinking:high) │
│ └─ Structured output → Pydantic GeminiVerdict │
│ {is_valid, confidence, reason} │
│ │
│ dispatch_discord() │
│ └─ Rich embed via httpx.post │
│ └─ Gate: is_valid=true AND confidence ≥ 80 │
└─────────────────────────────────────────────────────────────────┘
| Component | Choice | Reason |
|---|---|---|
| Event loop | uvloop |
2–4× faster than default asyncio on ARM64 |
| HTTP | httpx.AsyncClient |
Native async, connection pooling, retries |
| Concurrency | asyncio.Semaphore(20) |
Bounds RAM on Pi 5 during deep JS analysis |
| AI | Two-tier Gemini (google-genai): 3.5-flash-lite → 3.6-flash |
Cheap pre-filter kills noise; strong tier deep-validates real/critical findings with structured output |
| Transport | WebSocket fan-out | Browser gets live logs without polling |
| Frontend | Vanilla JS, self-hosted CSS + fonts | Zero build step, deployable immediately, fully offline-capable (no Tailwind CDN — removed; see frontend/index.html) |
secretnode/
├── backend/
│ ├── main.py # FastAPI app: REST + WebSocket + static server + auth/SSRF guards
│ ├── scanner.py # Async scan engine (63 patterns, source maps, entropy, base64, Gemini, Discord)
│ ├── verifier.py # Optional live credential verification (off by default)
│ ├── cli.py # CLI entrypoint (scan → SARIF/JSON/CSV/HTML; CI gate)
│ ├── storage.py # SQLite persistence: scan history + false-positive suppression
│ ├── report.py # HTML / CSV / SARIF report generation (+ verified status)
│ ├── watch.py # Continuous monitoring: scan-to-scan delta, triage, client digest
│ ├── ops/ # Operations layer: local-LLM adapter + grounding/prompt guards
│ │ ├── llm.py # Ollama, schema-constrained, Pi-tuned, fails loudly
│ │ ├── guards.py # Grounding (anti-hallucination) + refuse secrets in prompts
│ │ ├── ledger.py # Authorization ledger — the gate every scan passes
│ │ ├── contacts.py # Verified contact lookup (`python3 -m ops.contacts acme.com`)
│ │ └── selfcheck.py # `python3 -m ops.selfcheck` — verify it works on this Pi
│ └── tests/ # pytest suite (see badge above for current count)
├── frontend/
│ └── index.html # Live dashboard SPA (vanilla JS, self-hosted CSS)
├── .github/
│ ├── workflows/ci.yml # CI: ruff + pytest (3.11/3.12) + Docker build
│ ├── ISSUE_TEMPLATE/ # Bug / feature templates
│ └── pull_request_template.md
├── watch-roster.example.json # Template for the monitored-target roster (real one is gitignored)
├── action.yml # Composite GitHub Action (SARIF in CI)
├── Dockerfile # Non-root, healthchecked container image
├── docker-compose.yml
├── pyproject.toml # Packaging + ruff + pytest config
├── Makefile # setup / test / lint / run / docker shortcuts
├── requirements.txt
├── setup.sh # One-shot bootstrap (venv, deps, .env, systemd)
├── .env.example
├── LICENSE SECURITY.md CONTRIBUTING.md CHANGELOG.md
└── README.md
git clone https://github.com/azmolhaque/secretnode.git
cd secretnodechmod +x setup.sh
./setup.shThe script will:
- Check Python 3.11+
- Install system dependencies (libxml2, libxslt for lxml on ARM64)
- Create a Python virtual environment at
.venv/ - Install all Python requirements
- Generate a
.envfile template - Optionally install a systemd service
- Offer to start the server immediately
nano .envFill in GEMINI_API_KEY and DISCORD_WEBHOOK_URL.
cd backend
source ../.venv/bin/activate
uvicorn main:app --host 0.0.0.0 --port 8000 --loop uvloophttp://<raspberry-pi-ip>:8000
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/health |
Health check |
| POST | /api/scans |
Start a new scan |
| POST | /api/scans/{id}/stop |
Cancel a running scan |
| GET | /api/scans |
List all scans (session) |
| GET | /api/scans/{id} |
Get scan detail + findings |
| GET | /api/scans/{id}/status |
Lightweight status poll |
| GET | /api/active |
List running scans |
| WS | /ws/logs/{scan_id} |
Per-scan live event stream |
| WS | /ws/logs |
Global event stream |
| type | Payload | Description |
|---|---|---|
scan_start |
{scan_id, target_url} |
Scan initiated |
log |
{level, message} |
Terminal log line |
status |
{stage} |
Pipeline stage change |
assets_found |
{count, urls[]} |
Assets collected (JS + source maps) |
raw_count |
{count} |
Raw regex candidates |
finding |
{data: ValidatedFinding} |
Confirmed secret |
scan_complete |
{scan_id, result} |
Scan finished |
scan_cancelled |
{scan_id} |
User stopped scan |
scan_error |
{error} |
Fatal scan error |
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).
CRITICAL — AWS Access/Secret Key · GitHub PAT (classic + fine-grained) · GitLab PAT · Stripe Secret Key · OpenAI Key · Anthropic Key · Slack Token · npm Token · PyPI Token · DigitalOcean PAT · HashiCorp Vault Token · Azure Storage Key · HCP Terraform · OpenAI Service-Account · PEM/PGP Private Key · Database URI with credentials
HIGH — Google Cloud/OAuth · GitHub OAuth · Slack Webhook · SendGrid · Twilio · Heroku · Shopify · Mailgun · Square · Postman · Databricks · Telegram Bot · Discord Bot · Datadog · Firebase FCM · Slack App-Level · GitHub Server/Refresh · New Relic · Grafana · JWT · Basic-auth URL
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, 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.
GET /api/scans/{id}/report?format=html|csv|json|sarif
| Format | Use |
|---|---|
html |
Self-contained, print-styled report → browser Print → Save as PDF for a client deliverable |
csv |
Spreadsheet-friendly export (severity, CWE, confidence, status per finding) |
json |
Raw structured scan record |
sarif |
SARIF 2.1.0 — upload to GitHub code scanning or ingest in any SARIF-aware CI/security pipeline |
Every /api/* call needs the X-API-Key header; WebSocket connections pass ?api_key=.
FastAPI also serves interactive docs at /docs (Swagger UI) and /redoc.
export KEY=your_secretnode_api_key
# 1) Health / config check
curl -s localhost:8000/api/health | jq
# 2) Start a scan (crawl up to 3 same-domain pages)
curl -s -X POST localhost:8000/api/scans \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"target_url":"https://example.com","crawl_pages":3}' | jq
# -> { "scan_id": "…", "ws_url": "/ws/logs/…", … }
# 3) Stream live events (needs a websocket client, e.g. websocat)
websocat "ws://localhost:8000/ws/logs/<scan_id>?api_key=$KEY"
# 3b) Or hand it a whole domain — enumerate, probe, then scan every live host
curl -sX POST localhost:8000/api/deep-scans \
-H "X-API-Key: $SECRETNODE_API_KEY" -H 'content-type: application/json' \
-d '{"domain":"example.com","crawl_pages":3,"max_targets":25,"include_historical":true}'
# -> same {scan_id, ws_url} shape; findings and reports are aggregated across hosts
# 4) Fetch findings once complete
curl -s localhost:8000/api/scans/<scan_id> -H "X-API-Key: $KEY" | jq '.confirmed_findings'
# 5) Export a report — html | csv | json | sarif
curl -s "localhost:8000/api/scans/<scan_id>/report?format=sarif" \
-H "X-API-Key: $KEY" -o findings.sarif
# 6) Mark a false positive (never re-alerts on future scans of this target)
curl -s -X POST localhost:8000/api/findings/suppress \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"fingerprint":"<fp>","target_url":"https://example.com","note":"mock key"}'
# 7) Review persisted history (survives restarts)
curl -s localhost:8000/api/scans/history -H "X-API-Key: $KEY" | jq '.scans[] | {target_url, confirmed_count, created_at}'CI integration: run a scan, export SARIF, and upload it to GitHub code scanning with
github/codeql-action/upload-sarif, or feed it to any SARIF-aware pipeline.
A single scan says what is exposed now. Monitoring has to say what changed, and whether
it needs a human today. backend/watch.py is that layer — pure functions over two scan
records, no network and no database of its own:
from watch import compute_delta, classify, render_digest
delta = compute_delta(current_scan, previous_scan) # new / resolved / recurring
tier, why = classify(delta) # URGENT | REVIEW | ROUTINE
draft = render_digest(delta, client="Acme Ltd", period="August 2026")Triage rules. A new CRITICAL/HIGH finding is URGENT. So is any new finding that
live-verification confirmed is an active credential — a MEDIUM secret that provably
works is a way in, and holding it for the monthly report is indefensible. Everything else
new is REVIEW; a period with no new findings is ROUTINE.
Resolution is claimed carefully. A finding vanishes from a scan for two reasons that look identical in the data: it was fixed, or this run saw less than the last one (asset 404'd, WAF blocked it, crawl budget ran out, scan errored). Resolution is asserted only when the scan completed and coverage is within 50% of the previous run. Otherwise those findings are reported as "no longer observed, resolution unconfirmed" and the digest says so in plain language. A weaker claim that is true beats a stronger one that might not be.
Nothing is sent automatically. render_digest() returns a draft for human review, with
all internal triage notes inside a single HTML comment — delete that block and the document
is client-ready. Two checkpoints are never automated here: the authorization to scan at
all, and the final severity call on anything critical.
Roster. Copy watch-roster.example.json to watch-roster.json (gitignored — it names
clients and their infrastructure) and list the monitored targets. A missing roster raises
rather than running zero targets, because "monitoring completed, nothing to do" is the most
dangerous way for a paid subscription to fail. Listing a host schedules a scan; it does not
authorize one — that still comes from a signed RoE.
Following the industry shift to verification-first detection, SecretNode can confirm whether a confirmed finding is a currently active credential — the single biggest lever against 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, Cloudflare, DigitalOcean, Datadog, Notion, Linear, Figma, Postman, Doppler. Fails closed on any error.
- Each finding gets a
verifiedstatus:verified(active),unverified(dead / unconfirmed),unsupported(no safe auto-check — verify manually). only_verifieddrops confirmed-inactive findings so a pipeline only fails on live secrets.
⚠️ Verifying a credential means using it (read-only) against its issuer. Only do this on assets you own or are authorized to test. SeeSECURITY.md.
CLI — emits SARIF/JSON/CSV/HTML; --fail-on-findings makes it a build gate:
python backend/cli.py https://example.com -f sarif -o secretnode.sarif
python backend/cli.py https://example.com --crawl 5 --fail-on-findings
GEMINI_API_KEY=... python backend/cli.py https://example.com --verifyGitHub Action — scan and upload results to code scanning:
- uses: azmolhaque/secretnode@main
with:
target: https://example.com
fail-on-findings: "true"
gemini-api-key: ${{ secrets.GEMINI_API_KEY }}
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: secretnode.sarifcp .env.example .env # then set SECRETNODE_API_KEY, GEMINI_API_KEY, DISCORD_WEBHOOK_URL
docker compose up --build
# dashboard: http://localhost:8000Or a one-off container:
docker build -t secretnode .
docker run -p 8000:8000 -e SECRETNODE_API_KEY=$(openssl rand -hex 24) -e GEMINI_API_KEY=... -e DISCORD_WEBHOOK_URL=... secretnodeThe image runs as a non-root user, includes a /api/health healthcheck, and persists scan
history in a named volume.
⚠ AUTHORIZED USE ONLY This tool is for security professionals conducting authorized penetration tests and bug bounty reconnaissance on infrastructure they own or have explicit written permission to test. Unauthorized scanning is illegal and unethical.
- Secrets found are partially redacted in reports, logs, and Discord alerts
- Scan history is persisted to SQLite (
backend/data/secretnode.db) — survives restarts - The API/WebSocket/dashboard require
SECRETNODE_API_KEYon every request (the server refuses to boot without one)
Worth stating precisely, because "passive scanner" gets used loosely:
- By default, SecretNode never contacts a third-party provider with a credential it found. It reads what the target already serves in public — pages, JavaScript bundles, source maps, configuration — and never authenticates, never writes, never modifies.
- Liveness checking is a separate, opt-in step.
--verifyexists (see Verification), it is off unless asked for, and it is the smallest metadata call that establishes whether a key is still valid. It stops there. - The distinction is the point. A report that says "exposed, and I have not confirmed it is active" is weaker copy and a more honest artifact. Choosing when to cross that line belongs to whoever signed the authorization, not to the tool.
SecretNode is written and maintained by Md. Azmol Haque Rony — Google VRP–credited, Dhaka, Bangladesh. It is MIT-licensed and free to use, and it is also the delivery engine behind the continuous-monitoring tier at Cindrasec (also in বাংলা), an attack-surface and AI/LLM security studio for founders and SMEs.
The code is public for a specific reason: a client evaluating a security vendor can read exactly what the scanner does with their credentials instead of taking the vendor's word for it. That is harder to fake than a testimonial.
Related published research, in 9 languages each:
- Anatomy of an Exposed IAM Frontend — Google VRP — a full authentication bypass on Google-acquisition infrastructure, fixed in 9 days, and why it resolved to credit rather than cash.
- The Same Model, 4.6× the Exposure — prompt-injection resistance measured over 256 trials per attack: 46.9% vs 10.2% for the same model, with non-overlapping 95% confidence intervals.
The defaults are already tuned for the Pi 5's capabilities:
CONCURRENCY_LIMIT = 20 # parallel HTTP fetches
FETCH_TIMEOUT = 20.0 # seconds per request
MIN_ENTROPY_THRESHOLD = 3.5 # bits — filters ~80% of false matches before AI
MAX_ASSET_BYTES = 5MB # skip oversized JS bundles
GEMINI_CONFIDENCE_MIN = 80 # only alert on high-confidence findingsAll of these are now environment variables (set them in .env) — no code edits needed:
- To reduce Gemini API costs, set
MIN_ENTROPY_THRESHOLD=4.0. - To scan deeper, set
CONCURRENCY_LIMIT=40(watch RAM withhtop). - See
.env.examplefor the full list of tunables.
| Variable | Default | Purpose |
|---|---|---|
SECRETNODE_USER_AGENT |
(unset → real Chrome UA) | Force a specific User-Agent (e.g. a client-approved test-agent string). Unset = current-Chrome fingerprint with automatic rotation on a WAF challenge. |
FOLLOW_SOURCE_MAPS |
true |
Follow declared //# sourceMappingURL= maps (.js.map) and scan their un-minified original source. |
MAX_SOURCE_MAPS |
40 |
Cap on source maps fetched per scan. |
SCOPE_SAME_DOMAIN |
true |
Keep asset/source-map discovery on the target's own registrable domain. |
Why a browser User-Agent? A
SecretNode-botagent gets an instant HTTP 403 from Cloudflare/WAF-fronted sites, so an authorized scan of a target you own couldn't reach the same surface an attacker would. Presenting a normal browser fingerprint is standard for security scanners (Burp, ZAP, nuclei all do it) and is resilience for authorized testing — the SSRF guard, same-domain scope, passive-only behaviour and the authorization gate (seeSECURITY.md) are all unchanged.