shipped-secrets 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -22,7 +22,7 @@ Secrets are always masked in the output. Nothing is sent anywhere unless you ask
22
22
 
23
23
  ## What it checks
24
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.
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 the common ways it gets encoded on its way into a bundle: base64 (standard and URL-safe, with or without padding), hex, URL-encoded, and with `/` escaped as `\/` or `/` the way some JSON and RSC payloads write it. Each of these is an exact transform of your real value, so none of them can raise a false alarm.
26
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
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
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.
@@ -113,8 +113,18 @@ Checked: Stripe (`/v1/balance`), OpenAI and Anthropic (list models), GitHub (`/u
113
113
 
114
114
  `--all` is `--history` and `--verify` together.
115
115
 
116
+ ## A secret split into pieces
117
+
118
+ ```sh
119
+ npx shipped-secrets --deep
120
+ ```
121
+
122
+ A plain split like `"sk_live_" + "..."` is usually caught already, because minifiers join string pieces back together. `--deep` is for a split the minifier cannot undo, such as pieces in an array joined when the code runs. It cuts each `.env` value into pieces of 10 characters, keeps only the ones that look random (a digit, a letter, eight different characters, so never a common start like `sk_live_` or `https://`), and reports a file that holds two or more of them but never the whole value. It is off by default to keep the default run free of false alarms. The Vite plugin takes it too: `shippedSecrets({ deep: true })`.
123
+
116
124
  ## What it does not do
117
125
 
126
+ - It does not find an encrypted secret, or one encoded in a way not listed above. If the browser can decrypt it, so can anyone, so it belongs on the server anyway.
127
+ - `--deep` does not find a secret cut into pieces shorter than 10 characters, and it is not used on git history.
118
128
  - 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.
119
129
  - `--verify` does not check AWS, Twilio, private keys or database URLs: checking those safely needs more than one harmless call.
120
130
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // shipped-secrets [folder or URL] [--dir <path>] [--allow NAME] [--history] [--verify] [--all] [--strict] [--json]
2
+ // shipped-secrets [folder or URL] [--dir <path>] [--allow NAME] [--history] [--verify] [--deep] [--all] [--strict] [--json]
3
3
  //
4
4
  // With no argument: finds the browser output in this project (dist, .next/static,
5
5
  // build, out, ...), reads this project's .env files, and looks for your real
@@ -25,6 +25,7 @@ names that look secret, and source maps left public.
25
25
 
26
26
  --history also search every commit in git history, and flag any .env file ever committed
27
27
  --verify ask each found key's own provider whether it still works (sends the key to that provider only)
28
+ --deep also look for a secret split into pieces (two or more long random pieces of it in one file)
28
29
  --all --history and --verify
29
30
  --dir <path> scan this folder instead of finding the build output (repeatable)
30
31
  --allow NAME a variable that is fine to ship (repeatable). Or add "# shipped-ok" to its .env line
@@ -37,14 +38,15 @@ const target = positional[0] ?? '.'
37
38
  const allow = values('--allow')
38
39
  const history = flag('--history') || flag('--all')
39
40
  const verify = flag('--verify') || flag('--all')
41
+ const deep = flag('--deep')
40
42
  let result
41
43
  if (/^https?:\/\//.test(target)) {
42
44
  const { secrets } = loadSecrets(process.cwd(), { env: process.env, allow })
43
- result = await scanUrl(target, secrets)
45
+ result = await scanUrl(target, secrets, { deep })
44
46
  } else {
45
47
  const root = resolve(target)
46
48
  const dirs = values('--dir')
47
- result = scanProject(root, { dirs: dirs.length ? dirs.map((d) => resolve(root, d)) : undefined, allow })
49
+ result = scanProject(root, { dirs: dirs.length ? dirs.map((d) => resolve(root, d)) : undefined, allow, deep })
48
50
  if (!dirs.length && !result.dirs.length && !history) {
49
51
  console.error('No build output found (dist, build, out, .next/static, ...). Build first, pass --dir <path>, or use --history.')
50
52
  process.exit(2)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shipped-secrets",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
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
5
  "type": "module",
6
6
  "exports": {
package/src/history.js CHANGED
@@ -7,7 +7,7 @@ import { spawn } from 'node:child_process'
7
7
  import { createInterface } from 'node:readline'
8
8
  import { findShapes, withKey } from './shapes.js'
9
9
  import { isSecretLine, mask, parseDotenv } from './env.js'
10
- import { keyOf } from './scan.js'
10
+ import { forms, keyOf } from './scan.js'
11
11
 
12
12
  const ENV_FILE = /(^|\/)(\.env(\.[^/]*)?|\.dev\.vars)$/
13
13
  const SAFE_ENV = /\.(example|sample|template|dist|defaults)$/i
@@ -70,8 +70,10 @@ export function scanHistory(root, secrets = []) {
70
70
  return
71
71
  }
72
72
  for (const s of secrets) {
73
- if (added.includes(s.value)) {
74
- add(keyOf({ level: 'error', kind: 'history-value', file, line, commit: commit.slice(0, 8), date, what: `${s.name} was committed in ${file} (${commit.slice(0, 8)}, ${date})`, sample: mask(s.value) }, s.value))
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
+ }
75
77
  }
76
78
  }
77
79
  for (const f of findShapes(added)) {
package/src/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type Finding = {
2
2
  level: 'error' | 'warn'
3
- kind: 'value' | 'shape' | 'name' | 'sourcemap' | 'history-value' | 'history-shape' | 'history-env'
3
+ kind: 'value' | 'pieces' | 'shape' | 'name' | 'sourcemap' | 'history-value' | 'history-shape' | 'history-env'
4
4
  file: string
5
5
  line: number
6
6
  what: string
@@ -15,13 +15,13 @@ export type Finding = {
15
15
  export type Result = { findings: Finding[]; files: number; dirs?: string[]; secretsChecked?: number; commits?: number; verified?: boolean }
16
16
  export type Secret = { name: string; value: string; source: string; line: number; ok: boolean }
17
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>
18
+ export declare function scanProject(root: string, opts?: { dirs?: string[]; allow?: string[]; env?: Record<string, string | undefined>; envFiles?: string[]; deep?: boolean }): Result
19
+ export declare function scanUrl(url: string, secrets?: Secret[], opts?: { deep?: boolean }): Promise<Result>
20
20
  export declare function scanHistory(root: string, secrets?: Secret[]): Promise<{ findings: Finding[]; commits: number } | null>
21
21
  export declare function verifyKey(shapeId: string, key: string, fetch?: typeof globalThis.fetch): Promise<'live' | 'dead' | 'unknown'>
22
22
  export declare function verifyFindings(findings: Finding[], fetch?: typeof globalThis.fetch): Promise<Finding[]>
23
23
  export declare const VERIFIABLE: string[]
24
- export declare function scanText(text: string, secrets: Secret[]): Omit<Finding, 'file'>[]
24
+ export declare function scanText(text: string, secrets: Secret[], opts?: { deep?: boolean }): Omit<Finding, 'file'>[]
25
25
  export declare function findOutputDirs(root: string): string[]
26
26
  export declare function loadSecrets(dir: string, opts?: { env?: Record<string, string | undefined>; files?: string[]; allow?: string[] }): { secrets: Secret[]; all: Secret[] }
27
27
  export declare function report(result: Result): string
package/src/scan.js CHANGED
@@ -73,14 +73,75 @@ export function keyOf(finding, value) {
73
73
  return shape && shape.match === value ? withKey(Object.assign(finding, { shapeId: shape.shape.id }), value) : finding
74
74
  }
75
75
 
76
- export function scanText(text, secrets) {
76
+ /**
77
+ * The value as written and the common ways it gets encoded on its way into a
78
+ * bundle: base64 (padding dropped, so it matches with or without the "="), hex,
79
+ * URL-encoded, and with "/" escaped the way some JSON and RSC payloads write it.
80
+ * Each is an exact transform of the real value, so none of them can raise a
81
+ * false alarm.
82
+ */
83
+ export function forms(value) {
84
+ const out = [{ text: value, how: '' }]
85
+ const add = (text, how) => { if (text !== value && !out.some((f) => f.text === text)) out.push({ text, how }) }
86
+ const buf = Buffer.from(value, 'utf8')
87
+ const b64 = buf.toString('base64').replace(/=+$/, '')
88
+ add(b64, ', base64')
89
+ add(b64.replace(/\+/g, '-').replace(/\//g, '_'), ', base64url')
90
+ add(buf.toString('hex'), ', hex')
91
+ add(buf.toString('hex').toUpperCase(), ', hex')
92
+ add(encodeURIComponent(value), ', URL-encoded')
93
+ if (value.includes('/')) {
94
+ add(value.replace(/\//g, '\\/'), ', escaped')
95
+ add(value.replace(/\//g, '\\u002f'), ', escaped')
96
+ }
97
+ return out
98
+ }
99
+
100
+ const PIECE = 10
101
+
102
+ /**
103
+ * For --deep: pieces of the value, to find a secret split up so that the whole
104
+ * of it never appears. Only from values long enough to have a random part, and
105
+ * only pieces that look random (a digit, a letter, eight different characters),
106
+ * so a common start like "sk_live_" or "https://" is never one of them.
107
+ */
108
+ export function pieces(value) {
109
+ if (value.length < 24) return []
110
+ const out = []
111
+ for (let i = 0; i + PIECE <= value.length; i += PIECE) {
112
+ const p = value.slice(i, i + PIECE)
113
+ if (/\d/.test(p) && /[A-Za-z]/.test(p) && new Set(p).size >= 8) out.push(p)
114
+ }
115
+ return out
116
+ }
117
+
118
+ /**
119
+ * @param {string} text
120
+ * @param {{ name: string, value: string, source: string }[]} secrets
121
+ * @param {{ deep?: boolean }} [opts]
122
+ */
123
+ export function scanText(text, secrets, opts = {}) {
77
124
  const found = []
78
125
  for (const s of secrets) {
79
- let at = text.indexOf(s.value)
80
- while (at !== -1) {
81
- found.push(keyOf({ level: 'error', kind: 'value', what: `${s.name} from ${s.source.split(/[\\/]/).pop()}`, sample: mask(s.value), line: lineAt(text, at) }, s.value))
82
- at = text.indexOf(s.value, at + s.value.length)
83
- if (found.length > 50) break
126
+ const from = s.source.split(/[\\/]/).pop()
127
+ let whole = false
128
+ for (const { text: form, how } of forms(s.value)) {
129
+ let at = text.indexOf(form)
130
+ while (at !== -1) {
131
+ whole = true
132
+ found.push(keyOf({ level: 'error', kind: 'value', what: `${s.name} from ${from}${how}`, sample: mask(s.value), line: lineAt(text, at) }, s.value))
133
+ at = text.indexOf(form, at + form.length)
134
+ if (found.length > 50) break
135
+ }
136
+ }
137
+ // Two or more random pieces of one secret in the same file, and never the
138
+ // whole of it: split on purpose. A single piece is left alone.
139
+ if (opts.deep && !whole) {
140
+ const all = pieces(s.value)
141
+ const hits = all.map((p) => text.indexOf(p)).filter((at) => at !== -1)
142
+ if (hits.length >= 2) {
143
+ found.push(keyOf({ level: 'error', kind: 'pieces', what: `${s.name} from ${from}, in pieces (${hits.length} of ${all.length} found)`, sample: mask(s.value), line: lineAt(text, Math.min(...hits)) }, s.value))
144
+ }
84
145
  }
85
146
  }
86
147
  for (const f of findShapes(text)) {
@@ -95,7 +156,7 @@ export function scanText(text, secrets) {
95
156
  * Scans a project folder: its env files for risky names, and its browser output
96
157
  * for real secret values and known key shapes.
97
158
  * @param {string} root
98
- * @param {{ dirs?: string[], allow?: string[], env?: Record<string, string | undefined>, envFiles?: string[] }} [opts]
159
+ * @param {{ dirs?: string[], allow?: string[], env?: Record<string, string | undefined>, envFiles?: string[], deep?: boolean }} [opts]
99
160
  */
100
161
  export function scanProject(root, opts = {}) {
101
162
  const { secrets, all } = loadSecrets(root, { env: opts.env ?? process.env, files: opts.envFiles, allow: opts.allow })
@@ -116,7 +177,7 @@ export function scanProject(root, opts = {}) {
116
177
  files++
117
178
  if (file.endsWith('.map')) maps++
118
179
  const text = readFileSync(file, 'utf8')
119
- for (const f of scanText(text, secrets)) findings.push(Object.assign(f, { file: relative(root, file) }))
180
+ for (const f of scanText(text, secrets, { deep: opts.deep })) findings.push(Object.assign(f, { file: relative(root, file) }))
120
181
  }
121
182
  }
122
183
  if (maps) {
package/src/url.js CHANGED
@@ -1,67 +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
- }
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 = [], opts = {}) {
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, { deep: opts.deep })) 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/vite.d.ts CHANGED
@@ -1 +1 @@
1
- export default function shippedSecrets(opts?: { allow?: string[]; failOnWarn?: boolean }): { name: string; apply: 'build' }
1
+ export default function shippedSecrets(opts?: { allow?: string[]; failOnWarn?: boolean; deep?: boolean }): { name: string; apply: 'build' }
package/src/vite.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import { report, scanProject } from './index.js'
8
8
 
9
9
  /**
10
- * @param {{ allow?: string[], failOnWarn?: boolean }} [opts]
10
+ * @param {{ allow?: string[], failOnWarn?: boolean, deep?: boolean }} [opts]
11
11
  */
12
12
  export default function shippedSecrets(opts = {}) {
13
13
  let root = process.cwd()
@@ -23,7 +23,7 @@ export default function shippedSecrets(opts = {}) {
23
23
  },
24
24
  closeBundle() {
25
25
  if (!isBuild) return
26
- const result = scanProject(root, { dirs: [outDir], allow: opts.allow })
26
+ const result = scanProject(root, { dirs: [outDir], allow: opts.allow, deep: opts.deep })
27
27
  if (!result.findings.length) return
28
28
  const text = report(result)
29
29
  const fail = result.findings.some((f) => f.level === 'error') || (opts.failOnWarn && result.findings.length)