cleartoship 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/action.yml +154 -0
  4. package/dist/banner.d.ts +1 -0
  5. package/dist/banner.js +14 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +103 -0
  8. package/dist/data/popular.d.ts +17 -0
  9. package/dist/data/popular.js +103 -0
  10. package/dist/index.d.ts +5 -0
  11. package/dist/index.js +4 -0
  12. package/dist/report.d.ts +15 -0
  13. package/dist/report.js +317 -0
  14. package/dist/scan.d.ts +24 -0
  15. package/dist/scan.js +81 -0
  16. package/dist/scanners/community.d.ts +2 -0
  17. package/dist/scanners/community.js +155 -0
  18. package/dist/scanners/dependencies.d.ts +21 -0
  19. package/dist/scanners/dependencies.js +549 -0
  20. package/dist/scanners/index.d.ts +8 -0
  21. package/dist/scanners/index.js +13 -0
  22. package/dist/scanners/rls.d.ts +7 -0
  23. package/dist/scanners/rls.js +531 -0
  24. package/dist/scanners/secrets.d.ts +2 -0
  25. package/dist/scanners/secrets.js +439 -0
  26. package/dist/scanners/server-actions.d.ts +2 -0
  27. package/dist/scanners/server-actions.js +466 -0
  28. package/dist/types.d.ts +65 -0
  29. package/dist/types.js +10 -0
  30. package/dist/utils/ast.d.ts +13 -0
  31. package/dist/utils/ast.js +107 -0
  32. package/dist/utils/detect.d.ts +2 -0
  33. package/dist/utils/detect.js +77 -0
  34. package/dist/utils/entropy.d.ts +7 -0
  35. package/dist/utils/entropy.js +19 -0
  36. package/dist/utils/files.d.ts +13 -0
  37. package/dist/utils/files.js +154 -0
  38. package/dist/utils/osv.d.ts +26 -0
  39. package/dist/utils/osv.js +123 -0
  40. package/dist/utils/registry.d.ts +44 -0
  41. package/dist/utils/registry.js +195 -0
  42. package/dist/utils/sql.d.ts +20 -0
  43. package/dist/utils/sql.js +157 -0
  44. package/dist/utils/suppress.d.ts +19 -0
  45. package/dist/utils/suppress.js +56 -0
  46. package/dist/utils/traverse.d.ts +2 -0
  47. package/dist/utils/traverse.js +14 -0
  48. package/dist/vendor/gitleaks/rules.d.ts +13 -0
  49. package/dist/vendor/gitleaks/rules.js +1571 -0
  50. package/dist/vendor/guardvibe/index.d.ts +11 -0
  51. package/dist/vendor/guardvibe/index.js +60 -0
  52. package/dist/vendor/guardvibe/rules/advanced-security.d.ts +2 -0
  53. package/dist/vendor/guardvibe/rules/advanced-security.js +418 -0
  54. package/dist/vendor/guardvibe/rules/ai-host-security.d.ts +2 -0
  55. package/dist/vendor/guardvibe/rules/ai-host-security.js +190 -0
  56. package/dist/vendor/guardvibe/rules/ai-security.d.ts +2 -0
  57. package/dist/vendor/guardvibe/rules/ai-security.js +413 -0
  58. package/dist/vendor/guardvibe/rules/ai-tool-runtime.d.ts +2 -0
  59. package/dist/vendor/guardvibe/rules/ai-tool-runtime.js +194 -0
  60. package/dist/vendor/guardvibe/rules/api-security.d.ts +2 -0
  61. package/dist/vendor/guardvibe/rules/api-security.js +154 -0
  62. package/dist/vendor/guardvibe/rules/auth.d.ts +2 -0
  63. package/dist/vendor/guardvibe/rules/auth.js +222 -0
  64. package/dist/vendor/guardvibe/rules/cicd.d.ts +2 -0
  65. package/dist/vendor/guardvibe/rules/cicd.js +108 -0
  66. package/dist/vendor/guardvibe/rules/core.d.ts +2 -0
  67. package/dist/vendor/guardvibe/rules/core.js +484 -0
  68. package/dist/vendor/guardvibe/rules/cve-versions.d.ts +2 -0
  69. package/dist/vendor/guardvibe/rules/cve-versions.js +1130 -0
  70. package/dist/vendor/guardvibe/rules/database.d.ts +2 -0
  71. package/dist/vendor/guardvibe/rules/database.js +173 -0
  72. package/dist/vendor/guardvibe/rules/deployment.d.ts +2 -0
  73. package/dist/vendor/guardvibe/rules/deployment.js +269 -0
  74. package/dist/vendor/guardvibe/rules/dockerfile.d.ts +2 -0
  75. package/dist/vendor/guardvibe/rules/dockerfile.js +99 -0
  76. package/dist/vendor/guardvibe/rules/firebase.d.ts +2 -0
  77. package/dist/vendor/guardvibe/rules/firebase.js +96 -0
  78. package/dist/vendor/guardvibe/rules/go.d.ts +2 -0
  79. package/dist/vendor/guardvibe/rules/go.js +85 -0
  80. package/dist/vendor/guardvibe/rules/modern-stack.d.ts +2 -0
  81. package/dist/vendor/guardvibe/rules/modern-stack.js +631 -0
  82. package/dist/vendor/guardvibe/rules/nextjs.d.ts +2 -0
  83. package/dist/vendor/guardvibe/rules/nextjs.js +233 -0
  84. package/dist/vendor/guardvibe/rules/other-services.d.ts +2 -0
  85. package/dist/vendor/guardvibe/rules/other-services.js +72 -0
  86. package/dist/vendor/guardvibe/rules/payments.d.ts +2 -0
  87. package/dist/vendor/guardvibe/rules/payments.js +123 -0
  88. package/dist/vendor/guardvibe/rules/react-native.d.ts +2 -0
  89. package/dist/vendor/guardvibe/rules/react-native.js +132 -0
  90. package/dist/vendor/guardvibe/rules/services.d.ts +2 -0
  91. package/dist/vendor/guardvibe/rules/services.js +149 -0
  92. package/dist/vendor/guardvibe/rules/shell.d.ts +2 -0
  93. package/dist/vendor/guardvibe/rules/shell.js +72 -0
  94. package/dist/vendor/guardvibe/rules/sql.d.ts +2 -0
  95. package/dist/vendor/guardvibe/rules/sql.js +60 -0
  96. package/dist/vendor/guardvibe/rules/supply-chain.d.ts +2 -0
  97. package/dist/vendor/guardvibe/rules/supply-chain.js +243 -0
  98. package/dist/vendor/guardvibe/rules/terraform.d.ts +2 -0
  99. package/dist/vendor/guardvibe/rules/terraform.js +84 -0
  100. package/dist/vendor/guardvibe/rules/types.d.ts +14 -0
  101. package/dist/vendor/guardvibe/rules/types.js +11 -0
  102. package/dist/vendor/guardvibe/rules/web-security.d.ts +2 -0
  103. package/dist/vendor/guardvibe/rules/web-security.js +258 -0
  104. package/examples/security.yml +23 -0
  105. package/package.json +70 -0
