shipped-secrets 0.0.0-stage → 1.0.1
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 +140 -3
- package/bin/shipped-secrets.js +71 -0
- package/package.json +20 -4
- package/src/env.js +111 -0
- package/src/history.js +87 -0
- package/src/index.d.ts +29 -0
- package/src/index.js +47 -0
- package/src/scan.js +142 -0
- package/src/shapes.js +55 -0
- package/src/url.js +67 -0
- package/src/verify.js +70 -0
- package/src/vite.d.ts +1 -0
- package/src/vite.js +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fuad Laguda
|
|
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
CHANGED
|
@@ -1,3 +1,140 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# shipped-secrets
|
|
2
|
+
|
|
3
|
+
Find secrets that ship to the browser, or sit in your git history, and check whether they still work.
|
|
4
|
+
|
|
5
|
+
Every frontend framework has a prefix that puts a setting into the JavaScript every visitor downloads: `VITE_`, `NEXT_PUBLIC_`, `REACT_APP_`, `EXPO_PUBLIC_`, `PUBLIC_`. Give a server key that prefix by mistake, or paste a key into client code, and it is public. The build works, the site works, and nobody notices until a bill or a breach. One Vite user lost about €800 when bots scraped a `VITE_GEMINI_API_KEY` from the bundle.
|
|
6
|
+
|
|
7
|
+
`shipped-secrets` looks at what your build actually ships and tells you, before you deploy.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx shipped-secrets
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
ERROR .next/static/chunks/app/page-5fb8.js:1 NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY from .env.local [eyJh…sM (157 chars)]
|
|
15
|
+
ERROR .next/server/app/index.html:1 STRIPE_SECRET_KEY from .env.local [sk_t…H5 (38 chars)] (2 times)
|
|
16
|
+
warn .env.local:1 NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY has a public prefix, so its value goes to every visitor. ...
|
|
17
|
+
|
|
18
|
+
2 secrets shipped to the browser, in 3 places. Rotate them: deleting a key from the code does not take it back from anyone who already downloaded it.
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Secrets are always masked in the output. Nothing is sent anywhere unless you ask for `--verify`.
|
|
22
|
+
|
|
23
|
+
## What it checks
|
|
24
|
+
|
|
25
|
+
1. **Your real secret values.** It reads your `.env*` files (not `.env.example`), Cloudflare's `.dev.vars`, and in CI the environment variables whose names look secret. Then it searches every file the browser gets, source maps included, for each server value. This catches any secret, whatever it looks like, including your own tokens that no pattern knows. It also looks for each value in base64 (standard and URL-safe, with or without padding), so a key put through `btoa()` on its way into the bundle is still found.
|
|
26
|
+
2. **Known key shapes**, for keys that are not in your local files: Stripe secret and restricted keys, Supabase service_role keys and `sb_secret_` keys, OpenAI, Anthropic, AWS, GitHub, SendGrid, Mailgun, Resend, Twilio, Slack, private keys, and database URLs with a password. Google API keys are a warning, because Maps and Firebase browser keys are meant to be public.
|
|
27
|
+
3. **Supabase keys are decoded.** The anon key and the service_role key look the same. `shipped-secrets` reads the role inside the key, so the anon key is never reported and the service_role key always is.
|
|
28
|
+
4. **Risky names.** A public prefix on a name that sounds secret (`VITE_STRIPE_SECRET`, `NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY`) is a warning. Names meant for browsers (anon key, publishable key, client ID, client token, site key) are not.
|
|
29
|
+
5. **Source maps left public.** A warning, because anyone can read your original code from them, and a key pasted into the code is in there too.
|
|
30
|
+
|
|
31
|
+
## Where it looks
|
|
32
|
+
|
|
33
|
+
It finds the browser output by itself and never reads server code:
|
|
34
|
+
|
|
35
|
+
| framework | folder |
|
|
36
|
+
|---|---|
|
|
37
|
+
| Next.js | `.next/static`, plus prerendered pages in `.next/server/app` (`.html`, `.rsc`), which go straight to visitors |
|
|
38
|
+
| Next.js static export | `out` |
|
|
39
|
+
| Vite, Astro | `dist`, or `dist/client` when there is a server build |
|
|
40
|
+
| Create React App, React Router, Remix | `build`, or `build/client` |
|
|
41
|
+
| SvelteKit | `.svelte-kit/output/client` |
|
|
42
|
+
| Nuxt, Nitro | `.output/public` |
|
|
43
|
+
| Vercel output | `.vercel/output/static` |
|
|
44
|
+
| Storybook | `storybook-static` |
|
|
45
|
+
|
|
46
|
+
Folders named `server` or `ssr` inside them are skipped. Point it anywhere else with `--dir`.
|
|
47
|
+
|
|
48
|
+
## A live site
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
npx shipped-secrets https://your.site
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It loads the page the way a stranger would, follows its scripts, the chunks they load and their source maps, and looks for key shapes. Run it from your project folder and it also looks for your own `.env` values on the live site. Only files on the same site are fetched.
|
|
55
|
+
|
|
56
|
+
## In your build
|
|
57
|
+
|
|
58
|
+
Vite plugin, which fails the build when a secret ships:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
// vite.config.js
|
|
62
|
+
import shippedSecrets from 'shipped-secrets/vite'
|
|
63
|
+
|
|
64
|
+
export default { plugins: [shippedSecrets()] }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Any framework, after the build in CI:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
npm run build && npx shipped-secrets
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
It exits with code 1 when a secret is found, so CI stops. Warnings do not fail it unless you add `--strict`.
|
|
74
|
+
|
|
75
|
+
## When something is meant to be public
|
|
76
|
+
|
|
77
|
+
Add `# shipped-ok` to the line in your `.env` file:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
NEXT_PUBLIC_MAPS_KEY=AIza... # shipped-ok, restricted to our domain
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
or pass `--allow NAME`. `--json` gives the findings as JSON, masked like the text output.
|
|
84
|
+
|
|
85
|
+
## Git history
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
npx shipped-secrets --history
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A key that was committed once is in every clone of the repository, even after you delete it. `--history` reads every commit on every branch and reports:
|
|
92
|
+
|
|
93
|
+
- known key shapes in any line ever added
|
|
94
|
+
- your current `.env` secret values in any file ever committed, so a key you moved out of the code is still caught where it used to be
|
|
95
|
+
- any `.env` file ever committed with a server secret in it. A public build switch like `VITE_EDITION=web` is not reported.
|
|
96
|
+
|
|
97
|
+
Each finding names the file, line, commit and date. Run it without a build and it searches history only.
|
|
98
|
+
|
|
99
|
+
## Is the key still live?
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
npx shipped-secrets --verify
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
For each key it finds, it asks that key's own provider, with a read-only call, whether the key still works:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
ERROR dist/assets/index-3f9a.js:1 Stripe secret or restricted key [sk_l…x2 (107 chars)] (LIVE, rotate now)
|
|
109
|
+
ERROR dist/assets/index-3f9a.js:1 GitHub token [ghp_…9Q (40 chars)] (dead)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Checked: Stripe (`/v1/balance`), OpenAI and Anthropic (list models), GitHub (`/user`), SendGrid, Mailgun, Resend, Slack (`auth.test`), and Supabase service_role keys (your project's own API, read from the key). Others are marked "not checked". The key goes to its own provider over HTTPS and nowhere else, and only when you pass `--verify`. Live keys are listed first.
|
|
113
|
+
|
|
114
|
+
`--all` is `--history` and `--verify` together.
|
|
115
|
+
|
|
116
|
+
## What it does not do
|
|
117
|
+
|
|
118
|
+
- It does not find a secret that was split into pieces, encrypted, or encoded some other way before it reached the bundle. It matches the exact value, its base64 forms and the known key shapes.
|
|
119
|
+
- It does not fix anything. A shipped or committed secret must be rotated at the provider: deleting it from the code, or rewriting history, does not take it back from anyone who already has it.
|
|
120
|
+
- `--verify` does not check AWS, Twilio, private keys or database URLs: checking those safely needs more than one harmless call.
|
|
121
|
+
|
|
122
|
+
## Tested
|
|
123
|
+
|
|
124
|
+
`npm test` runs the unit tests. `npm run test:apps` builds a real Vite app and a real Next.js app with planted fake keys, and checks that:
|
|
125
|
+
|
|
126
|
+
- a Stripe key behind `VITE_` is found by its real value, and a key pasted into code is found in the source map too
|
|
127
|
+
- the Supabase anon key and a server-only token are not reported
|
|
128
|
+
- a Supabase service_role key behind `NEXT_PUBLIC_` is found in the Next.js client bundle
|
|
129
|
+
- a secret printed into a page by a server component is found in the prerendered HTML and RSC payload
|
|
130
|
+
- a server-only secret in Next.js server code is not reported
|
|
131
|
+
- the same build served locally is caught by URL, with and without local `.env` values
|
|
132
|
+
- the Vite plugin stops a build with a secret and passes a clean one
|
|
133
|
+
|
|
134
|
+
The unit tests also build a git repository with a key committed and then deleted and check `--history` finds it, and check `--verify` against a stand-in provider (live, dead and unreachable). Full keys are never in the JSON output.
|
|
135
|
+
|
|
136
|
+
Fake keys are made while the tests run, so nothing secret-shaped is ever committed.
|
|
137
|
+
|
|
138
|
+
## Licence
|
|
139
|
+
|
|
140
|
+
MIT. By Fuad Laguda.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// shipped-secrets [folder or URL] [--dir <path>] [--allow NAME] [--history] [--verify] [--all] [--strict] [--json]
|
|
3
|
+
//
|
|
4
|
+
// With no argument: finds the browser output in this project (dist, .next/static,
|
|
5
|
+
// build, out, ...), reads this project's .env files, and looks for your real
|
|
6
|
+
// secret values and known key shapes in what ships. With a URL: scans the live
|
|
7
|
+
// site the way a stranger would, using this folder's .env values too.
|
|
8
|
+
// --history searches every commit, --verify asks each key's provider if it still works.
|
|
9
|
+
// Exit code 1 when a secret is found, or a warning with --strict.
|
|
10
|
+
|
|
11
|
+
import { resolve } from 'node:path'
|
|
12
|
+
import { loadSecrets, report, scanHistory, scanProject, scanUrl, verifyFindings } from '../src/index.js'
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2)
|
|
15
|
+
const flag = (name) => args.includes(name)
|
|
16
|
+
const values = (name) => args.flatMap((a, i) => (a === name && args[i + 1] ? [args[i + 1]] : []))
|
|
17
|
+
const positional = args.filter((a, i) => !a.startsWith('--') && !['--dir', '--allow'].includes(args[i - 1]))
|
|
18
|
+
|
|
19
|
+
if (flag('--help') || flag('-h')) {
|
|
20
|
+
console.log(`Usage: shipped-secrets [folder | https://your.site] [options]
|
|
21
|
+
|
|
22
|
+
Finds secrets that ship to the browser: your real .env values, known key shapes
|
|
23
|
+
(Stripe, Supabase service_role, OpenAI, Anthropic, AWS, GitHub, ...), public-prefixed
|
|
24
|
+
names that look secret, and source maps left public.
|
|
25
|
+
|
|
26
|
+
--history also search every commit in git history, and flag any .env file ever committed
|
|
27
|
+
--verify ask each found key's own provider whether it still works (sends the key to that provider only)
|
|
28
|
+
--all --history and --verify
|
|
29
|
+
--dir <path> scan this folder instead of finding the build output (repeatable)
|
|
30
|
+
--allow NAME a variable that is fine to ship (repeatable). Or add "# shipped-ok" to its .env line
|
|
31
|
+
--strict warnings fail too
|
|
32
|
+
--json machine-readable output (full keys are never included)`)
|
|
33
|
+
process.exit(0)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const target = positional[0] ?? '.'
|
|
37
|
+
const allow = values('--allow')
|
|
38
|
+
const history = flag('--history') || flag('--all')
|
|
39
|
+
const verify = flag('--verify') || flag('--all')
|
|
40
|
+
let result
|
|
41
|
+
if (/^https?:\/\//.test(target)) {
|
|
42
|
+
const { secrets } = loadSecrets(process.cwd(), { env: process.env, allow })
|
|
43
|
+
result = await scanUrl(target, secrets)
|
|
44
|
+
} else {
|
|
45
|
+
const root = resolve(target)
|
|
46
|
+
const dirs = values('--dir')
|
|
47
|
+
result = scanProject(root, { dirs: dirs.length ? dirs.map((d) => resolve(root, d)) : undefined, allow })
|
|
48
|
+
if (!dirs.length && !result.dirs.length && !history) {
|
|
49
|
+
console.error('No build output found (dist, build, out, .next/static, ...). Build first, pass --dir <path>, or use --history.')
|
|
50
|
+
process.exit(2)
|
|
51
|
+
}
|
|
52
|
+
if (history) {
|
|
53
|
+
const { secrets } = loadSecrets(root, { env: {}, allow })
|
|
54
|
+
const past = await scanHistory(root, secrets)
|
|
55
|
+
if (!past) console.error(`${root} is not in a git repository, so history was not searched.`)
|
|
56
|
+
else {
|
|
57
|
+
result.findings.push(...past.findings)
|
|
58
|
+
result.commits = past.commits
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (verify) {
|
|
64
|
+
await verifyFindings(result.findings)
|
|
65
|
+
result.verified = true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (flag('--json')) console.log(JSON.stringify(result, null, 2))
|
|
69
|
+
else console.log(report(result))
|
|
70
|
+
const errors = result.findings.some((f) => f.level === 'error')
|
|
71
|
+
process.exit(errors || (flag('--strict') && result.findings.length) ? 1 : 0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shipped-secrets",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Find secrets that ship to the browser or sit in git history, and check whether they still work: your real .env values, known key shapes and risky public env names, in any framework's build output, source maps included.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": { "types": "./src/index.d.ts", "default": "./src/index.js" },
|
|
8
|
+
"./vite": { "types": "./src/vite.d.ts", "default": "./src/vite.js" }
|
|
9
|
+
},
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"bin": { "shipped-secrets": "bin/shipped-secrets.js" },
|
|
12
|
+
"files": ["src", "bin"],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test test/*.test.js",
|
|
15
|
+
"test:apps": "node test/apps/run.mjs"
|
|
16
|
+
},
|
|
17
|
+
"keywords": ["secrets", "security", "vite", "vite-plugin", "nextjs", "env", "bundle", "leak", "supabase", "stripe", "ci", "git-history", "verify"],
|
|
18
|
+
"author": "Fuad Laguda",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"repository": { "type": "git", "url": "git+https://github.com/lagudafuadtosin/shipped-secrets.git" },
|
|
21
|
+
"engines": { "node": ">=18" }
|
|
22
|
+
}
|
package/src/env.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Reads the secret values the project actually has, so the scan can look for the
|
|
2
|
+
// real thing in what ships, not just for shapes.
|
|
3
|
+
//
|
|
4
|
+
// Sources: every .env* file in the project folder (not the .example / .sample /
|
|
5
|
+
// .template ones) and Cloudflare's .dev.vars, plus, for CI, environment
|
|
6
|
+
// variables whose names look secret. A value counts as a secret only if its
|
|
7
|
+
// name has no public prefix. Values that are also given to a public-prefixed
|
|
8
|
+
// name are public on purpose and left out.
|
|
9
|
+
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
export const PUBLIC_PREFIXES = ['VITE_', 'NEXT_PUBLIC_', 'REACT_APP_', 'EXPO_PUBLIC_', 'PUBLIC_', 'NUXT_PUBLIC_', 'GATSBY_', 'STORYBOOK_']
|
|
14
|
+
export const isPublicName = (name) => PUBLIC_PREFIXES.some((p) => name.startsWith(p))
|
|
15
|
+
const stripPrefix = (name) => {
|
|
16
|
+
const p = PUBLIC_PREFIXES.find((x) => name.startsWith(x))
|
|
17
|
+
return p ? name.slice(p.length) : name
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SECRET_WORDS = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE|API_?KEY|ACCESS_?KEY|SERVICE_?ROLE|DATABASE_URL|DB_URL|CONNECTION_STRING|CREDENTIAL|AUTH_KEY|WEBHOOK)/
|
|
21
|
+
// Names that look secret but are meant for browsers.
|
|
22
|
+
const SAFE_WORDS = /(ANON_?KEY|PUBLISHABLE|PUBLIC_?KEY|SITE_?KEY|MEASUREMENT_ID|CLIENT_?ID|CLIENT_?TOKEN|APP_?ID)/
|
|
23
|
+
|
|
24
|
+
export const looksSecretName = (name) => SECRET_WORDS.test(name) && !SAFE_WORDS.test(name)
|
|
25
|
+
export const looksSafeName = (name) => SAFE_WORDS.test(name)
|
|
26
|
+
|
|
27
|
+
/** Parses dotenv text. Returns [{ name, value, line, ok }] where ok = a "shipped-ok" comment on the line. */
|
|
28
|
+
export function parseDotenv(text) {
|
|
29
|
+
const out = []
|
|
30
|
+
text.split(/\r?\n/).forEach((raw, i) => {
|
|
31
|
+
const line = raw.trim()
|
|
32
|
+
if (!line || line.startsWith('#')) return
|
|
33
|
+
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
|
|
34
|
+
if (!m) return
|
|
35
|
+
let value = m[2]
|
|
36
|
+
let ok = /#.*shipped-ok/.test(value)
|
|
37
|
+
if (/^"/.test(value)) value = (/^"((?:[^"\\]|\\.)*)"/.exec(value) || [, ''])[1].replace(/\\n/g, '\n')
|
|
38
|
+
else if (/^'/.test(value)) value = (/^'([^']*)'/.exec(value) || [, ''])[1]
|
|
39
|
+
else value = value.replace(/\s+#.*$/, '').trim()
|
|
40
|
+
out.push({ name: m[1], value, line: i + 1, ok })
|
|
41
|
+
})
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SKIP_FILES = /\.(example|sample|template|dist|defaults)$/i
|
|
46
|
+
|
|
47
|
+
/** Every env file in dir that holds real values. */
|
|
48
|
+
export function envFiles(dir) {
|
|
49
|
+
const names = existsSync(dir) ? readdirSync(dir) : []
|
|
50
|
+
return names.filter((n) => (n === '.dev.vars' || n === '.env' || n.startsWith('.env.')) && !SKIP_FILES.test(n)).map((n) => join(dir, n))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Too short, or a value that is fine to be public whatever its name.
|
|
54
|
+
function trivial(value) {
|
|
55
|
+
if (value.length < 12) return true
|
|
56
|
+
if (/^(true|false|null|undefined|production|development|test)$/i.test(value)) return true
|
|
57
|
+
if (/^\d+(\.\d+)?$/.test(value)) return true
|
|
58
|
+
// A URL is only a secret if it carries a password or a token in it.
|
|
59
|
+
if (/^https?:\/\/[^\s]+$/i.test(value) && !/:\/\/[^/@\s]+:[^/@\s]+@/.test(value) && !/[?&](key|token|secret|sig|signature|password)=/i.test(value)) return true
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The secrets to look for: { name, value, source, ok }.
|
|
65
|
+
* @param {string} dir the project folder
|
|
66
|
+
* @param {{ env?: Record<string, string | undefined>, files?: string[], allow?: string[] }} [opts]
|
|
67
|
+
*/
|
|
68
|
+
export function loadSecrets(dir, opts = {}) {
|
|
69
|
+
const allow = new Set(opts.allow ?? [])
|
|
70
|
+
const files = opts.files ?? envFiles(dir)
|
|
71
|
+
const all = []
|
|
72
|
+
for (const file of files) for (const v of parseDotenv(readFileSync(file, 'utf8'))) all.push({ ...v, source: file })
|
|
73
|
+
// CI: secrets usually arrive as environment variables, not files. Only take
|
|
74
|
+
// the ones whose names look secret, so PATH and friends are never searched for.
|
|
75
|
+
for (const [name, value] of Object.entries(opts.env ?? {})) {
|
|
76
|
+
if (value && looksSecretName(name) && !isPublicName(name)) all.push({ name, value, line: 0, ok: false, source: 'environment' })
|
|
77
|
+
}
|
|
78
|
+
// A value given to a public name is public on purpose, unless that public name
|
|
79
|
+
// itself sounds secret (VITE_STRIPE_SECRET): then it is the leak, not the proof.
|
|
80
|
+
const risky = (v) => isPublicName(v.name) && looksSecretName(stripPrefix(v.name))
|
|
81
|
+
const publicValues = new Set(all.filter((v) => isPublicName(v.name) && (v.ok || !risky(v))).map((v) => v.value))
|
|
82
|
+
const seen = new Set()
|
|
83
|
+
const secrets = []
|
|
84
|
+
for (const v of all) {
|
|
85
|
+
if (v.ok || allow.has(v.name)) continue
|
|
86
|
+
if (isPublicName(v.name) ? !risky(v) : looksSafeName(v.name)) continue
|
|
87
|
+
if (trivial(v.value) || publicValues.has(v.value) || seen.has(v.value)) continue
|
|
88
|
+
seen.add(v.value)
|
|
89
|
+
secrets.push(v)
|
|
90
|
+
}
|
|
91
|
+
return { secrets, all }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Public-prefixed names that look secret: { name, source, line }. */
|
|
95
|
+
export function riskyNames(all, allow = []) {
|
|
96
|
+
const allowed = new Set(allow)
|
|
97
|
+
return all.filter((v) => isPublicName(v.name) && !v.ok && !allowed.has(v.name) && looksSecretName(stripPrefix(v.name)))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Shows enough of a secret to recognise it, never the whole thing. */
|
|
101
|
+
export function mask(value) {
|
|
102
|
+
return value.length <= 8 ? '****' : `${value.slice(0, 4)}…${value.slice(-2)} (${value.length} chars)`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One .env line that holds a server secret: not public, not a known browser key, not trivial, not marked shipped-ok. */
|
|
106
|
+
export function isSecretLine(v) {
|
|
107
|
+
if (v.ok) return false
|
|
108
|
+
const risky = isPublicName(v.name) && looksSecretName(stripPrefix(v.name))
|
|
109
|
+
if (isPublicName(v.name) ? !risky : looksSafeName(v.name)) return false
|
|
110
|
+
return !trivial(v.value)
|
|
111
|
+
}
|
package/src/history.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Searches every commit in the repository: lines that were ever added, for known
|
|
2
|
+
// key shapes and for the project's current secret values, and every .env file
|
|
3
|
+
// that was ever committed with a server secret in it, even if it was deleted later. A secret that was
|
|
4
|
+
// committed once is in every clone of the repository, deleted or not.
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { createInterface } from 'node:readline'
|
|
8
|
+
import { findShapes, withKey } from './shapes.js'
|
|
9
|
+
import { isSecretLine, mask, parseDotenv } from './env.js'
|
|
10
|
+
import { forms, keyOf } from './scan.js'
|
|
11
|
+
|
|
12
|
+
const ENV_FILE = /(^|\/)(\.env(\.[^/]*)?|\.dev\.vars)$/
|
|
13
|
+
const SAFE_ENV = /\.(example|sample|template|dist|defaults)$/i
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} root a folder inside the repository
|
|
17
|
+
* @param {{ name: string, value: string }[]} secrets current values to look for
|
|
18
|
+
* @returns {Promise<{ findings: object[], commits: number } | null>} null when root is not a git repository
|
|
19
|
+
*/
|
|
20
|
+
export function scanHistory(root, secrets = []) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
// Every commit on every branch, oldest last, with the added lines of each diff.
|
|
23
|
+
const git = spawn('git', ['log', '--all', '-p', '--no-color', '--no-ext-diff', '--unified=0', '--format=@@commit %H %ad', '--date=short'], { cwd: root })
|
|
24
|
+
const findings = []
|
|
25
|
+
const reported = new Set()
|
|
26
|
+
let commits = 0
|
|
27
|
+
let commit = ''
|
|
28
|
+
let date = ''
|
|
29
|
+
let file = ''
|
|
30
|
+
let line = 0
|
|
31
|
+
let envFile = false
|
|
32
|
+
let failed = false
|
|
33
|
+
git.on('error', () => {
|
|
34
|
+
failed = true
|
|
35
|
+
})
|
|
36
|
+
git.stderr.on('data', (d) => {
|
|
37
|
+
if (/not a git repository/i.test(String(d))) failed = true
|
|
38
|
+
})
|
|
39
|
+
const add = (f) => {
|
|
40
|
+
const key = `${f.kind}|${f.sample}|${f.file}`
|
|
41
|
+
if (reported.has(key)) return // the same secret in the same file: report the newest commit only
|
|
42
|
+
reported.add(key)
|
|
43
|
+
findings.push(f)
|
|
44
|
+
}
|
|
45
|
+
createInterface({ input: git.stdout, crlfDelay: Infinity }).on('line', (text) => {
|
|
46
|
+
if (text.startsWith('@@commit ')) {
|
|
47
|
+
;[, commit, date] = text.split(' ')
|
|
48
|
+
commits++
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
if (text.startsWith('+++ b/')) {
|
|
52
|
+
file = text.slice(6)
|
|
53
|
+
envFile = ENV_FILE.test(file) && !SAFE_ENV.test(file)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(text)
|
|
57
|
+
if (hunk) {
|
|
58
|
+
line = Number(hunk[1])
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (!text.startsWith('+') || text.startsWith('+++')) return
|
|
62
|
+
const added = text.slice(1)
|
|
63
|
+
// A committed .env file counts only for the server secrets it held, not for VITE_EDITION=web.
|
|
64
|
+
if (envFile) {
|
|
65
|
+
for (const v of parseDotenv(added)) {
|
|
66
|
+
if (!isSecretLine(v)) continue
|
|
67
|
+
add(keyOf({ level: 'error', kind: 'history-env', file, line, commit: commit.slice(0, 8), date, what: `${v.name} was committed in ${file} (${commit.slice(0, 8)}, ${date}). Every clone has it, even if the file was deleted later.`, sample: mask(v.value) }, v.value))
|
|
68
|
+
}
|
|
69
|
+
line++
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
for (const s of secrets) {
|
|
73
|
+
for (const { text: form, how } of forms(s.value)) {
|
|
74
|
+
if (added.includes(form)) {
|
|
75
|
+
add(keyOf({ level: 'error', kind: 'history-value', file, line, commit: commit.slice(0, 8), date, what: `${s.name}${how ? ` (${how.slice(2)})` : ''} was committed in ${file} (${commit.slice(0, 8)}, ${date})`, sample: mask(s.value) }, s.value))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const f of findShapes(added)) {
|
|
80
|
+
if (secrets.some((s) => f.match.includes(s.value) || s.value.includes(f.match))) continue
|
|
81
|
+
add(withKey({ level: f.shape.level, kind: 'history-shape', file, line, commit: commit.slice(0, 8), date, what: `${f.shape.name} was committed in ${file} (${commit.slice(0, 8)}, ${date})`, sample: mask(f.match), shapeId: f.shape.id }, f.match))
|
|
82
|
+
}
|
|
83
|
+
line++
|
|
84
|
+
})
|
|
85
|
+
git.on('close', () => resolve(failed ? null : { findings, commits }))
|
|
86
|
+
})
|
|
87
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type Finding = {
|
|
2
|
+
level: 'error' | 'warn'
|
|
3
|
+
kind: 'value' | 'shape' | 'name' | 'sourcemap' | 'history-value' | 'history-shape' | 'history-env'
|
|
4
|
+
file: string
|
|
5
|
+
line: number
|
|
6
|
+
what: string
|
|
7
|
+
sample?: string
|
|
8
|
+
shapeId?: string
|
|
9
|
+
/** History findings: the short commit and its date. */
|
|
10
|
+
commit?: string
|
|
11
|
+
date?: string
|
|
12
|
+
/** Set by verifyFindings: did the provider accept the key? */
|
|
13
|
+
verified?: 'live' | 'dead' | 'unknown'
|
|
14
|
+
}
|
|
15
|
+
export type Result = { findings: Finding[]; files: number; dirs?: string[]; secretsChecked?: number; commits?: number; verified?: boolean }
|
|
16
|
+
export type Secret = { name: string; value: string; source: string; line: number; ok: boolean }
|
|
17
|
+
|
|
18
|
+
export declare function scanProject(root: string, opts?: { dirs?: string[]; allow?: string[]; env?: Record<string, string | undefined>; envFiles?: string[] }): Result
|
|
19
|
+
export declare function scanUrl(url: string, secrets?: Secret[]): Promise<Result>
|
|
20
|
+
export declare function scanHistory(root: string, secrets?: Secret[]): Promise<{ findings: Finding[]; commits: number } | null>
|
|
21
|
+
export declare function verifyKey(shapeId: string, key: string, fetch?: typeof globalThis.fetch): Promise<'live' | 'dead' | 'unknown'>
|
|
22
|
+
export declare function verifyFindings(findings: Finding[], fetch?: typeof globalThis.fetch): Promise<Finding[]>
|
|
23
|
+
export declare const VERIFIABLE: string[]
|
|
24
|
+
export declare function scanText(text: string, secrets: Secret[]): Omit<Finding, 'file'>[]
|
|
25
|
+
export declare function findOutputDirs(root: string): string[]
|
|
26
|
+
export declare function loadSecrets(dir: string, opts?: { env?: Record<string, string | undefined>; files?: string[]; allow?: string[] }): { secrets: Secret[]; all: Secret[] }
|
|
27
|
+
export declare function report(result: Result): string
|
|
28
|
+
export declare function mask(value: string): string
|
|
29
|
+
export declare const PUBLIC_PREFIXES: string[]
|
package/src/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// shipped-secrets: find secrets that ship to the browser, or sit in git history.
|
|
2
|
+
export { scanProject, scanText, findOutputDirs } from './scan.js'
|
|
3
|
+
export { scanUrl } from './url.js'
|
|
4
|
+
export { scanHistory } from './history.js'
|
|
5
|
+
export { verifyKey, verifyFindings, VERIFIABLE } from './verify.js'
|
|
6
|
+
export { loadSecrets, riskyNames, parseDotenv, envFiles, mask, PUBLIC_PREFIXES } from './env.js'
|
|
7
|
+
export { SHAPES, findShapes } from './shapes.js'
|
|
8
|
+
|
|
9
|
+
const ORDER = { error: 0, warn: 1 }
|
|
10
|
+
const VERDICT = { live: 'LIVE, rotate now', dead: 'dead', unknown: 'not checked' }
|
|
11
|
+
|
|
12
|
+
/** Plain-text report, worst first. Secrets are always masked. */
|
|
13
|
+
export function report(result) {
|
|
14
|
+
const lines = []
|
|
15
|
+
const live = (f) => (f.verified === 'live' ? 0 : 1)
|
|
16
|
+
const findings = [...result.findings].sort((a, b) => ORDER[a.level] - ORDER[b.level] || live(a) - live(b) || String(a.file).localeCompare(String(b.file)) || a.line - b.line)
|
|
17
|
+
// The same secret twice on one line is one line of output.
|
|
18
|
+
const counts = new Map()
|
|
19
|
+
for (const f of findings) {
|
|
20
|
+
const key = `${f.level}|${f.file}|${f.line}|${f.what}|${f.sample ?? ''}|${f.verified ?? ''}`
|
|
21
|
+
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
22
|
+
}
|
|
23
|
+
for (const [key, n] of counts) {
|
|
24
|
+
const [level, file, line, what, sample, verified] = key.split('|')
|
|
25
|
+
const where = line !== '0' ? `${file}:${line}` : file
|
|
26
|
+
lines.push(`${level === 'error' ? 'ERROR' : 'warn '} ${where} ${what}${sample ? ` [${sample}]` : ''}${verified ? ` (${VERDICT[verified]})` : ''}${n > 1 ? ` (${n} times)` : ''}`)
|
|
27
|
+
}
|
|
28
|
+
const errors = findings.filter((f) => f.level === 'error')
|
|
29
|
+
const history = errors.filter((f) => f.kind.startsWith('history'))
|
|
30
|
+
const shipped = errors.filter((f) => !f.kind.startsWith('history'))
|
|
31
|
+
const distinct = (list) => new Set(list.map((f) => `${f.what}|${f.sample}`)).size
|
|
32
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
33
|
+
const warns = findings.length - errors.length
|
|
34
|
+
lines.push('')
|
|
35
|
+
if (shipped.length) {
|
|
36
|
+
const n = distinct(shipped)
|
|
37
|
+
const places = new Set(shipped.map((f) => `${f.file}:${f.line}`)).size
|
|
38
|
+
lines.push(`${plural(n, 'secret')} shipped to the browser, in ${plural(places, 'place')}. Rotate ${n === 1 ? 'it' : 'them'}: deleting a key from the code does not take it back from anyone who already downloaded it.`)
|
|
39
|
+
} else if (result.dirs?.length || result.files) lines.push('No secrets found in what ships to the browser.')
|
|
40
|
+
if (history.length) {
|
|
41
|
+
lines.push(`${plural(new Set(history.map((f) => f.sample ?? f.file)).size, 'secret')} in git history. Rotate ${history.length === 1 ? 'it' : 'them'}: every clone of the repository has ${history.length === 1 ? 'it' : 'them'}, even after a delete.`)
|
|
42
|
+
} else if (result.commits != null) lines.push(`No secrets found in ${plural(result.commits, 'commit')} of git history.`)
|
|
43
|
+
const liveCount = new Set(errors.filter((f) => f.verified === 'live').map((f) => f.sample)).size
|
|
44
|
+
if (result.verified && errors.some((f) => f.verified)) lines.push(liveCount ? `${plural(liveCount, 'key')} still work${liveCount === 1 ? 's' : ''}: rotate ${liveCount === 1 ? 'it' : 'them'} first.` : 'None of the keys that could be checked still work.')
|
|
45
|
+
if (warns) lines.push(`${plural(warns, 'warning')} to check.`)
|
|
46
|
+
return lines.join('\n')
|
|
47
|
+
}
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Finds what a site ships to the browser and looks through it for secrets.
|
|
2
|
+
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
4
|
+
import { extname, join, relative, sep } from 'node:path'
|
|
5
|
+
import { findShapes, withKey } from './shapes.js'
|
|
6
|
+
import { loadSecrets, mask, riskyNames } from './env.js'
|
|
7
|
+
|
|
8
|
+
// Where each framework puts what the browser downloads, most specific first.
|
|
9
|
+
const OUTPUT_DIRS = [
|
|
10
|
+
'.next/static', // Next.js (.next/server is server code and is never scanned)
|
|
11
|
+
'out', // Next.js static export
|
|
12
|
+
'.output/public', // Nuxt, Nitro
|
|
13
|
+
'.svelte-kit/output/client', // SvelteKit
|
|
14
|
+
'.vercel/output/static', // Vercel build output
|
|
15
|
+
'dist/client', // Vite and Astro with a server build next to it
|
|
16
|
+
'dist',
|
|
17
|
+
'build/client', // React Router, Remix
|
|
18
|
+
'build',
|
|
19
|
+
'storybook-static',
|
|
20
|
+
]
|
|
21
|
+
// Server-side output inside an otherwise browser folder.
|
|
22
|
+
const SERVER_PARTS = new Set(['server', 'ssr', '.server'])
|
|
23
|
+
const TEXT = new Set(['.js', '.mjs', '.cjs', '.css', '.html', '.htm', '.map', '.json', '.txt', '.xml', '.webmanifest', '.svg'])
|
|
24
|
+
|
|
25
|
+
/** The folders of browser output found under the project folder. */
|
|
26
|
+
export function findOutputDirs(root) {
|
|
27
|
+
const found = []
|
|
28
|
+
for (const d of OUTPUT_DIRS) {
|
|
29
|
+
const p = join(root, d)
|
|
30
|
+
if (!existsSync(p) || !statSync(p).isDirectory()) continue
|
|
31
|
+
// dist and build are skipped when their client half was already taken.
|
|
32
|
+
if (found.some((f) => p.startsWith(f + sep) || f.startsWith(p + sep))) continue
|
|
33
|
+
found.push(p)
|
|
34
|
+
}
|
|
35
|
+
return found
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function walk(dir, out = [], exts = TEXT) {
|
|
39
|
+
for (const name of readdirSync(dir)) {
|
|
40
|
+
const p = join(dir, name)
|
|
41
|
+
const st = statSync(p)
|
|
42
|
+
if (st.isDirectory()) {
|
|
43
|
+
if (!SERVER_PARTS.has(name) && name !== 'node_modules') walk(p, out, exts)
|
|
44
|
+
} else if (exts.has(extname(name).toLowerCase()) && st.size < 20 * 1024 * 1024) out.push(p)
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Next.js keeps prerendered pages under .next/server, and they go straight to
|
|
50
|
+
// visitors: the HTML and the RSC payload. Only those are read there, never the
|
|
51
|
+
// server's own JavaScript.
|
|
52
|
+
const NEXT_PAGES = ['.next/server/app', '.next/server/pages']
|
|
53
|
+
const PAGE_FILES = new Set(['.html', '.rsc', '.body', '.meta'])
|
|
54
|
+
function nextPages(root) {
|
|
55
|
+
const out = []
|
|
56
|
+
for (const d of NEXT_PAGES) {
|
|
57
|
+
const p = join(root, d)
|
|
58
|
+
if (existsSync(p)) walk(p, out, PAGE_FILES)
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lineAt = (text, index) => text.slice(0, index).split('\n').length
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Scans text that a browser receives. Returns findings without the file name.
|
|
67
|
+
* @param {string} text
|
|
68
|
+
* @param {{ name: string, value: string, source: string }[]} secrets
|
|
69
|
+
*/
|
|
70
|
+
/** A secret value that has a known shape can be checked by --verify too. */
|
|
71
|
+
export function keyOf(finding, value) {
|
|
72
|
+
const shape = findShapes(value)[0]
|
|
73
|
+
return shape && shape.match === value ? withKey(Object.assign(finding, { shapeId: shape.shape.id }), value) : finding
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The value as written, and base64 of it, so a key put through btoa() or
|
|
78
|
+
* Buffer.toString('base64') on its way into the bundle is still found. The
|
|
79
|
+
* padding is dropped, so it matches with or without the trailing "=".
|
|
80
|
+
*/
|
|
81
|
+
export function forms(value) {
|
|
82
|
+
const b64 = Buffer.from(value, 'utf8').toString('base64').replace(/=+$/, '')
|
|
83
|
+
const url = b64.replace(/\+/g, '-').replace(/\//g, '_')
|
|
84
|
+
return [{ text: value, how: '' }, { text: b64, how: ', base64' }, ...(url !== b64 ? [{ text: url, how: ', base64url' }] : [])]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function scanText(text, secrets) {
|
|
88
|
+
const found = []
|
|
89
|
+
for (const s of secrets) {
|
|
90
|
+
for (const { text: form, how } of forms(s.value)) {
|
|
91
|
+
let at = text.indexOf(form)
|
|
92
|
+
while (at !== -1) {
|
|
93
|
+
found.push(keyOf({ level: 'error', kind: 'value', what: `${s.name} from ${s.source.split(/[\\/]/).pop()}${how}`, sample: mask(s.value), line: lineAt(text, at) }, s.value))
|
|
94
|
+
at = text.indexOf(form, at + form.length)
|
|
95
|
+
if (found.length > 50) break
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const f of findShapes(text)) {
|
|
100
|
+
// A value already reported by name is not reported twice as a shape.
|
|
101
|
+
if (secrets.some((s) => f.match.includes(s.value) || s.value.includes(f.match))) continue
|
|
102
|
+
found.push(withKey({ level: f.shape.level, kind: 'shape', what: f.shape.name, sample: mask(f.match), line: lineAt(text, f.index), shapeId: f.shape.id }, f.match))
|
|
103
|
+
}
|
|
104
|
+
return found
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Scans a project folder: its env files for risky names, and its browser output
|
|
109
|
+
* for real secret values and known key shapes.
|
|
110
|
+
* @param {string} root
|
|
111
|
+
* @param {{ dirs?: string[], allow?: string[], env?: Record<string, string | undefined>, envFiles?: string[] }} [opts]
|
|
112
|
+
*/
|
|
113
|
+
export function scanProject(root, opts = {}) {
|
|
114
|
+
const { secrets, all } = loadSecrets(root, { env: opts.env ?? process.env, files: opts.envFiles, allow: opts.allow })
|
|
115
|
+
const findings = []
|
|
116
|
+
for (const v of riskyNames(all, opts.allow)) {
|
|
117
|
+
findings.push({
|
|
118
|
+
level: 'warn', kind: 'name', file: relative(root, v.source) || v.source, line: v.line,
|
|
119
|
+
what: `${v.name} has a public prefix, so its value goes to every visitor. If it is a secret, drop the prefix and use it on the server only. If it is meant to be public, add "# shipped-ok" to the line.`,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
const dirs = (opts.dirs ?? findOutputDirs(root)).map((d) => (d.startsWith(root) ? d : join(root, d)))
|
|
123
|
+
let files = 0
|
|
124
|
+
let maps = 0
|
|
125
|
+
const lists = dirs.map((d) => walk(d))
|
|
126
|
+
if (!opts.dirs) lists.push(nextPages(root))
|
|
127
|
+
for (const list of lists) {
|
|
128
|
+
for (const file of list) {
|
|
129
|
+
files++
|
|
130
|
+
if (file.endsWith('.map')) maps++
|
|
131
|
+
const text = readFileSync(file, 'utf8')
|
|
132
|
+
for (const f of scanText(text, secrets)) findings.push(Object.assign(f, { file: relative(root, file) }))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (maps) {
|
|
136
|
+
findings.push({
|
|
137
|
+
level: 'warn', kind: 'sourcemap', file: dirs.map((d) => relative(root, d)).join(', '), line: 0,
|
|
138
|
+
what: `${maps} source map${maps === 1 ? '' : 's'} ship to the public. Anyone can read your original source code from them. Turn them off for production or keep them off the public server.`,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
return { findings, dirs: dirs.map((d) => relative(root, d) || '.'), files, secretsChecked: secrets.length }
|
|
142
|
+
}
|
package/src/shapes.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// What known secret keys look like. Publishable keys that are meant for browsers
|
|
2
|
+
// (Stripe pk_, Supabase anon and sb_publishable_) are never reported.
|
|
3
|
+
// level: error = a server secret, warn = often fine in a browser, check it.
|
|
4
|
+
|
|
5
|
+
const b64url = (s) => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
|
|
6
|
+
|
|
7
|
+
export const SHAPES = [
|
|
8
|
+
{ id: 'stripe-secret', name: 'Stripe secret or restricted key', level: 'error', re: /\b(?:sk|rk)_(?:live|test)_[0-9A-Za-z]{20,}\b/g },
|
|
9
|
+
{ id: 'supabase-secret', name: 'Supabase secret key', level: 'error', re: /\bsb_secret_[0-9A-Za-z_-]{20,}\b/g },
|
|
10
|
+
{
|
|
11
|
+
id: 'supabase-service-role', name: 'Supabase service_role key (bypasses row level security)', level: 'error',
|
|
12
|
+
re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
13
|
+
// A Supabase key is a JWT. Only the service_role one is a secret; the anon one is meant for browsers.
|
|
14
|
+
check(match) {
|
|
15
|
+
try {
|
|
16
|
+
const payload = JSON.parse(b64url(match.split('.')[1]))
|
|
17
|
+
return payload && payload.role === 'service_role'
|
|
18
|
+
} catch {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{ id: 'anthropic', name: 'Anthropic API key', level: 'error', re: /\bsk-ant-(?:api|admin)\d{2}-[0-9A-Za-z_-]{20,}\b/g },
|
|
24
|
+
{ id: 'openai', name: 'OpenAI API key', level: 'error', re: /\bsk-(?!ant-)(?:proj-|svcacct-|admin-)?[0-9A-Za-z_-]{32,}\b/g },
|
|
25
|
+
{ id: 'aws-access-key', name: 'AWS access key ID', level: 'error', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
26
|
+
{ id: 'github-token', name: 'GitHub token', level: 'error', re: /\b(?:gh[pousr]_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{60,})\b/g },
|
|
27
|
+
{ id: 'sendgrid', name: 'SendGrid API key', level: 'error', re: /\bSG\.[0-9A-Za-z_-]{22}\.[0-9A-Za-z_-]{43}\b/g },
|
|
28
|
+
{ id: 'mailgun', name: 'Mailgun API key', level: 'error', re: /\bkey-[0-9a-f]{32}\b/g },
|
|
29
|
+
{ id: 'resend', name: 'Resend API key', level: 'error', re: /\bre_[0-9A-Za-z]{8}_[0-9A-Za-z]{20,}\b/g },
|
|
30
|
+
{ id: 'twilio-api-key', name: 'Twilio API key', level: 'error', re: /\bSK[0-9a-f]{32}\b/g },
|
|
31
|
+
{ id: 'slack-token', name: 'Slack token', level: 'error', re: /\bxox[abprs]-[0-9A-Za-z-]{10,}\b/g },
|
|
32
|
+
{ id: 'private-key', name: 'Private key', level: 'error', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----/g },
|
|
33
|
+
{ id: 'database-url', name: 'Database URL with a password', level: 'error', re: /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|rediss):\/\/[^\s:'"`/@]+:[^\s'"`@]+@[^\s'"`]+/g },
|
|
34
|
+
// Google browser keys (Maps, Firebase) are usually meant to be public and locked by referrer.
|
|
35
|
+
{ id: 'google-api-key', name: 'Google API key (often public by design: check it is restricted)', level: 'warn', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/** Every known secret shape in text: [{ shape, match, index }]. */
|
|
39
|
+
export function findShapes(text) {
|
|
40
|
+
const out = []
|
|
41
|
+
for (const shape of SHAPES) {
|
|
42
|
+
shape.re.lastIndex = 0
|
|
43
|
+
let m
|
|
44
|
+
while ((m = shape.re.exec(text))) {
|
|
45
|
+
if (shape.check && !shape.check(m[0])) continue
|
|
46
|
+
out.push({ shape, match: m[0], index: m.index })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Keeps the full key on a finding for --verify, hidden from JSON and from printing. */
|
|
53
|
+
export function withKey(finding, key) {
|
|
54
|
+
return Object.defineProperty(finding, 'key', { value: key, enumerable: false })
|
|
55
|
+
}
|
package/src/url.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Scans a live site the way a stranger would: the page, the scripts it loads,
|
|
2
|
+
// the chunks those scripts load, and any source maps they point to.
|
|
3
|
+
|
|
4
|
+
import { scanText } from './scan.js'
|
|
5
|
+
|
|
6
|
+
const MAX_FILES = 200
|
|
7
|
+
|
|
8
|
+
async function get(url) {
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(url, { redirect: 'follow', headers: { 'user-agent': 'shipped-secrets' } })
|
|
11
|
+
if (!res.ok) return null
|
|
12
|
+
const type = res.headers.get('content-type') || ''
|
|
13
|
+
if (!/text|javascript|json|html|xml|svg/.test(type) && !/\.(m?js|map|json|css|html?)(\?|$)/.test(url)) return null
|
|
14
|
+
return await res.text()
|
|
15
|
+
} catch {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assetsIn(text, base, isHtml) {
|
|
21
|
+
const urls = new Set()
|
|
22
|
+
const add = (u) => {
|
|
23
|
+
try {
|
|
24
|
+
const abs = new URL(u, base)
|
|
25
|
+
if (abs.origin === new URL(base).origin) urls.add(abs.href.split('#')[0])
|
|
26
|
+
} catch {
|
|
27
|
+
/* not a URL */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (isHtml) {
|
|
31
|
+
for (const m of text.matchAll(/<script[^>]*\bsrc=["']([^"']+)["']/gi)) add(m[1])
|
|
32
|
+
for (const m of text.matchAll(/<link[^>]*\brel=["'](?:modulepreload|preload|stylesheet)["'][^>]*\bhref=["']([^"']+)["']/gi)) add(m[1])
|
|
33
|
+
for (const m of text.matchAll(/<link[^>]*\bhref=["']([^"']+)["'][^>]*\brel=["'](?:modulepreload|preload|stylesheet)["']/gi)) add(m[1])
|
|
34
|
+
} else {
|
|
35
|
+
// Chunks a bundle loads later, and its source map.
|
|
36
|
+
for (const m of text.matchAll(/["'`]((?:\.{0,2}\/)?[\w./-]+\.(?:m?js|css))["'`]/g)) add(m[1])
|
|
37
|
+
for (const m of text.matchAll(/\/[/*]# sourceMappingURL=([^\s*]+)/g)) if (!m[1].startsWith('data:')) add(m[1])
|
|
38
|
+
}
|
|
39
|
+
return urls
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Scans a deployed site by URL. Only files on the same site are fetched.
|
|
44
|
+
* @param {string} url
|
|
45
|
+
* @param {{ name: string, value: string, source: string }[]} [secrets] local values to look for too
|
|
46
|
+
*/
|
|
47
|
+
export async function scanUrl(url, secrets = []) {
|
|
48
|
+
const findings = []
|
|
49
|
+
const seen = new Set()
|
|
50
|
+
const queue = [url]
|
|
51
|
+
let maps = 0
|
|
52
|
+
while (queue.length && seen.size < MAX_FILES) {
|
|
53
|
+
const u = queue.shift()
|
|
54
|
+
if (seen.has(u)) continue
|
|
55
|
+
seen.add(u)
|
|
56
|
+
const text = await get(u)
|
|
57
|
+
if (text === null) continue
|
|
58
|
+
const isHtml = u === url || /<html|<!doctype/i.test(text.slice(0, 500))
|
|
59
|
+
if (/\.map(\?|$)/.test(u)) maps++
|
|
60
|
+
for (const f of scanText(text, secrets)) findings.push(Object.assign(f, { file: u }))
|
|
61
|
+
for (const next of assetsIn(text, u, isHtml)) if (!seen.has(next)) queue.push(next)
|
|
62
|
+
}
|
|
63
|
+
if (maps) {
|
|
64
|
+
findings.push({ level: 'warn', kind: 'sourcemap', file: url, line: 0, what: `${maps} source map${maps === 1 ? '' : 's'} can be downloaded from this site. Anyone can read your original source code from them.` })
|
|
65
|
+
}
|
|
66
|
+
return { findings, files: seen.size }
|
|
67
|
+
}
|
package/src/verify.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Checks whether a found key still works, by asking its own provider with a
|
|
2
|
+
// harmless read-only call, the way TruffleHog does. Opt-in (--verify): the key
|
|
3
|
+
// goes only to the provider that issued it, over HTTPS, and nowhere else.
|
|
4
|
+
//
|
|
5
|
+
// Answers: 'live' (the provider accepted it: rotate it now), 'dead' (refused:
|
|
6
|
+
// revoked or never real), 'unknown' (no safe way to check, or the provider did
|
|
7
|
+
// not answer).
|
|
8
|
+
|
|
9
|
+
const b64url = (s) => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
|
|
10
|
+
|
|
11
|
+
// shape id -> how to ask. Each returns the Response, or null when it cannot ask.
|
|
12
|
+
const CHECKS = {
|
|
13
|
+
'stripe-secret': (key, f) => f('https://api.stripe.com/v1/balance', { headers: { authorization: `Bearer ${key}` } }),
|
|
14
|
+
openai: (key, f) => f('https://api.openai.com/v1/models', { headers: { authorization: `Bearer ${key}` } }),
|
|
15
|
+
anthropic: (key, f) => f('https://api.anthropic.com/v1/models', { headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' } }),
|
|
16
|
+
'github-token': (key, f) => f('https://api.github.com/user', { headers: { authorization: `Bearer ${key}`, 'user-agent': 'shipped-secrets' } }),
|
|
17
|
+
sendgrid: (key, f) => f('https://api.sendgrid.com/v3/scopes', { headers: { authorization: `Bearer ${key}` } }),
|
|
18
|
+
resend: (key, f) => f('https://api.resend.com/domains', { headers: { authorization: `Bearer ${key}` } }),
|
|
19
|
+
mailgun: (key, f) => f('https://api.mailgun.net/v3/domains', { headers: { authorization: `Basic ${Buffer.from(`api:${key}`).toString('base64')}` } }),
|
|
20
|
+
'slack-token': (key, f) => f('https://slack.com/api/auth.test', { method: 'POST', headers: { authorization: `Bearer ${key}` } }),
|
|
21
|
+
// A Supabase key carries its project ref, so the project's own API can be asked.
|
|
22
|
+
'supabase-service-role': (key, f) => {
|
|
23
|
+
try {
|
|
24
|
+
const ref = JSON.parse(b64url(key.split('.')[1])).ref
|
|
25
|
+
if (!/^[a-z0-9]{8,40}$/.test(ref ?? '')) return null
|
|
26
|
+
return f(`https://${ref}.supabase.co/rest/v1/`, { headers: { apikey: key, authorization: `Bearer ${key}` } })
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Which shape ids can be verified. */
|
|
34
|
+
export const VERIFIABLE = Object.keys(CHECKS)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} shapeId
|
|
38
|
+
* @param {string} key
|
|
39
|
+
* @param {typeof fetch} [f]
|
|
40
|
+
* @returns {Promise<'live' | 'dead' | 'unknown'>}
|
|
41
|
+
*/
|
|
42
|
+
export async function verifyKey(shapeId, key, f = globalThis.fetch) {
|
|
43
|
+
const ask = CHECKS[shapeId]
|
|
44
|
+
if (!ask) return 'unknown'
|
|
45
|
+
try {
|
|
46
|
+
const res = await ask(key, (url, init) => f(url, { ...init, signal: AbortSignal.timeout(10_000) }))
|
|
47
|
+
if (!res) return 'unknown'
|
|
48
|
+
if (shapeId === 'slack-token') return (await res.json()).ok ? 'live' : 'dead'
|
|
49
|
+
if (res.status === 200) return 'live'
|
|
50
|
+
if (res.status === 401 || res.status === 403) return 'dead'
|
|
51
|
+
return 'unknown'
|
|
52
|
+
} catch {
|
|
53
|
+
return 'unknown'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Adds a verdict to every shape finding that carries its full key.
|
|
59
|
+
* @param {{ shapeId?: string, key?: string, what: string }[]} findings
|
|
60
|
+
*/
|
|
61
|
+
export async function verifyFindings(findings, f = globalThis.fetch) {
|
|
62
|
+
const asked = new Map()
|
|
63
|
+
for (const finding of findings) {
|
|
64
|
+
if (!finding.shapeId || !finding.key) continue
|
|
65
|
+
const id = `${finding.shapeId}|${finding.key}`
|
|
66
|
+
if (!asked.has(id)) asked.set(id, verifyKey(finding.shapeId, finding.key, f))
|
|
67
|
+
finding.verified = await asked.get(id)
|
|
68
|
+
}
|
|
69
|
+
return findings
|
|
70
|
+
}
|
package/src/vite.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function shippedSecrets(opts?: { allow?: string[]; failOnWarn?: boolean }): { name: string; apply: 'build' }
|
package/src/vite.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Vite plugin: after every production build, scan what was built and fail the
|
|
2
|
+
// build if a secret is in it. Warnings are printed and do not fail the build.
|
|
3
|
+
//
|
|
4
|
+
// import shippedSecrets from 'shipped-secrets/vite'
|
|
5
|
+
// export default { plugins: [shippedSecrets()] }
|
|
6
|
+
|
|
7
|
+
import { report, scanProject } from './index.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {{ allow?: string[], failOnWarn?: boolean }} [opts]
|
|
11
|
+
*/
|
|
12
|
+
export default function shippedSecrets(opts = {}) {
|
|
13
|
+
let root = process.cwd()
|
|
14
|
+
let outDir = 'dist'
|
|
15
|
+
let isBuild = false
|
|
16
|
+
return {
|
|
17
|
+
name: 'shipped-secrets',
|
|
18
|
+
apply: 'build',
|
|
19
|
+
configResolved(config) {
|
|
20
|
+
root = config.root
|
|
21
|
+
outDir = config.build.outDir
|
|
22
|
+
isBuild = config.command === 'build' && !config.build.ssr
|
|
23
|
+
},
|
|
24
|
+
closeBundle() {
|
|
25
|
+
if (!isBuild) return
|
|
26
|
+
const result = scanProject(root, { dirs: [outDir], allow: opts.allow })
|
|
27
|
+
if (!result.findings.length) return
|
|
28
|
+
const text = report(result)
|
|
29
|
+
const fail = result.findings.some((f) => f.level === 'error') || (opts.failOnWarn && result.findings.length)
|
|
30
|
+
if (fail) throw new Error(`shipped-secrets stopped the build:\n${text}`)
|
|
31
|
+
console.warn(`shipped-secrets:\n${text}`)
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|