shipped-secrets 1.0.0 → 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/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 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
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.
@@ -115,6 +115,7 @@ Checked: Stripe (`/v1/balance`), OpenAI and Anthropic (list models), GitHub (`/u
115
115
 
116
116
  ## What it does not do
117
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.
118
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.
119
120
  - `--verify` does not check AWS, Twilio, private keys or database URLs: checking those safely needs more than one harmless call.
120
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shipped-secrets",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
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/scan.js CHANGED
@@ -73,14 +73,27 @@ 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
+ /**
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
+
76
87
  export function scanText(text, secrets) {
77
88
  const found = []
78
89
  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
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
+ }
84
97
  }
85
98
  }
86
99
  for (const f of findShapes(text)) {