@@ -0,0 +1,439 @@
1
+ import { basename } from 'node:path';
2
+ import { read, rel, lineAt, snippetAt, exists, isScript } from '../utils/files.js';
3
+ import { Suppressions } from '../utils/suppress.js';
4
+ import { emptyResult } from '../types.js';
5
+ import { join } from 'node:path';
6
+ import { shannonEntropy } from '../utils/entropy.js';
7
+ import { GITLEAKS_RULES, GITLEAKS_STOPWORDS, GITLEAKS_ATTRIBUTION, } from '../vendor/gitleaks/rules.js';
8
+ /** Decodes a JWT payload without verifying it — we only want the claims. */
9
+ function jwtPayload(token) {
10
+ const parts = token.split('.');
11
+ if (parts.length !== 3)
12
+ return null;
13
+ try {
14
+ const json = Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
15
+ return JSON.parse(json);
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ const PATTERNS = [
22
+ {
23
+ id: 'supabase-service-role',
24
+ label: 'Supabase service-role key',
25
+ re: /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
26
+ severity: 'critical',
27
+ confirm: (m) => jwtPayload(m)?.role === 'service_role',
28
+ note: 'This key bypasses every Row Level Security policy on the project.',
29
+ },
30
+ {
31
+ id: 'supabase-anon',
32
+ label: 'Supabase anon key',
33
+ re: /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
34
+ severity: 'info',
35
+ confirm: (m) => jwtPayload(m)?.role === 'anon',
36
+ note: 'The anon key is designed to be public; it is only as safe as your RLS policies.',
37
+ },
38
+ { id: 'supabase-secret', label: 'Supabase secret key', re: /\bsb_secret_[A-Za-z0-9_-]{16,}/g, severity: 'critical' },
39
+ { id: 'openai', label: 'OpenAI API key', re: /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{24,}/g, severity: 'critical' },
40
+ { id: 'anthropic', label: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{24,}/g, severity: 'critical' },
41
+ { id: 'stripe-live', label: 'Stripe live secret key', re: /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}/g, severity: 'critical' },
42
+ { id: 'stripe-test', label: 'Stripe test secret key', re: /\b(?:sk|rk)_test_[A-Za-z0-9]{16,}/g, severity: 'medium' },
43
+ { id: 'aws', label: 'AWS access key id', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, severity: 'critical' },
44
+ { id: 'github-pat', label: 'GitHub personal access token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{60,}/g, severity: 'critical' },
45
+ { id: 'google', label: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g, severity: 'high' },
46
+ { id: 'slack', label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g, severity: 'critical' },
47
+ { id: 'resend', label: 'Resend API key', re: /\bre_[A-Za-z0-9]{8,}_[A-Za-z0-9]{16,}/g, severity: 'high' },
48
+ { id: 'sendgrid', label: 'SendGrid API key', re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g, severity: 'critical' },
49
+ { id: 'private-key', label: 'Private key block', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g, severity: 'critical' },
50
+ { id: 'postgres-url', label: 'PostgreSQL connection string with password', re: /\bpostgres(?:ql)?:\/\/[^\s:'"$]+:[^\s@'"$]{6,}@[^\s/'"]+/g, severity: 'critical' },
51
+ ];
52
+ /**
53
+ * Values that are obviously stand-ins rather than live credentials. Docs,
54
+ * READMEs and rule fixtures are full of these and reporting them is pure noise.
55
+ */
56
+ const PLACEHOLDER = /(your[_-]?|example|placeholder|sample|template|<[a-z_ -]+>|\.\.\.|\u2026|changeme|change_me|dummy|redacted|notreal|fake|mock|s3cret|abc123|deadbeef|1234567|xxx|yyy|zzz|\bfoo\b|\bbar\b)/i;
57
+ /** The same character five or more times running, as in a filler-value key. */
58
+ const REPEATED_RUN = /(.)\1{4,}/;
59
+ /**
60
+ * Vendored gitleaks rules withheld as noisy in this tool's context, measured
61
+ * across the fixtures and the reference corpus. Each carries its reason.
62
+ */
63
+ const GITLEAKS_WITHHELD = new Map([
64
+ [
65
+ 'generic-api-key',
66
+ "gitleaks' own catch-all for unknown providers. It matches any `key`-ish identifier next " +
67
+ "to a quoted string, so it fires on ordinary arrays of method names ('auth.getUser', " +
68
+ "'auth.getClaims') and on SQL column lists — 97 of 118 hits across the reference corpus " +
69
+ 'were false. The providers that matter are covered precisely by PATTERNS above.',
70
+ ],
71
+ [
72
+ 'sourcegraph-access-token',
73
+ 'matches any 40-character hex string, so every SHA-pinned GitHub Action ' +
74
+ '(`uses: actions/checkout@<sha>`) is reported as a leaked token',
75
+ ],
76
+ ]);
77
+ /** Git object ids and integrity digests are hashes, not credentials. */
78
+ const HEX_DIGEST = /^[a-f0-9]{40}$|^[a-f0-9]{64}$/i;
79
+ /**
80
+ * Paths where a credential-shaped string is a fixture, not a deployed secret.
81
+ * Findings here are reported at `low` so they are visible but never blocking.
82
+ */
83
+ const NON_PRODUCTION_PATH = /(^|\/)(tests?|__tests__|__mocks__|__fixtures__|fixtures?|spec|specs|examples?|docs?|demo|samples?|e2e|cypress|playwright|stories)(\/|$)|\.(test|spec|stories|fixture)\.[a-z]+$|(^|\/)(README|CHANGELOG|CONTRIBUTING)/i;
84
+ /** Env var names that are meant to be public even though they read like secrets. */
85
+ const PUBLIC_BY_DESIGN = /(ANON_KEY|PUBLISHABLE_KEY|PUBLIC_KEY|CLIENT_ID|MEASUREMENT_ID|PROJECT_ID|APP_ID|SENDER_ID|FIREBASE_API_KEY|MAPBOX_TOKEN|POSTHOG_KEY|SENTRY_DSN)$/;
86
+ const SECRETY_NAME = /(SECRET|SERVICE_ROLE|PRIVATE|PASSWORD|PASSWD|_TOKEN|API_KEY|ACCESS_KEY|CREDENTIAL)/;
87
+ const LOCKFILES = new Set([
88
+ 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb',
89
+ 'poetry.lock', 'Pipfile.lock', 'composer.lock',
90
+ ]);
91
+ /**
92
+ * True when the match sits on a commented-out line. Splits on real newlines and
93
+ * on the two-character `\n` escape as well, because docs-in-a-string-literal
94
+ * (`"# .env — WRONG\n# NEXT_PUBLIC_SECRET=..."`) are a common way to show the
95
+ * wrong way to do something, and flagging those is noise.
96
+ */
97
+ function isCommentedOut(source, index) {
98
+ const before = source.slice(0, index);
99
+ const start = Math.max(before.lastIndexOf('\n'), before.lastIndexOf('\\n') + 1, before.lastIndexOf('"'), before.lastIndexOf("'"), before.lastIndexOf('`'));
100
+ const segment = before.slice(start + 1).trimStart();
101
+ return /^(#|\/\/|\*|--)/.test(segment);
102
+ }
103
+ /**
104
+ * True when the offset sits inside a string literal on its own line. Security
105
+ * tooling, docs and rule libraries quote the very patterns we search for, and
106
+ * a quoted mention is prose, not configuration.
107
+ */
108
+ function isQuoted(source, index) {
109
+ const lineStart = source.lastIndexOf('\n', index - 1) + 1;
110
+ const before = source.slice(lineStart, index);
111
+ for (const quote of ['"', "'", '`']) {
112
+ let count = 0;
113
+ for (let i = 0; i < before.length; i++) {
114
+ if (before[i] === quote && before[i - 1] !== '\\')
115
+ count++;
116
+ }
117
+ if (count % 2 === 1)
118
+ return true;
119
+ }
120
+ return false;
121
+ }
122
+ export const secretsScanner = {
123
+ name: 'Secrets & client-bundle boundary',
124
+ applies() {
125
+ return true;
126
+ },
127
+ async run(ctx) {
128
+ const result = emptyResult();
129
+ let filesScanned = 0;
130
+ const seen = new Set();
131
+ for (const file of ctx.files) {
132
+ const name = basename(file);
133
+ if (LOCKFILES.has(name))
134
+ continue;
135
+ const source = read(file);
136
+ if (source === null)
137
+ continue;
138
+ const relPath = rel(ctx.root, file);
139
+ const isExample = /\.(example|sample|template)$/.test(relPath) || /\.env\.example/.test(relPath);
140
+ const fixtureFile = NON_PRODUCTION_PATH.test(relPath);
141
+ filesScanned++;
142
+ const suppress = new Suppressions(source);
143
+ const clientComponent = /^\s*(['"])use client\1/m.test(source.slice(0, 400));
144
+ for (const pattern of PATTERNS) {
145
+ pattern.re.lastIndex = 0;
146
+ let m;
147
+ while ((m = pattern.re.exec(source)) !== null) {
148
+ const value = m[0];
149
+ if (pattern.confirm && !pattern.confirm(value))
150
+ continue;
151
+ if (PLACEHOLDER.test(value) || REPEATED_RUN.test(value))
152
+ continue;
153
+ if (pattern.id === 'supabase-anon')
154
+ continue; // informational only, not reported
155
+ const line = lineAt(source, m.index);
156
+ if (suppress.suppressed(line, 'CTS030'))
157
+ continue;
158
+ const key = `${relPath}:${line}:${pattern.id}`;
159
+ if (seen.has(key))
160
+ continue;
161
+ seen.add(key);
162
+ seen.add(`${relPath}:${line}:builtin`);
163
+ const redacted = value.length > 14 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value;
164
+ // Redact first, then truncate: a long key would otherwise be cut off
165
+ // before the replacement could match, leaving the secret on screen.
166
+ const rawLine = source.split('\n')[line - 1] ?? '';
167
+ const safeLine = rawLine.split(value).join(redacted).trim();
168
+ const snippet = safeLine.length > 160 ? safeLine.slice(0, 157) + '...' : safeLine;
169
+ const severity = isExample || fixtureFile
170
+ ? 'low'
171
+ : clientComponent && pattern.severity === 'critical'
172
+ ? 'critical'
173
+ : pattern.severity;
174
+ result.findings.push({
175
+ id: 'CTS030',
176
+ severity,
177
+ title: `Hardcoded ${pattern.label}`,
178
+ detail: `${pattern.label} \`${redacted}\` is written into ${relPath}. ` +
179
+ (clientComponent
180
+ ? 'This file is a client component, so the value is compiled into the JavaScript bundle every visitor downloads. '
181
+ : 'Anything committed to git is recoverable from history even after you delete the line. ') +
182
+ (pattern.note ?? '') +
183
+ (isExample
184
+ ? ' (This looks like an example file, so the severity is reduced — confirm the value is not real.)'
185
+ : fixtureFile
186
+ ? ' (This path looks like tests or docs, so the severity is reduced — confirm the value is not real.)'
187
+ : ''),
188
+ fix: 'Move the value into an environment variable that is only read server-side, rotate the key ' +
189
+ 'at the provider (assume it is burned), and purge it from git history if it was ever committed.',
190
+ file: relPath,
191
+ line,
192
+ snippet,
193
+ cwe: 'CWE-798: Use of Hard-coded Credentials',
194
+ owasp: 'A04:2025 - Cryptographic Failures',
195
+ meta: { kind: pattern.id, clientComponent },
196
+ });
197
+ }
198
+ }
199
+ // Broad provider coverage from the vendored gitleaks ruleset. The
200
+ // hand-written PATTERNS above stay first: they carry tuned severities and
201
+ // extra verification (decoding a JWT to confirm `role: service_role`), so
202
+ // anything they already reported on this line is not reported twice.
203
+ const lowerSource = source.toLowerCase();
204
+ for (const rule of GITLEAKS_RULES) {
205
+ if (GITLEAKS_WITHHELD.has(rule.id))
206
+ continue;
207
+ // Cheap substring prefilter before touching the regex.
208
+ if (rule.keywords.length > 0 && !rule.keywords.some((k) => lowerSource.includes(k))) {
209
+ continue;
210
+ }
211
+ rule.pattern.lastIndex = 0;
212
+ let g;
213
+ let hits = 0;
214
+ while ((g = rule.pattern.exec(source)) !== null) {
215
+ if (g[0].length === 0) {
216
+ rule.pattern.lastIndex++;
217
+ continue;
218
+ }
219
+ const secret = g[1] ?? g[0];
220
+ const line = lineAt(source, g.index);
221
+ if (HEX_DIGEST.test(secret))
222
+ continue;
223
+ if (rule.entropy !== null && shannonEntropy(secret) < rule.entropy)
224
+ continue;
225
+ if (PLACEHOLDER.test(secret) || REPEATED_RUN.test(secret))
226
+ continue;
227
+ if (GITLEAKS_STOPWORDS.some((w) => secret.toLowerCase().includes(w)))
228
+ continue;
229
+ if (isCommentedOut(source, g.index))
230
+ continue;
231
+ // A hand-written pattern already claimed this location.
232
+ if (seen.has(`${relPath}:${line}:builtin`))
233
+ continue;
234
+ const findingId = `GL-${rule.id}`;
235
+ const key = `${relPath}:${line}:${findingId}`;
236
+ if (seen.has(key) || suppress.suppressed(line, findingId))
237
+ continue;
238
+ seen.add(key);
239
+ const redacted = secret.length > 14 ? `${secret.slice(0, 6)}\u2026${secret.slice(-4)}` : secret;
240
+ const rawLine = source.split('\n')[line - 1] ?? '';
241
+ const safeLine = rawLine.split(secret).join(redacted).trim();
242
+ result.findings.push({
243
+ id: findingId,
244
+ severity: isExample || fixtureFile ? 'low' : clientComponent ? 'critical' : 'high',
245
+ title: `Hardcoded credential (${rule.id})`,
246
+ detail: rule.description +
247
+ ` The matched value (\`${redacted}\`) scores ` +
248
+ `${shannonEntropy(secret).toFixed(1)} bits/char of entropy, which is consistent with a ` +
249
+ 'real credential rather than a placeholder.' +
250
+ (clientComponent
251
+ ? ' This file is a client component, so the value ships in the browser bundle.'
252
+ : '') +
253
+ (isExample || fixtureFile
254
+ ? ' (Path looks like an example, test or docs file, so the severity is reduced.)'
255
+ : ''),
256
+ fix: 'Rotate this credential at the provider — assume it is burned — then move it to an ' +
257
+ 'environment variable read only on the server, and purge it from git history.',
258
+ file: relPath,
259
+ line,
260
+ snippet: safeLine.length > 160 ? safeLine.slice(0, 157) + '...' : safeLine,
261
+ cwe: 'CWE-798: Use of Hard-coded Credentials',
262
+ owasp: 'A04:2025 - Cryptographic Failures',
263
+ meta: {
264
+ source: 'gitleaks',
265
+ rule: rule.id,
266
+ attribution: GITLEAKS_ATTRIBUTION,
267
+ entropy: Number(shannonEntropy(secret).toFixed(2)),
268
+ },
269
+ });
270
+ if (++hits >= 3)
271
+ break;
272
+ }
273
+ }
274
+ // Server-only secrets routed through a NEXT_PUBLIC_ variable end up in the bundle.
275
+ // Only a genuine read (`process.env.X`) or assignment (`X=...`) counts;
276
+ // a variable merely named inside a string or a test description does not.
277
+ const envRe = /process\.env\.(NEXT_PUBLIC_[A-Z0-9_]+)|(NEXT_PUBLIC_[A-Z0-9_]+)\s*[=:]/g;
278
+ let e;
279
+ while ((e = envRe.exec(source)) !== null) {
280
+ const varName = e[1] ?? e[2];
281
+ if (PUBLIC_BY_DESIGN.test(varName))
282
+ continue;
283
+ if (!SECRETY_NAME.test(varName))
284
+ continue;
285
+ const line = lineAt(source, e.index);
286
+ if (isCommentedOut(source, e.index))
287
+ continue; // a documented counter-example
288
+ if (suppress.suppressed(line, 'CTS031'))
289
+ continue;
290
+ const key = `${relPath}:${varName}`;
291
+ if (seen.has(key))
292
+ continue;
293
+ seen.add(key);
294
+ result.findings.push({
295
+ id: 'CTS031',
296
+ severity: fixtureFile ? 'low' : 'critical',
297
+ title: 'Server secret exposed through a NEXT_PUBLIC_ variable',
298
+ detail: `\`${varName}\` is prefixed \`NEXT_PUBLIC_\`, so Next.js inlines its value into the browser ` +
299
+ 'bundle at build time. The name says it holds a secret. Every visitor can read it in devtools.',
300
+ fix: `Rename it to \`${varName.replace('NEXT_PUBLIC_', '')}\`, read it only in server code (Server ` +
301
+ 'Actions, Route Handlers, server components), and rotate the current value.',
302
+ file: relPath,
303
+ line,
304
+ snippet: snippetAt(source, line),
305
+ cwe: 'CWE-200: Exposure of Sensitive Information to an Unauthorized Actor',
306
+ owasp: 'A04:2025 - Cryptographic Failures',
307
+ meta: { variable: varName },
308
+ });
309
+ }
310
+ // Any server-side env var read in a client component is inlined into the
311
+ // bundle as `undefined` at best, and as its value at worst. The named
312
+ // secrets below are the critical case; this is the general one.
313
+ if (clientComponent && isScript(file)) {
314
+ const anyEnv = /process\.env\.(?!NEXT_PUBLIC_)([A-Z][A-Z0-9_]{2,})/g;
315
+ const reported = new Set();
316
+ let g;
317
+ while ((g = anyEnv.exec(source)) !== null) {
318
+ const varName = g[1];
319
+ if (varName === 'NODE_ENV' || varName === 'VERCEL_ENV' || varName === 'NODE_OPTIONS')
320
+ continue;
321
+ if (SECRETY_NAME.test(varName))
322
+ continue; // covered at critical by CTS033
323
+ if (reported.has(varName))
324
+ continue;
325
+ reported.add(varName);
326
+ const line = lineAt(source, g.index);
327
+ if (isCommentedOut(source, g.index))
328
+ continue;
329
+ if (suppress.suppressed(line, 'CTS040'))
330
+ continue;
331
+ result.findings.push({
332
+ id: 'CTS040',
333
+ severity: fixtureFile ? 'low' : 'high',
334
+ title: 'Client component reads a server-side environment variable',
335
+ detail: `${relPath} is a client component and reads \`process.env.${varName}\`. Next.js only ` +
336
+ 'inlines `NEXT_PUBLIC_*` variables into the browser bundle, so this is `undefined` at ' +
337
+ 'runtime — the logic depending on it is silently not running, and if the prefix is ever ' +
338
+ 'added to "fix" that, the value ships to every visitor.',
339
+ fix: `Read \`${varName}\` in a Server Component, Server Action or Route Handler and pass the ` +
340
+ 'result down as a prop. Add the `NEXT_PUBLIC_` prefix only if the value is genuinely public.',
341
+ file: relPath,
342
+ line,
343
+ snippet: snippetAt(source, line),
344
+ cwe: 'CWE-668: Exposure of Resource to Wrong Sphere',
345
+ owasp: 'A04:2025 - Cryptographic Failures',
346
+ meta: { variable: varName },
347
+ });
348
+ }
349
+ }
350
+ // An AI SDK client told to run in the browser ships the API key with it.
351
+ if (isScript(file)) {
352
+ const browserFlag = /dangerouslyAllowBrowser\s*:\s*true/.exec(source);
353
+ if (browserFlag &&
354
+ !isCommentedOut(source, browserFlag.index) &&
355
+ !isQuoted(source, browserFlag.index)) {
356
+ const line = lineAt(source, browserFlag.index);
357
+ if (!suppress.suppressed(line, 'CTS045')) {
358
+ result.findings.push({
359
+ id: 'CTS045',
360
+ severity: fixtureFile ? 'low' : 'critical',
361
+ title: 'AI client configured to run in the browser',
362
+ detail: `${relPath} sets \`dangerouslyAllowBrowser: true\`. That flag exists purely to disable ` +
363
+ 'the SDK\u2019s own guard against shipping your API key to the client. Every visitor can ' +
364
+ 'read the key out of the bundle and spend your quota.',
365
+ fix: 'Call the model from a Route Handler or Server Action and have the browser call that ' +
366
+ 'instead. If you need streaming, proxy the stream through your own endpoint.',
367
+ file: relPath,
368
+ line,
369
+ snippet: snippetAt(source, line),
370
+ cwe: 'CWE-522: Insufficiently Protected Credentials',
371
+ owasp: 'A04:2025 - Cryptographic Failures',
372
+ meta: { flag: 'dangerouslyAllowBrowser' },
373
+ });
374
+ }
375
+ }
376
+ }
377
+ // A client component reaching for a service-role client is always wrong.
378
+ if (clientComponent && isScript(file)) {
379
+ const hit = /SUPABASE_SERVICE_ROLE_KEY|SERVICE_ROLE_KEY|STRIPE_SECRET_KEY/.exec(source);
380
+ if (hit) {
381
+ const line = lineAt(source, hit.index);
382
+ if (!suppress.suppressed(line, 'CTS033')) {
383
+ result.findings.push({
384
+ id: 'CTS033',
385
+ severity: 'critical',
386
+ title: 'Client component references a server-only secret',
387
+ detail: `${relPath} carries the \`'use client'\` directive and reads \`${hit[0]}\`. Even if the ` +
388
+ 'variable is undefined in the browser today, the reference means the code path was designed ' +
389
+ 'to run privileged work in an untrusted context.',
390
+ fix: 'Move the privileged work into a Server Action or Route Handler and call that from the ' +
391
+ 'client component instead.',
392
+ file: relPath,
393
+ line,
394
+ snippet: snippetAt(source, line),
395
+ cwe: 'CWE-668: Exposure of Resource to Wrong Sphere',
396
+ owasp: 'A04:2025 - Cryptographic Failures',
397
+ });
398
+ }
399
+ }
400
+ }
401
+ }
402
+ // Are real .env files kept out of git?
403
+ const gitignore = read(join(ctx.root, '.gitignore'));
404
+ const envFiles = ctx.files
405
+ .map((f) => rel(ctx.root, f))
406
+ .filter((f) => /(^|\/)\.env(\.|$)/.test(f) && !/\.(example|sample|template)$/.test(f));
407
+ if (envFiles.length > 0 && exists(join(ctx.root, '.git'))) {
408
+ const covered = gitignore !== null &&
409
+ gitignore.split('\n').some((l) => {
410
+ const t = l.trim();
411
+ return t === '.env' || t === '.env*' || t === '*.env' || t === '.env.*' || t === '.env*.local';
412
+ });
413
+ if (!covered) {
414
+ result.findings.push({
415
+ id: 'CTS032',
416
+ severity: 'high',
417
+ title: '.env file is not covered by .gitignore',
418
+ detail: `${envFiles.join(', ')} ${envFiles.length === 1 ? 'exists' : 'exist'} in a git repository ` +
419
+ `and \`.gitignore\` has no rule matching ${envFiles.length === 1 ? 'it' : 'them'}. ` +
420
+ `One \`git add .\` publishes every key ${envFiles.length === 1 ? 'it holds' : 'they hold'}.`,
421
+ fix: 'Add `.env*` to .gitignore (keeping `!.env.example`), then `git rm --cached` any env file already tracked.',
422
+ file: '.gitignore',
423
+ line: 1,
424
+ cwe: 'CWE-538: Insertion of Sensitive Information into an Externally-Accessible File',
425
+ owasp: 'A04:2025 - Cryptographic Failures',
426
+ meta: { envFiles },
427
+ });
428
+ }
429
+ }
430
+ const leaked = result.findings.filter((f) => f.id !== 'CTS032' && f.severity !== 'low').length;
431
+ result.checks.push({
432
+ label: `Secret & client-bundle boundary (${filesScanned} file${filesScanned === 1 ? '' : 's'}, ` +
433
+ `${PATTERNS.length + GITLEAKS_RULES.length - GITLEAKS_WITHHELD.size} credential patterns)`,
434
+ passed: leaked === 0,
435
+ note: `${GITLEAKS_WITHHELD.size} gitleaks rules withheld as noisy`,
436
+ });
437
+ return result;
438
+ },
439
+ };
@@ -0,0 +1,2 @@
1
+ import type { Scanner } from '../types.js';
2
+ export declare const serverActionsScanner: Scanner;