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 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
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }