dotenv-exposure-check 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/package.json +37 -0
- package/scripts/cli.js +6 -0
- package/scripts/report.js +77 -0
- package/scripts/scan.js +398 -0
- package/test/scan.test.js +113 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Renzo Madueno
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# dotenv-exposure-check
|
|
2
|
+
|
|
3
|
+
> Probe any **live URL** for the secret artifacts that get served by accident — `.env`, `.env.production`, the `.git/` directory, production source maps (`.js.map`), `.DS_Store`, and backup/database dumps — and **prove each hit by fetching the bytes** and showing the real credentials, remote URLs, and source paths inside. Other scanners read your repo; this checks what your *server* is actually handing to strangers.
|
|
4
|
+
|
|
5
|
+
> ⚡ **Run it in one line, no install, no token:**
|
|
6
|
+
> ```bash
|
|
7
|
+
> npx dotenv-exposure-check --url https://your-app.example.com
|
|
8
|
+
> ```
|
|
9
|
+
|
|
10
|
+
> 🤝 **Want it done for you?** [Fixed-scope audit — $99 / 24h](https://buy.stripe.com/3cIeVdgikfj47yx9LkcAo0m): I verify each exposure live, help you rotate the leaked credentials, and send a written remediation report.
|
|
11
|
+
|
|
12
|
+
[](https://www.npmjs.com/package/dotenv-exposure-check) [](https://www.npmjs.com/package/dotenv-exposure-check)   
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
$ npx dotenv-exposure-check --url https://app.example.com
|
|
16
|
+
2 critical, 1 high, 1 medium — 4 CONFIRMED by fetching the bytes
|
|
17
|
+
CRITICAL /.env 5 vars readable — secret keys: DATABASE_URL, STRIPE_SECRET_KEY, JWT_SECRET
|
|
18
|
+
CRITICAL /.git/config repo cloneable — remote https://<credentials-redacted>@github.com/acme/app.git
|
|
19
|
+
HIGH /main.js.map 12 source files mapped (original source embedded)
|
|
20
|
+
MEDIUM /.DS_Store directory listing leaked
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Why this exists
|
|
24
|
+
|
|
25
|
+
Serving a secret file is the single highest-yield mistake on the web, and it
|
|
26
|
+
happens constantly: researchers catalogued **12M+ exposed `.env` files** in the
|
|
27
|
+
wild, and Palo Alto Unit 42 documented an extortion campaign that **scanned 230M
|
|
28
|
+
targets and harvested 90,000+ secret variables** from misconfigured `.env`
|
|
29
|
+
endpoints. Source maps are just as bad — even Claude Code shipped a production
|
|
30
|
+
`.js.map` leak in 2026 — and a web-readable `.git/` folder lets anyone clone your
|
|
31
|
+
entire source, remote token included.
|
|
32
|
+
|
|
33
|
+
The hard part is *confirmation*. Most single-page apps return `200 OK` with
|
|
34
|
+
`index.html` for **every** path, so "got a 200 on `/.env`" means nothing.
|
|
35
|
+
`dotenv-exposure-check` fetches each candidate and **inspects the actual bytes**:
|
|
36
|
+
an `.env` hit must contain real `KEY=VALUE` assignments, a source map must be
|
|
37
|
+
valid sourcemap JSON, a `.DS_Store` must carry the `Bud1` magic, a backup must
|
|
38
|
+
have a real archive/dump signature. You triage facts, not 200s.
|
|
39
|
+
|
|
40
|
+
## What it checks
|
|
41
|
+
|
|
42
|
+
| Check | Severity | How it's confirmed |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `.env` / `.env.production` / `.env.local` … served | critical | body parsed for `KEY=VALUE` lines; secret-looking keys (DB/API/JWT/Stripe/AWS) flagged |
|
|
45
|
+
| Exposed `.git/` directory (repo cloneable) | critical | `/.git/config` + `/.git/HEAD` validated as git stanzas; embedded remote credentials detected and redacted |
|
|
46
|
+
| Production source map (`.js.map`) exposed | high | parsed as sourcemap JSON (`version` + `mappings`); counts mapped sources, flags embedded original source |
|
|
47
|
+
| Backup / database dump downloadable | high | archive (`zip`/`gzip`) or SQL-dump signature in the bytes |
|
|
48
|
+
| `.DS_Store` directory listing | medium | `Bud1` binary magic at offset 4 |
|
|
49
|
+
|
|
50
|
+
SPA catch-all (`200` + `index.html` for everything) is explicitly rejected, so
|
|
51
|
+
the tool does not false-positive on modern frontends.
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Probe a live site (tries the common filenames for each artifact)
|
|
57
|
+
npx dotenv-exposure-check --url https://app.example.com
|
|
58
|
+
|
|
59
|
+
# Restrict to specific candidate paths
|
|
60
|
+
npx dotenv-exposure-check --url https://app.example.com --paths .env,.env.production
|
|
61
|
+
|
|
62
|
+
# Write a shareable HTML report
|
|
63
|
+
npx dotenv-exposure-check --url https://app.example.com --html report.html
|
|
64
|
+
|
|
65
|
+
# Dry run: list what would be checked, send no requests
|
|
66
|
+
npx dotenv-exposure-check --url https://app.example.com --no-probe
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Output is JSON on stdout (pipe it into CI) and a one-line summary on stderr.
|
|
70
|
+
Exit is non-zero only on usage errors — gate your pipeline on the JSON `summary`.
|
|
71
|
+
|
|
72
|
+
## Install (optional)
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm i -g dotenv-exposure-check
|
|
76
|
+
dotenv-exposure-check --url https://app.example.com
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Zero dependencies. Read-only and keyless — every request goes straight from the
|
|
80
|
+
tool to the target you name; nothing is stored, modified, or sent anywhere else.
|
|
81
|
+
**Only scan systems you own or are authorized to test.**
|
|
82
|
+
|
|
83
|
+
## Sister tools
|
|
84
|
+
|
|
85
|
+
Same active-probe philosophy across the stack, all MIT:
|
|
86
|
+
|
|
87
|
+
[supabase-security](https://github.com/Perufitlife/supabase-security-skill) ·
|
|
88
|
+
[pocketbase-security](https://github.com/Perufitlife/pocketbase-security-skill) ·
|
|
89
|
+
[firebase-security](https://github.com/Perufitlife/firebase-security-skill) ·
|
|
90
|
+
[appwrite-security](https://github.com/Perufitlife/appwrite-security-skill) ·
|
|
91
|
+
[nhost-security](https://github.com/Perufitlife/nhost-security-skill) ·
|
|
92
|
+
[strapi-security](https://github.com/Perufitlife/strapi-security) ·
|
|
93
|
+
[directus-security](https://github.com/Perufitlife/directus-security) ·
|
|
94
|
+
[aws-s3-security](https://github.com/Perufitlife/aws-s3-security) ·
|
|
95
|
+
[stripe-webhook-security](https://github.com/Perufitlife/stripe-webhook-security) ·
|
|
96
|
+
[github-actions-security](https://github.com/Perufitlife/github-actions-security) ·
|
|
97
|
+
[web-exposure-mcp](https://github.com/Perufitlife/web-exposure-mcp)
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT © [Renzo Madueno](https://github.com/Perufitlife)
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dotenv-exposure-check",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Probe a live URL for accidentally-served secret artifacts — .env, .env.production, .git/config, source maps (.js.map), .DS_Store, backup files — and CONFIRM each hit by fetching the bytes and flagging the real credentials and endpoints found inside. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"dotenv-exposure-check": "./scripts/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"scan": "node scripts/scan.js",
|
|
11
|
+
"test": "node test/scan.test.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"dotenv",
|
|
15
|
+
"env",
|
|
16
|
+
"security",
|
|
17
|
+
"security-audit",
|
|
18
|
+
"secret-scanning",
|
|
19
|
+
"secrets-detection",
|
|
20
|
+
"exposure",
|
|
21
|
+
"sourcemap",
|
|
22
|
+
"auditor",
|
|
23
|
+
"devsecops",
|
|
24
|
+
"git-config",
|
|
25
|
+
"ds-store"
|
|
26
|
+
],
|
|
27
|
+
"author": "Renzo Madueno (@Perufitlife)",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/Perufitlife/dotenv-exposure-check"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/Perufitlife/dotenv-exposure-check",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/scripts/cli.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// HTML report renderer for dotenv-exposure-check. Self-contained, no deps.
|
|
2
|
+
const esc = (s) =>
|
|
3
|
+
String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
4
|
+
|
|
5
|
+
const SEV_COLOR = { critical: "#dc2626", high: "#f97316", medium: "#eab308", low: "#3b82f6", info: "#6b7280" };
|
|
6
|
+
|
|
7
|
+
function evidenceLine(f) {
|
|
8
|
+
const e = f.evidence;
|
|
9
|
+
if (!e) return "";
|
|
10
|
+
if (f.check === "dotenv")
|
|
11
|
+
return `${e.var_count} variables readable${e.has_secrets ? ` — secret keys: ${esc((e.secret_keys || []).join(", "))}` : ""}`;
|
|
12
|
+
if (f.check === "git_config")
|
|
13
|
+
return e.kind === "config" ? `git config readable${e.remote_url ? ` — remote ${esc(e.remote_url)}` : ""}` : `git ${esc(e.kind)} readable`;
|
|
14
|
+
if (f.check === "sourcemap")
|
|
15
|
+
return `${e.source_files} source files mapped${e.embeds_source ? " (original source embedded)" : ""}`;
|
|
16
|
+
if (f.check === "ds_store") return `valid .DS_Store (${e.bytes} bytes) — directory listing leaked`;
|
|
17
|
+
if (f.check === "backup_dump") return `${esc(e.format)} artifact downloadable (${e.bytes} bytes)`;
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renderHtml(result) {
|
|
22
|
+
const { target_url, summary, findings, active_probe } = result;
|
|
23
|
+
const total = findings.filter((f) => f.confirmed).length;
|
|
24
|
+
const score = Math.max(
|
|
25
|
+
0,
|
|
26
|
+
100 - (summary.critical * 25 + summary.high * 12 + summary.medium * 5 + summary.low * 1)
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const rows = findings
|
|
30
|
+
.filter((f) => f.confirmed)
|
|
31
|
+
.map(
|
|
32
|
+
(f) => `
|
|
33
|
+
<div style="border-left:4px solid ${SEV_COLOR[f.severity]};background:#fff;padding:14px 18px;border-radius:6px;margin-bottom:12px;box-shadow:0 1px 2px rgba(0,0,0,.06)">
|
|
34
|
+
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
35
|
+
<strong>${esc(f.title)}</strong>
|
|
36
|
+
<span style="background:${SEV_COLOR[f.severity]};color:#fff;font-size:11px;padding:2px 8px;border-radius:10px;text-transform:uppercase">${f.severity}</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div style="color:#374151;font-size:14px;margin:6px 0">${esc(f.explain)}</div>
|
|
39
|
+
<div style="font-size:13px;color:#6b7280"><code>${esc(f.target)}</code></div>
|
|
40
|
+
<div style="font-size:12px;color:#b91c1c;margin-top:4px">✓ CONFIRMED by fetching the bytes — ${esc(evidenceLine(f))}</div>
|
|
41
|
+
<details style="margin-top:6px"><summary style="cursor:pointer;font-size:13px;font-weight:600;color:#047857">Fix</summary>
|
|
42
|
+
<div style="font-size:13px;background:#f0fdf4;padding:8px;border-radius:4px;margin-top:4px">${esc(f.fix || "")}</div></details>
|
|
43
|
+
</div>`
|
|
44
|
+
)
|
|
45
|
+
.join("");
|
|
46
|
+
|
|
47
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
48
|
+
<title>dotenv-exposure-check report — ${esc(target_url)}</title>
|
|
49
|
+
<style>body{font-family:system-ui,-apple-system,sans-serif;background:#f3f4f6;margin:0;color:#111827}
|
|
50
|
+
.wrap{max-width:860px;margin:0 auto;padding:24px}.card{background:#fff;padding:18px;border-radius:8px;box-shadow:0 1px 2px rgba(0,0,0,.06);margin-bottom:18px}
|
|
51
|
+
.stat{display:inline-block;text-align:center;margin-right:24px}.stat .n{font-size:30px;font-weight:700}.stat .l{font-size:12px;color:#6b7280}</style></head>
|
|
52
|
+
<body><div class="wrap">
|
|
53
|
+
<h1 style="margin:8px 0">🔓 Secret Exposure Report</h1>
|
|
54
|
+
<div style="color:#6b7280;font-size:14px">${esc(target_url)} · scored ${score}/100 · ${active_probe.confirmed} artifact(s) confirmed live</div>
|
|
55
|
+
<div class="card" style="margin-top:14px">
|
|
56
|
+
<span class="stat"><div class="n" style="color:${SEV_COLOR.critical}">${summary.critical}</div><div class="l">CRITICAL</div></span>
|
|
57
|
+
<span class="stat"><div class="n" style="color:${SEV_COLOR.high}">${summary.high}</div><div class="l">HIGH</div></span>
|
|
58
|
+
<span class="stat"><div class="n" style="color:${SEV_COLOR.medium}">${summary.medium}</div><div class="l">MEDIUM</div></span>
|
|
59
|
+
<span class="stat"><div class="n">${active_probe.probed}</div><div class="l">PATHS PROBED</div></span>
|
|
60
|
+
</div>
|
|
61
|
+
${total ? rows : `<div class="card">✅ No exposed secret artifacts confirmed in the paths probed. Re-run after each deploy.</div>`}
|
|
62
|
+
${
|
|
63
|
+
summary.critical + summary.high > 0
|
|
64
|
+
? `
|
|
65
|
+
<div class="card" style="background:#ecfdf5;border:1px solid #a7f3d0">
|
|
66
|
+
<h2 style="margin:0 0 6px">Want this fixed for you?</h2>
|
|
67
|
+
<p style="font-size:14px;color:#374151;margin:0 0 10px">You have <strong>${summary.critical} critical</strong> and <strong>${summary.high} high</strong> exposures. I verify each one live, help you rotate the leaked credentials, and send a written remediation report.</p>
|
|
68
|
+
<a href="https://buy.stripe.com/3cIeVdgikfj47yx9LkcAo0m" style="background:#047857;color:#fff;text-decoration:none;font-size:14px;padding:8px 14px;border-radius:6px">Fixed-scope audit — $99 / 24h</a>
|
|
69
|
+
<a href="https://github.com/Perufitlife/dotenv-exposure-check" style="color:#047857;font-size:14px;margin-left:10px">See the tool</a>
|
|
70
|
+
</div>`
|
|
71
|
+
: ""
|
|
72
|
+
}
|
|
73
|
+
<div style="text-align:center;font-size:12px;color:#9ca3af;padding:8px">
|
|
74
|
+
Generated by <a href="https://github.com/Perufitlife/dotenv-exposure-check" style="color:#047857">dotenv-exposure-check</a> · MIT · Runs locally, nothing leaves your machine.
|
|
75
|
+
</div>
|
|
76
|
+
</div></body></html>`;
|
|
77
|
+
}
|
package/scripts/scan.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dotenv-exposure-check — pure Node.js, no deps.
|
|
3
|
+
//
|
|
4
|
+
// Probes a live URL for the secret artifacts that get served by accident, then
|
|
5
|
+
// CONFIRMS each hit by fetching the bytes and inspecting what's actually inside:
|
|
6
|
+
//
|
|
7
|
+
// - .env / .env.production / .env.local / ... → real KEY=VALUE secrets
|
|
8
|
+
// - .git/config + .git/HEAD → cloneable repo + remote URL
|
|
9
|
+
// - JavaScript source maps (.js.map) → original source / paths leaked
|
|
10
|
+
// - .DS_Store → directory listing leaked
|
|
11
|
+
// - backup & dump files (.bak, .sql, .zip ...) → server-side files downloadable
|
|
12
|
+
//
|
|
13
|
+
// A 200 alone is not enough — many SPAs return index.html for everything. So
|
|
14
|
+
// every candidate is content-verified: a hit only counts when the body really
|
|
15
|
+
// looks like the artifact (env assignments, git config stanza, sourcemap JSON,
|
|
16
|
+
// DS_Store magic, archive/dump signatures).
|
|
17
|
+
//
|
|
18
|
+
// Usage:
|
|
19
|
+
// dotenv-exposure-check --url https://app.example.com
|
|
20
|
+
// dotenv-exposure-check --url https://app.example.com --paths .env,.env.prod
|
|
21
|
+
// dotenv-exposure-check --url https://app.example.com --html report.html
|
|
22
|
+
// dotenv-exposure-check --url https://app.example.com --no-probe
|
|
23
|
+
//
|
|
24
|
+
// Keyless and read-only. Every request goes straight from this process to the
|
|
25
|
+
// target; nothing is stored, modified, or sent anywhere else.
|
|
26
|
+
|
|
27
|
+
import { writeFileSync } from "node:fs";
|
|
28
|
+
|
|
29
|
+
const VERSION = "0.1.0";
|
|
30
|
+
const UA = `dotenv-exposure-check/${VERSION}`;
|
|
31
|
+
const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
32
|
+
|
|
33
|
+
// Cap how many bytes we pull per candidate so a giant dump doesn't blow memory.
|
|
34
|
+
const MAX_BYTES = 64 * 1024;
|
|
35
|
+
|
|
36
|
+
// ---- the artifact catalog ---------------------------------------------------
|
|
37
|
+
// Each artifact knows the paths to try, a severity, copy, a fix, and a
|
|
38
|
+
// `confirm(text, ct)` that returns evidence ONLY when the body is the real
|
|
39
|
+
// thing (so SPA catch-all index.html never produces a false positive).
|
|
40
|
+
|
|
41
|
+
const ARTIFACTS = [
|
|
42
|
+
{
|
|
43
|
+
id: "dotenv",
|
|
44
|
+
severity: "critical",
|
|
45
|
+
title: "Environment file (.env) served over HTTP",
|
|
46
|
+
explain:
|
|
47
|
+
"An .env file with KEY=VALUE secrets is reachable anonymously. Attackers mass-scan for exactly this; a single hit can hand over database passwords, API keys, and signing secrets. 12M+ exposed .env files have been catalogued in the wild.",
|
|
48
|
+
fix: "Stop serving dotfiles: block `/\\.env` at the web server / CDN, move secrets to the platform's env-var store, and rotate every credential that was in the file.",
|
|
49
|
+
paths: [
|
|
50
|
+
".env",
|
|
51
|
+
".env.production",
|
|
52
|
+
".env.prod",
|
|
53
|
+
".env.local",
|
|
54
|
+
".env.development",
|
|
55
|
+
".env.dev",
|
|
56
|
+
".env.staging",
|
|
57
|
+
".env.backup",
|
|
58
|
+
".env.bak",
|
|
59
|
+
".env.save",
|
|
60
|
+
".env.old",
|
|
61
|
+
],
|
|
62
|
+
confirm: confirmDotenv,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "git_config",
|
|
66
|
+
severity: "critical",
|
|
67
|
+
title: "Exposed .git directory (repo is cloneable)",
|
|
68
|
+
explain:
|
|
69
|
+
"The .git directory is web-readable. With /.git/config and /.git/HEAD an attacker can reconstruct your entire source tree — and the config often embeds the remote URL with an embedded token.",
|
|
70
|
+
fix: "Deny access to `/\\.git` in the web server config (and confirm /.git/HEAD 404s). Never deploy the .git folder; build artifacts only.",
|
|
71
|
+
paths: [".git/config", ".git/HEAD"],
|
|
72
|
+
confirm: confirmGitConfig,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "sourcemap",
|
|
76
|
+
severity: "high",
|
|
77
|
+
title: "Production source map (.js.map) exposed",
|
|
78
|
+
explain:
|
|
79
|
+
"A JavaScript source map is downloadable, reverse-mapping minified code back to original source — internal file paths, comments, sometimes hard-coded endpoints and keys. (Claude Code itself shipped a sourcemap leak in 2026.)",
|
|
80
|
+
fix: "Disable source-map emission in production builds, or stop uploading *.js.map to the public origin. If maps are needed for error tracking, upload them privately to your monitoring vendor only.",
|
|
81
|
+
paths: [
|
|
82
|
+
"main.js.map",
|
|
83
|
+
"app.js.map",
|
|
84
|
+
"index.js.map",
|
|
85
|
+
"bundle.js.map",
|
|
86
|
+
"assets/index.js.map",
|
|
87
|
+
"static/js/main.js.map",
|
|
88
|
+
"_next/static/chunks/main.js.map",
|
|
89
|
+
],
|
|
90
|
+
confirm: confirmSourceMap,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "ds_store",
|
|
94
|
+
severity: "medium",
|
|
95
|
+
title: ".DS_Store leaks the directory listing",
|
|
96
|
+
explain:
|
|
97
|
+
"A macOS .DS_Store file is served. It enumerates every filename in the directory it sits in, handing attackers a free map of hidden files and backups to probe next.",
|
|
98
|
+
fix: "Block `/\\.DS_Store`, delete it from the deploy, and add `.DS_Store` to .gitignore / your deploy ignore list.",
|
|
99
|
+
paths: [".DS_Store"],
|
|
100
|
+
confirm: confirmDsStore,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "backup_dump",
|
|
104
|
+
severity: "high",
|
|
105
|
+
title: "Backup / database dump downloadable",
|
|
106
|
+
explain:
|
|
107
|
+
"A backup or dump artifact is reachable anonymously. These often contain a full copy of the application or database, including credentials and customer data.",
|
|
108
|
+
fix: "Remove backups/dumps from the public web root and block the extensions (`\\.(sql|bak|zip|tar\\.gz|dump)$`) at the edge.",
|
|
109
|
+
paths: [
|
|
110
|
+
"backup.zip",
|
|
111
|
+
"backup.sql",
|
|
112
|
+
"backup.tar.gz",
|
|
113
|
+
"db.sql",
|
|
114
|
+
"dump.sql",
|
|
115
|
+
"database.sql",
|
|
116
|
+
"site.zip",
|
|
117
|
+
"www.zip",
|
|
118
|
+
"app.bak",
|
|
119
|
+
"index.php.bak",
|
|
120
|
+
"config.php.bak",
|
|
121
|
+
],
|
|
122
|
+
confirm: confirmBackup,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// ---- content confirmers -----------------------------------------------------
|
|
127
|
+
|
|
128
|
+
// Matches lines like KEY=value / KEY="value" / export KEY=value
|
|
129
|
+
const ENV_LINE = /^\s*(?:export\s+)?[A-Z][A-Z0-9_]{2,}\s*=\s*.+$/m;
|
|
130
|
+
const SECRETY_KEY =
|
|
131
|
+
/(SECRET|PASSWORD|PASSWD|TOKEN|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|DATABASE_?URL|DB_PASS|AWS_|STRIPE_|JWT|SUPABASE|MONGODB_URI|REDIS_URL)/i;
|
|
132
|
+
|
|
133
|
+
function looksLikeHtml(text, ct) {
|
|
134
|
+
if (ct && ct.includes("text/html")) return true;
|
|
135
|
+
const head = text.slice(0, 512).toLowerCase();
|
|
136
|
+
return head.includes("<!doctype html") || head.includes("<html");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function confirmDotenv(text, ct) {
|
|
140
|
+
if (looksLikeHtml(text, ct)) return null;
|
|
141
|
+
const lines = text.split(/\r?\n/);
|
|
142
|
+
const assignments = lines.filter((l) => ENV_LINE.test(l));
|
|
143
|
+
if (assignments.length === 0) return null;
|
|
144
|
+
const keys = assignments
|
|
145
|
+
.map((l) => l.replace(/^\s*export\s+/, "").split("=")[0].trim())
|
|
146
|
+
.filter(Boolean);
|
|
147
|
+
const secretKeys = keys.filter((k) => SECRETY_KEY.test(k));
|
|
148
|
+
return {
|
|
149
|
+
var_count: keys.length,
|
|
150
|
+
sample_keys: keys.slice(0, 8),
|
|
151
|
+
secret_keys: [...new Set(secretKeys)].slice(0, 8),
|
|
152
|
+
has_secrets: secretKeys.length > 0,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function confirmGitConfig(text, ct) {
|
|
157
|
+
if (looksLikeHtml(text, ct)) return null;
|
|
158
|
+
if (/^ref:\s+refs\//m.test(text)) return { kind: "HEAD", ref: text.trim().slice(0, 80) };
|
|
159
|
+
if (/\[core\]/.test(text) || /\[remote /.test(text)) {
|
|
160
|
+
const remote = (text.match(/url\s*=\s*(\S+)/) || [])[1] || null;
|
|
161
|
+
return { kind: "config", remote_url: remote ? redactUrl(remote) : null };
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function confirmSourceMap(text, ct) {
|
|
167
|
+
if (looksLikeHtml(text, ct)) return null;
|
|
168
|
+
let j;
|
|
169
|
+
try {
|
|
170
|
+
j = JSON.parse(text);
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
if (typeof j !== "object" || j === null) return null;
|
|
175
|
+
if (!("version" in j) || !("mappings" in j)) return null;
|
|
176
|
+
const sources = Array.isArray(j.sources) ? j.sources : [];
|
|
177
|
+
return {
|
|
178
|
+
sourcemap_version: j.version,
|
|
179
|
+
source_files: sources.length,
|
|
180
|
+
sample_sources: sources.slice(0, 5),
|
|
181
|
+
embeds_source: Array.isArray(j.sourcesContent) && j.sourcesContent.length > 0,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function confirmDsStore(text, ct, rawBytes) {
|
|
186
|
+
// .DS_Store starts with magic bytes 00 00 00 01 42 75 64 31 ("Bud1").
|
|
187
|
+
const bytes = rawBytes || Buffer.from(text, "binary");
|
|
188
|
+
if (bytes.length < 8) return null;
|
|
189
|
+
const magic = bytes.subarray(0, 8);
|
|
190
|
+
if (magic[4] === 0x42 && magic[5] === 0x75 && magic[6] === 0x64 && magic[7] === 0x31) {
|
|
191
|
+
return { magic: "Bud1", bytes: bytes.length };
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function confirmBackup(text, ct, rawBytes, url) {
|
|
197
|
+
if (looksLikeHtml(text, ct)) return null;
|
|
198
|
+
const bytes = rawBytes || Buffer.from(text, "binary");
|
|
199
|
+
if (bytes.length < 4) return null;
|
|
200
|
+
// Archive / dump signatures.
|
|
201
|
+
const b = bytes;
|
|
202
|
+
const sig = (...xs) => xs.every((v, i) => b[i] === v);
|
|
203
|
+
if (sig(0x50, 0x4b, 0x03, 0x04)) return { format: "zip", bytes: b.length };
|
|
204
|
+
if (sig(0x1f, 0x8b)) return { format: "gzip", bytes: b.length };
|
|
205
|
+
// SQL dumps are text — look for telltale statements.
|
|
206
|
+
const head = text.slice(0, 4096);
|
|
207
|
+
if (/(CREATE TABLE|INSERT INTO|DROP TABLE|-- MySQL dump|PostgreSQL database dump)/i.test(head)) {
|
|
208
|
+
return { format: "sql", bytes: b.length };
|
|
209
|
+
}
|
|
210
|
+
// PHP backups.
|
|
211
|
+
if (url && /\.php\.bak$/i.test(url) && /<\?php/.test(head)) {
|
|
212
|
+
return { format: "php-source", bytes: b.length };
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---- helpers ----------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
function redactUrl(u) {
|
|
220
|
+
// Strip embedded credentials but flag that they were present.
|
|
221
|
+
try {
|
|
222
|
+
const m = u.match(/^([a-z]+:\/\/)([^@/]+)@(.*)$/i);
|
|
223
|
+
if (m) return `${m[1]}<credentials-redacted>@${m[3]}`;
|
|
224
|
+
} catch {
|
|
225
|
+
/* ignore */
|
|
226
|
+
}
|
|
227
|
+
return u;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function fetchArtifact(url) {
|
|
231
|
+
try {
|
|
232
|
+
const r = await fetch(url, {
|
|
233
|
+
headers: { "User-Agent": UA, Accept: "*/*" },
|
|
234
|
+
redirect: "manual",
|
|
235
|
+
});
|
|
236
|
+
// A redirect to a login/home page is not an exposed file.
|
|
237
|
+
if (r.status >= 300 && r.status < 400) {
|
|
238
|
+
return { ok: false, status: r.status, redirected: true };
|
|
239
|
+
}
|
|
240
|
+
const ct = r.headers.get("content-type") || "";
|
|
241
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
242
|
+
const bytes = buf.subarray(0, MAX_BYTES);
|
|
243
|
+
return {
|
|
244
|
+
ok: r.ok,
|
|
245
|
+
status: r.status,
|
|
246
|
+
contentType: ct,
|
|
247
|
+
bytes,
|
|
248
|
+
text: bytes.toString("utf8"),
|
|
249
|
+
};
|
|
250
|
+
} catch (e) {
|
|
251
|
+
return { ok: false, status: 0, error: e.message };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---- main scan --------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
export async function scan({ url, paths = null, activeProbe = true } = {}) {
|
|
258
|
+
if (!url) throw new Error("scan() requires { url }");
|
|
259
|
+
const base = url.replace(/\/+$/, "");
|
|
260
|
+
const findings = [];
|
|
261
|
+
let probed = 0;
|
|
262
|
+
let confirmed = 0;
|
|
263
|
+
|
|
264
|
+
for (const art of ARTIFACTS) {
|
|
265
|
+
// Allow restricting which paths to try (per-artifact still confirmed).
|
|
266
|
+
const tryPaths = paths
|
|
267
|
+
? art.paths.filter((p) => paths.includes(p))
|
|
268
|
+
: art.paths;
|
|
269
|
+
if (tryPaths.length === 0) continue;
|
|
270
|
+
|
|
271
|
+
let hit = null;
|
|
272
|
+
for (const p of tryPaths) {
|
|
273
|
+
if (!activeProbe) break;
|
|
274
|
+
const full = `${base}/${p}`;
|
|
275
|
+
const res = await fetchArtifact(full);
|
|
276
|
+
probed++;
|
|
277
|
+
if (res.status !== 200 || !res.bytes) continue;
|
|
278
|
+
const evidence = art.confirm(res.text, res.contentType, res.bytes, full);
|
|
279
|
+
if (evidence) {
|
|
280
|
+
hit = { path: p, url: full, status: res.status, contentType: res.contentType, evidence };
|
|
281
|
+
break; // one confirmed artifact of this class is enough
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (hit) {
|
|
286
|
+
confirmed++;
|
|
287
|
+
findings.push({
|
|
288
|
+
check: art.id,
|
|
289
|
+
severity: art.severity,
|
|
290
|
+
title: art.title,
|
|
291
|
+
explain: art.explain,
|
|
292
|
+
target: hit.url,
|
|
293
|
+
confirmed: true,
|
|
294
|
+
evidence: hit.evidence,
|
|
295
|
+
fix: art.fix,
|
|
296
|
+
});
|
|
297
|
+
} else if (!activeProbe) {
|
|
298
|
+
// In static mode, list what WOULD be checked.
|
|
299
|
+
findings.push({
|
|
300
|
+
check: art.id,
|
|
301
|
+
severity: "info",
|
|
302
|
+
title: `Would probe: ${art.title}`,
|
|
303
|
+
explain: art.explain,
|
|
304
|
+
target: `${base}/{${art.paths.join(",")}}`,
|
|
305
|
+
confirmed: false,
|
|
306
|
+
fix: art.fix,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
312
|
+
const summary = findings.reduce(
|
|
313
|
+
(acc, f) => ({ ...acc, [f.severity]: (acc[f.severity] || 0) + 1 }),
|
|
314
|
+
{ critical: 0, high: 0, medium: 0, low: 0, info: 0 }
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
target_url: base,
|
|
319
|
+
scanned_by: `dotenv-exposure-check v${VERSION}`,
|
|
320
|
+
active_probe: { enabled: activeProbe, probed, confirmed },
|
|
321
|
+
summary,
|
|
322
|
+
findings,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- CLI --------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
function parseArgs(argv) {
|
|
329
|
+
const a = argv.slice(2);
|
|
330
|
+
const flag = (k) => {
|
|
331
|
+
const i = a.indexOf(k);
|
|
332
|
+
return i !== -1 ? a[i + 1] : null;
|
|
333
|
+
};
|
|
334
|
+
return {
|
|
335
|
+
help: a.includes("--help") || a.includes("-h"),
|
|
336
|
+
url: flag("--url") || process.env.TARGET_URL,
|
|
337
|
+
paths: (flag("--paths") || "")
|
|
338
|
+
.split(",")
|
|
339
|
+
.map((s) => s.trim())
|
|
340
|
+
.filter(Boolean),
|
|
341
|
+
activeProbe: !a.includes("--no-probe"),
|
|
342
|
+
html: a.includes("--html") ? flag("--html") || "dotenv-exposure-report.html" : null,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function run() {
|
|
347
|
+
const opts = parseArgs(process.argv);
|
|
348
|
+
if (opts.help || !opts.url) {
|
|
349
|
+
console.error(`dotenv-exposure-check — probe a URL for served secret artifacts and confirm each hit.
|
|
350
|
+
|
|
351
|
+
Usage:
|
|
352
|
+
dotenv-exposure-check --url https://app.example.com
|
|
353
|
+
dotenv-exposure-check --url https://app.example.com --paths .env,.env.production
|
|
354
|
+
dotenv-exposure-check --url https://app.example.com --html report.html
|
|
355
|
+
dotenv-exposure-check --url https://app.example.com --no-probe
|
|
356
|
+
|
|
357
|
+
Flags:
|
|
358
|
+
--url <url> Target base URL (or TARGET_URL env)
|
|
359
|
+
--paths a,b,c Restrict to specific candidate paths
|
|
360
|
+
--no-probe List what would be checked without sending any request
|
|
361
|
+
--html <file> Write a shareable HTML report
|
|
362
|
+
|
|
363
|
+
Detects (and content-confirms): .env files, exposed .git directory,
|
|
364
|
+
production source maps (.js.map), .DS_Store directory listing,
|
|
365
|
+
backup / database dumps. Keyless and read-only.`);
|
|
366
|
+
process.exit(opts.url ? 0 : 1);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const result = await scan({
|
|
370
|
+
url: opts.url,
|
|
371
|
+
paths: opts.paths.length ? opts.paths : null,
|
|
372
|
+
activeProbe: opts.activeProbe,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
if (opts.html) {
|
|
376
|
+
const { renderHtml } = await import("./report.js");
|
|
377
|
+
writeFileSync(opts.html, renderHtml(result));
|
|
378
|
+
console.error(`HTML report written to ${opts.html}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
console.log(JSON.stringify(result, null, 2));
|
|
382
|
+
const s = result.summary;
|
|
383
|
+
console.error(
|
|
384
|
+
`\n${s.critical} critical, ${s.high} high, ${s.medium} medium` +
|
|
385
|
+
(result.active_probe.enabled
|
|
386
|
+
? ` — ${result.active_probe.confirmed} CONFIRMED by fetching the bytes`
|
|
387
|
+
: "")
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const isMain =
|
|
392
|
+
process.argv[1] &&
|
|
393
|
+
(import.meta.url === `file://${process.argv[1].replace(/\\/g, "/")}` ||
|
|
394
|
+
import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/")));
|
|
395
|
+
if (isMain) run().catch((e) => {
|
|
396
|
+
console.error(e.message);
|
|
397
|
+
process.exit(1);
|
|
398
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Tests: drive scan() against a mocked fetch and assert that
|
|
2
|
+
// (1) a leaky site has each artifact CONFIRMED by its real bytes,
|
|
3
|
+
// (2) a clean / SPA-catch-all site produces zero false positives,
|
|
4
|
+
// (3) the .DS_Store binary magic and zip signature are matched correctly.
|
|
5
|
+
import { scan } from "../scripts/scan.js";
|
|
6
|
+
import assert from "node:assert";
|
|
7
|
+
|
|
8
|
+
const SPA_HTML = "<!doctype html><html><head><title>App</title></head><body>spa</body></html>";
|
|
9
|
+
|
|
10
|
+
const REAL_ENV = `# production
|
|
11
|
+
NODE_ENV=production
|
|
12
|
+
DATABASE_URL=postgres://user:s3cr3t@db.internal:5432/app
|
|
13
|
+
STRIPE_SECRET_KEY=sk_live_51abcDEF
|
|
14
|
+
JWT_SECRET=supersecretvalue
|
|
15
|
+
PORT=3000
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const REAL_GIT_CONFIG = `[core]
|
|
19
|
+
\trepositoryformatversion = 0
|
|
20
|
+
[remote "origin"]
|
|
21
|
+
\turl = https://x-token:ghp_abc123@github.com/acme/app.git
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const REAL_SOURCEMAP = JSON.stringify({
|
|
25
|
+
version: 3,
|
|
26
|
+
sources: ["webpack://app/src/index.js", "webpack://app/src/config.js"],
|
|
27
|
+
sourcesContent: ["const x=1", "export const API='https://api.acme.com'"],
|
|
28
|
+
mappings: "AAAA,SAASA",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// .DS_Store: 4 bytes + "Bud1" magic.
|
|
32
|
+
const DS_STORE = Buffer.concat([Buffer.from([0, 0, 0, 1]), Buffer.from("Bud1"), Buffer.alloc(32)]);
|
|
33
|
+
|
|
34
|
+
// zip backup: PK\x03\x04 signature.
|
|
35
|
+
const ZIP_BACKUP = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.alloc(64)]);
|
|
36
|
+
|
|
37
|
+
function resp(status, body, ct) {
|
|
38
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), "utf8");
|
|
39
|
+
return {
|
|
40
|
+
ok: status >= 200 && status < 300,
|
|
41
|
+
status,
|
|
42
|
+
headers: { get: (k) => (k.toLowerCase() === "content-type" ? ct || "application/octet-stream" : null) },
|
|
43
|
+
arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function leakyFetch(url) {
|
|
48
|
+
const u = String(url);
|
|
49
|
+
if (u.endsWith("/.env")) return resp(200, REAL_ENV, "text/plain");
|
|
50
|
+
if (u.endsWith("/.git/config")) return resp(200, REAL_GIT_CONFIG, "text/plain");
|
|
51
|
+
if (u.endsWith("/main.js.map")) return resp(200, REAL_SOURCEMAP, "application/json");
|
|
52
|
+
if (u.endsWith("/.DS_Store")) return resp(200, DS_STORE, "application/octet-stream");
|
|
53
|
+
if (u.endsWith("/backup.zip")) return resp(200, ZIP_BACKUP, "application/zip");
|
|
54
|
+
// Everything else: SPA catch-all returns index.html with HTTP 200.
|
|
55
|
+
return resp(200, SPA_HTML, "text/html");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cleanFetch() {
|
|
59
|
+
// Real 404s everywhere.
|
|
60
|
+
return resp(404, "Not Found", "text/plain");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function spaFetch() {
|
|
64
|
+
// The dangerous case: 200 + index.html for ALL paths. Must NOT false-positive.
|
|
65
|
+
return resp(200, SPA_HTML, "text/html");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let pass = 0;
|
|
69
|
+
|
|
70
|
+
// 1) Leaky site — every artifact confirmed by its bytes.
|
|
71
|
+
globalThis.fetch = async (url) => leakyFetch(url);
|
|
72
|
+
let r = await scan({ url: "https://leaky.test" });
|
|
73
|
+
const got = (id) => r.findings.find((f) => f.check === id && f.confirmed);
|
|
74
|
+
|
|
75
|
+
assert.ok(got("dotenv"), "should confirm .env");
|
|
76
|
+
assert.ok(got("dotenv").evidence.has_secrets, "should flag secret keys in .env");
|
|
77
|
+
assert.ok(got("dotenv").evidence.secret_keys.includes("DATABASE_URL"), "DATABASE_URL is a secret key");
|
|
78
|
+
assert.ok(got("git_config"), "should confirm .git/config");
|
|
79
|
+
assert.ok(/credentials-redacted/.test(got("git_config").evidence.remote_url), "remote creds redacted");
|
|
80
|
+
assert.ok(got("sourcemap"), "should confirm sourcemap");
|
|
81
|
+
assert.strictEqual(got("sourcemap").evidence.source_files, 2, "two source files mapped");
|
|
82
|
+
assert.ok(got("ds_store"), "should confirm .DS_Store via Bud1 magic");
|
|
83
|
+
assert.ok(got("backup_dump"), "should confirm zip backup via PK signature");
|
|
84
|
+
assert.strictEqual(got("backup_dump").evidence.format, "zip", "format is zip");
|
|
85
|
+
assert.ok(r.active_probe.confirmed >= 5, "should confirm >=5 artifacts");
|
|
86
|
+
assert.ok(r.summary.critical >= 2, "env + git are critical");
|
|
87
|
+
console.log("PASS: leaky site — all 5 artifact classes confirmed by their real bytes");
|
|
88
|
+
pass++;
|
|
89
|
+
|
|
90
|
+
// 2) Clean site — nothing confirmed.
|
|
91
|
+
globalThis.fetch = async () => cleanFetch();
|
|
92
|
+
r = await scan({ url: "https://clean.test" });
|
|
93
|
+
assert.strictEqual(r.findings.length, 0, "clean site has no findings");
|
|
94
|
+
assert.strictEqual(r.active_probe.confirmed, 0, "clean site confirms nothing");
|
|
95
|
+
console.log("PASS: clean site (all 404) — zero findings");
|
|
96
|
+
pass++;
|
|
97
|
+
|
|
98
|
+
// 3) SPA catch-all (200 + HTML for everything) — zero false positives.
|
|
99
|
+
globalThis.fetch = async () => spaFetch();
|
|
100
|
+
r = await scan({ url: "https://spa.test" });
|
|
101
|
+
assert.strictEqual(r.active_probe.confirmed, 0, "SPA catch-all must not false-positive");
|
|
102
|
+
assert.strictEqual(r.findings.length, 0, "SPA index.html for every path is not an exposure");
|
|
103
|
+
console.log("PASS: SPA catch-all (200 index.html everywhere) — zero false positives");
|
|
104
|
+
pass++;
|
|
105
|
+
|
|
106
|
+
// 4) Static mode lists checks without probing.
|
|
107
|
+
r = await scan({ url: "https://x.test", activeProbe: false });
|
|
108
|
+
assert.strictEqual(r.active_probe.probed, 0, "no requests in --no-probe mode");
|
|
109
|
+
assert.ok(r.findings.length >= 5, "static mode lists every artifact class");
|
|
110
|
+
console.log("PASS: static mode (--no-probe) lists checks, sends no requests");
|
|
111
|
+
pass++;
|
|
112
|
+
|
|
113
|
+
console.log(`\n${pass}/4 tests passed`);
|