@rigour-labs/core 4.3.1 → 4.3.3

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.
@@ -36,9 +36,10 @@ export class ContextWindowArtifactsGate extends Gate {
36
36
  if (!this.config.enabled)
37
37
  return [];
38
38
  const failures = [];
39
+ const scanPatterns = context.patterns || ['**/*.{ts,js,tsx,jsx,py}'];
39
40
  const files = await FileScanner.findFiles({
40
41
  cwd: context.cwd,
41
- patterns: ['**/*.{ts,js,tsx,jsx,py}'],
42
+ patterns: scanPatterns,
42
43
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*', '**/*.min.*'],
43
44
  });
44
45
  Logger.info(`Context Window Artifacts: Scanning ${files.length} files`);
@@ -48,9 +48,11 @@ export class DeprecatedApisGate extends Gate {
48
48
  return [];
49
49
  const failures = [];
50
50
  const deprecated = [];
51
+ const defaultPatterns = ['**/*.{ts,js,tsx,jsx,py,go,cs,java,kt}'];
52
+ const scanPatterns = context.patterns || defaultPatterns;
51
53
  const files = await FileScanner.findFiles({
52
54
  cwd: context.cwd,
53
- patterns: ['**/*.{ts,js,tsx,jsx,py,go,cs,java,kt}'],
55
+ patterns: scanPatterns,
54
56
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**',
55
57
  '**/*.test.*', '**/*.spec.*', '**/__tests__/**',
56
58
  '**/.venv/**', '**/venv/**', '**/vendor/**', '**/__pycache__/**',
@@ -33,9 +33,10 @@ export class DuplicationDriftGate extends Gate {
33
33
  return [];
34
34
  const failures = [];
35
35
  const functions = [];
36
+ const scanPatterns = context.patterns || ['**/*.{ts,js,tsx,jsx,py}'];
36
37
  const files = await FileScanner.findFiles({
37
38
  cwd: context.cwd,
38
- patterns: ['**/*.{ts,js,tsx,jsx,py}'],
39
+ patterns: scanPatterns,
39
40
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*'],
40
41
  });
41
42
  Logger.info(`Duplication Drift: Scanning ${files.length} files`);
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Frontend Secret Exposure Gate
3
+ *
4
+ * Detects server-side secret keys (Stripe, OpenAI, AWS, etc.) referenced
5
+ * in frontend-accessible files where they would be bundled into client JS.
6
+ *
7
+ * The "vibe-coded startup" attack pattern:
8
+ * process.env.STRIPE_SECRET_KEY inside a React component
9
+ * → bundled by webpack/Vite → visible in DevTools → $2,500 in fraudulent charges
10
+ *
11
+ * Two detection modes:
12
+ * 1. Env-var name detection: process.env.VARNAME / import.meta.env.VARNAME
13
+ * where VARNAME matches secret_env_name_patterns and lacks a safe public prefix.
14
+ * 2. Literal key detection: actual live API key values embedded in source
15
+ * (sk_live_..., sk-proj-..., AKIA..., ghp_..., etc.)
16
+ *
17
+ * @since v4.4.0
18
+ * @see CWE-312 Cleartext Storage of Sensitive Information
19
+ */
20
+ import { Gate, GateContext } from './base.js';
21
+ import { Failure, Provenance } from '../types/index.js';
22
+ export interface FrontendSecretExposureConfig {
23
+ enabled?: boolean;
24
+ block_on_severity?: 'critical' | 'high' | 'medium' | 'low';
25
+ check_process_env?: boolean;
26
+ check_import_meta_env?: boolean;
27
+ secret_env_name_patterns?: string[];
28
+ safe_public_prefixes?: string[];
29
+ frontend_path_patterns?: string[];
30
+ server_path_patterns?: string[];
31
+ allowlist_env_names?: string[];
32
+ }
33
+ export declare class FrontendSecretExposureGate extends Gate {
34
+ private cfg;
35
+ private secretNamePatterns;
36
+ private safePrefixPatterns;
37
+ private frontendPathPatterns;
38
+ private serverPathPatterns;
39
+ constructor(config?: FrontendSecretExposureConfig);
40
+ protected get provenance(): Provenance;
41
+ run(context: GateContext): Promise<Failure[]>;
42
+ /** Classify a file path as frontend, server, or ambiguous based on config patterns. */
43
+ private classifyFile;
44
+ /** Test/fixture files are excluded — they legitimately contain dummy keys. */
45
+ private isTestFile;
46
+ /**
47
+ * Check whether a var name looks like a server-side secret:
48
+ * - Matches at least one secret_env_name_pattern
49
+ * - Does NOT start with a safe public prefix
50
+ * - Is NOT in the user allowlist
51
+ */
52
+ private isSecretVar;
53
+ /** Scan one file for env-var references and literal key values. */
54
+ private scanFile;
55
+ private makeEnvRefExposure;
56
+ /** Filters out trivial dummy/placeholder text to reduce false positives. */
57
+ private isDummyValue;
58
+ private toFailures;
59
+ }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Frontend Secret Exposure Gate
3
+ *
4
+ * Detects server-side secret keys (Stripe, OpenAI, AWS, etc.) referenced
5
+ * in frontend-accessible files where they would be bundled into client JS.
6
+ *
7
+ * The "vibe-coded startup" attack pattern:
8
+ * process.env.STRIPE_SECRET_KEY inside a React component
9
+ * → bundled by webpack/Vite → visible in DevTools → $2,500 in fraudulent charges
10
+ *
11
+ * Two detection modes:
12
+ * 1. Env-var name detection: process.env.VARNAME / import.meta.env.VARNAME
13
+ * where VARNAME matches secret_env_name_patterns and lacks a safe public prefix.
14
+ * 2. Literal key detection: actual live API key values embedded in source
15
+ * (sk_live_..., sk-proj-..., AKIA..., ghp_..., etc.)
16
+ *
17
+ * @since v4.4.0
18
+ * @see CWE-312 Cleartext Storage of Sensitive Information
19
+ */
20
+ import { Gate } from './base.js';
21
+ import { FileScanner } from '../utils/scanner.js';
22
+ import { Logger } from '../utils/logger.js';
23
+ import fs from 'fs-extra';
24
+ import path from 'path';
25
+ /**
26
+ * Literal API key patterns — actual values that are always dangerous in frontend code.
27
+ * These bypass the env-name heuristic and fire regardless of variable naming.
28
+ */
29
+ const LITERAL_KEY_PATTERNS = [
30
+ {
31
+ label: 'Stripe Live Secret Key',
32
+ regex: /\bsk_live_[a-zA-Z0-9]{24,}\b/g,
33
+ severity: 'critical',
34
+ hint: 'Live Stripe secret key (sk_live_...) exposed in client bundle. ' +
35
+ 'Attackers can charge cards without a frontend. Move to server-side only.',
36
+ cwe: 'CWE-312',
37
+ },
38
+ {
39
+ label: 'Stripe Live Restricted Key',
40
+ regex: /\brk_live_[a-zA-Z0-9]{24,}\b/g,
41
+ severity: 'critical',
42
+ hint: 'Live Stripe restricted key (rk_live_...) must never appear in frontend code.',
43
+ cwe: 'CWE-312',
44
+ },
45
+ {
46
+ label: 'OpenAI API Key',
47
+ regex: /\bsk-proj-[a-zA-Z0-9_-]{40,}\b/g,
48
+ severity: 'critical',
49
+ hint: 'OpenAI project API key (sk-proj-...) exposed in client bundle. ' +
50
+ 'Attackers can make unlimited API calls at your expense.',
51
+ cwe: 'CWE-312',
52
+ },
53
+ {
54
+ label: 'OpenAI Legacy API Key',
55
+ regex: /\bsk-[a-zA-Z0-9]{48,}\b/g,
56
+ severity: 'critical',
57
+ hint: 'OpenAI API key (sk-...) exposed in client bundle. Move to server-side proxy.',
58
+ cwe: 'CWE-312',
59
+ },
60
+ {
61
+ label: 'AWS Access Key ID',
62
+ regex: /\bAKIA[A-Z2-7]{16}\b/g,
63
+ severity: 'critical',
64
+ hint: 'AWS Access Key ID exposed in client bundle. This grants AWS API access.',
65
+ cwe: 'CWE-312',
66
+ },
67
+ {
68
+ label: 'GitHub Personal Access Token',
69
+ regex: /\bghp_[a-zA-Z0-9]{36,}\b/g,
70
+ severity: 'critical',
71
+ hint: 'GitHub PAT (ghp_...) in frontend code. Grants repo access to anyone who views bundle.',
72
+ cwe: 'CWE-312',
73
+ },
74
+ {
75
+ label: 'GitHub Fine-Grained PAT',
76
+ regex: /\bgithub_pat_[a-zA-Z0-9_]{55,}\b/g,
77
+ severity: 'critical',
78
+ hint: 'GitHub fine-grained PAT (github_pat_...) must never appear in frontend code.',
79
+ cwe: 'CWE-312',
80
+ },
81
+ {
82
+ label: 'Anthropic API Key',
83
+ regex: /\bsk-ant-api\d{2}-[a-zA-Z0-9_-]{90,}\b/g,
84
+ severity: 'critical',
85
+ hint: 'Anthropic API key exposed in client bundle. Move to a server-side proxy.',
86
+ cwe: 'CWE-312',
87
+ },
88
+ {
89
+ label: 'SendGrid API Key',
90
+ regex: /\bSG\.[a-zA-Z0-9_-]{22,}\.[a-zA-Z0-9_-]{43,}\b/g,
91
+ severity: 'critical',
92
+ hint: 'SendGrid API key exposed in client bundle. Attackers can send spam as you.',
93
+ cwe: 'CWE-312',
94
+ },
95
+ ];
96
+ /** Severity ordering for threshold comparison */
97
+ const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
98
+ /**
99
+ * Extracts the env var name from a single-reference line.
100
+ * process.env.VARNAME → "VARNAME"
101
+ * process.env["VARNAME"] → "VARNAME"
102
+ * import.meta.env.VARNAME → "VARNAME"
103
+ * Returns null if no match.
104
+ */
105
+ function extractEnvVarName(line, checkProcessEnv, checkImportMetaEnv) {
106
+ if (checkProcessEnv) {
107
+ const dot = line.match(/process\.env\.([A-Z][A-Z0-9_]*)/);
108
+ if (dot)
109
+ return dot[1];
110
+ const bracket = line.match(/process\.env\[['"]([A-Z][A-Z0-9_]*)['"]\]/);
111
+ if (bracket)
112
+ return bracket[1];
113
+ }
114
+ if (checkImportMetaEnv) {
115
+ const vite = line.match(/import\.meta\.env\.([A-Z_][A-Z0-9_]*)/);
116
+ if (vite)
117
+ return vite[1];
118
+ }
119
+ return null;
120
+ }
121
+ /**
122
+ * Finds all var names from destructuring:
123
+ * const { STRIPE_SECRET_KEY, OPENAI_KEY } = process.env
124
+ */
125
+ function extractDestructuredEnvVars(line) {
126
+ if (!/=\s*process\.env\b/.test(line))
127
+ return [];
128
+ const brace = line.match(/\{\s*([^}]+)\s*\}/);
129
+ if (!brace)
130
+ return [];
131
+ return brace[1]
132
+ .split(',')
133
+ .map(s => s.split(':')[0].trim()) // handle "SECRET_KEY: renamed"
134
+ .filter(s => /^[A-Z_][A-Z0-9_]*$/.test(s));
135
+ }
136
+ export class FrontendSecretExposureGate extends Gate {
137
+ cfg;
138
+ secretNamePatterns;
139
+ safePrefixPatterns;
140
+ frontendPathPatterns;
141
+ serverPathPatterns;
142
+ constructor(config = {}) {
143
+ super('frontend-secret-exposure', 'Frontend Secret Exposure');
144
+ this.cfg = {
145
+ enabled: config.enabled ?? true,
146
+ block_on_severity: config.block_on_severity ?? 'high',
147
+ check_process_env: config.check_process_env ?? true,
148
+ check_import_meta_env: config.check_import_meta_env ?? true,
149
+ secret_env_name_patterns: config.secret_env_name_patterns ?? [
150
+ '(?:^|_)(?:secret|private)(?:_|$)',
151
+ '(?:^|_)(?:token|api[_-]?key|access[_-]?key|client[_-]?secret|signing|webhook)(?:_|$)',
152
+ '(?:^|_)(?:db[_-]?url|database[_-]?url|connection[_-]?string)(?:_|$)',
153
+ ],
154
+ safe_public_prefixes: config.safe_public_prefixes ?? [
155
+ 'NEXT_PUBLIC_', 'VITE_', 'PUBLIC_', 'NUXT_PUBLIC_', 'REACT_APP_',
156
+ ],
157
+ frontend_path_patterns: config.frontend_path_patterns ?? [
158
+ '(^|/)pages/(?!api/)',
159
+ '(^|/)components/',
160
+ '(^|/)src/components/',
161
+ '(^|/)src/views/',
162
+ '(^|/)src/app/',
163
+ '(^|/)app/(?!api/)',
164
+ '(^|/)views/',
165
+ '(^|/)public/',
166
+ ],
167
+ server_path_patterns: config.server_path_patterns ?? [
168
+ '(^|/)pages/api/',
169
+ '(^|/)src/pages/api/',
170
+ '(^|/)app/api/',
171
+ '(^|/)src/app/api/',
172
+ '\\.server\\.(?:ts|tsx|js|jsx|mjs|cjs)$',
173
+ ],
174
+ allowlist_env_names: config.allowlist_env_names ?? [],
175
+ };
176
+ this.secretNamePatterns = this.cfg.secret_env_name_patterns.map(p => new RegExp(p, 'i'));
177
+ // Escape prefix strings to safe regex literals
178
+ this.safePrefixPatterns = this.cfg.safe_public_prefixes.map(prefix => new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
179
+ this.frontendPathPatterns = this.cfg.frontend_path_patterns.map(p => new RegExp(p));
180
+ this.serverPathPatterns = this.cfg.server_path_patterns.map(p => new RegExp(p));
181
+ }
182
+ get provenance() { return 'security'; }
183
+ async run(context) {
184
+ if (!this.cfg.enabled)
185
+ return [];
186
+ const scanPatterns = context.patterns || ['**/*.{ts,tsx,js,jsx,mjs,cjs,vue,svelte}'];
187
+ const files = await FileScanner.findFiles({
188
+ cwd: context.cwd,
189
+ patterns: scanPatterns,
190
+ ignore: [
191
+ ...(context.ignore || []),
192
+ '**/node_modules/**', '**/dist/**', '**/build/**',
193
+ '**/.next/**', '**/coverage/**', '**/out/**',
194
+ ],
195
+ });
196
+ // Skip test/fixture files — they routinely use dummy keys
197
+ const sourceFiles = files.filter(f => !this.isTestFile(f));
198
+ Logger.info(`Frontend Secret Exposure Gate: scanning ${sourceFiles.length} files`);
199
+ const exposures = [];
200
+ for (const file of sourceFiles) {
201
+ const fileContext = this.classifyFile(file);
202
+ if (fileContext === 'server')
203
+ continue;
204
+ try {
205
+ const fullPath = path.join(context.cwd, file);
206
+ const content = await fs.readFile(fullPath, 'utf-8');
207
+ // Skip files guarded by Next.js server-only import
208
+ if (/import\s+['"]server-only['"]/.test(content))
209
+ continue;
210
+ this.scanFile(content, file, fileContext, exposures);
211
+ }
212
+ catch {
213
+ // Unreadable — skip silently
214
+ }
215
+ }
216
+ return this.toFailures(exposures);
217
+ }
218
+ /** Classify a file path as frontend, server, or ambiguous based on config patterns. */
219
+ classifyFile(file) {
220
+ const n = file.replace(/\\/g, '/');
221
+ // Server paths take priority over frontend paths
222
+ if (this.serverPathPatterns.some(p => p.test(n)))
223
+ return 'server';
224
+ if (this.frontendPathPatterns.some(p => p.test(n)))
225
+ return 'frontend';
226
+ return 'ambiguous';
227
+ }
228
+ /** Test/fixture files are excluded — they legitimately contain dummy keys. */
229
+ isTestFile(file) {
230
+ const n = file.replace(/\\/g, '/');
231
+ return /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(n)
232
+ || /\/__tests__\//.test(n)
233
+ || /\/(?:fixtures|mocks|stubs)\//.test(n);
234
+ }
235
+ /**
236
+ * Check whether a var name looks like a server-side secret:
237
+ * - Matches at least one secret_env_name_pattern
238
+ * - Does NOT start with a safe public prefix
239
+ * - Is NOT in the user allowlist
240
+ */
241
+ isSecretVar(varName) {
242
+ if (this.cfg.allowlist_env_names.includes(varName))
243
+ return false;
244
+ if (this.safePrefixPatterns.some(p => p.test(varName)))
245
+ return false;
246
+ return this.secretNamePatterns.some(p => p.test(varName));
247
+ }
248
+ /** Scan one file for env-var references and literal key values. */
249
+ scanFile(content, file, fileContext, out) {
250
+ const lines = content.split('\n');
251
+ for (let i = 0; i < lines.length; i++) {
252
+ const line = lines[i];
253
+ const lineNum = i + 1;
254
+ // Skip comment lines to avoid flagging docs/examples
255
+ const trimmed = line.trimStart();
256
+ if (trimmed.startsWith('//') ||
257
+ trimmed.startsWith('*') ||
258
+ trimmed.startsWith('#') ||
259
+ trimmed.startsWith('<!--'))
260
+ continue;
261
+ // 1. Env-var name detection (process.env / import.meta.env)
262
+ if (this.cfg.check_process_env || this.cfg.check_import_meta_env) {
263
+ const single = extractEnvVarName(line, this.cfg.check_process_env, this.cfg.check_import_meta_env);
264
+ if (single && this.isSecretVar(single)) {
265
+ out.push(this.makeEnvRefExposure(file, lineNum, line, single, fileContext));
266
+ }
267
+ // Destructuring: const { STRIPE_SECRET_KEY } = process.env
268
+ if (this.cfg.check_process_env) {
269
+ for (const varName of extractDestructuredEnvVars(line)) {
270
+ if (this.isSecretVar(varName)) {
271
+ out.push(this.makeEnvRefExposure(file, lineNum, line, varName, fileContext));
272
+ }
273
+ }
274
+ }
275
+ }
276
+ // 2. Literal API key detection
277
+ for (const pattern of LITERAL_KEY_PATTERNS) {
278
+ pattern.regex.lastIndex = 0;
279
+ let m;
280
+ while ((m = pattern.regex.exec(line)) !== null) {
281
+ // Skip dummy/placeholder values and test-mode keys
282
+ if (this.isDummyValue(m[0]))
283
+ continue;
284
+ if (/(?:sk_test_|pk_test_|_test_|_sandbox_)/i.test(m[0]))
285
+ continue;
286
+ out.push({
287
+ file,
288
+ line: lineNum,
289
+ lineText: line.trim().slice(0, 80),
290
+ varName: m[0].slice(0, 24) + (m[0].length > 24 ? '...' : ''),
291
+ kind: 'literal',
292
+ serviceLabel: pattern.label,
293
+ hint: pattern.hint,
294
+ cwe: pattern.cwe,
295
+ severity: pattern.severity,
296
+ fileContext,
297
+ });
298
+ }
299
+ }
300
+ }
301
+ }
302
+ makeEnvRefExposure(file, line, lineText, varName, fileContext) {
303
+ // Definite frontend → critical; ambiguous → high (may be a shared util)
304
+ const severity = fileContext === 'frontend' ? 'critical' : 'high';
305
+ return {
306
+ file,
307
+ line,
308
+ lineText: lineText.trim().slice(0, 80),
309
+ varName,
310
+ kind: 'env-ref',
311
+ serviceLabel: 'Secret Env Var',
312
+ hint: `\`${varName}\` looks like a server-side secret but is referenced in a ` +
313
+ `client-bundled file. Use an API route (Next.js /api, Remix action, ` +
314
+ `Nuxt server route) to proxy calls instead of exposing the secret to the browser.`,
315
+ cwe: 'CWE-312',
316
+ severity,
317
+ fileContext,
318
+ };
319
+ }
320
+ /** Filters out trivial dummy/placeholder text to reduce false positives. */
321
+ isDummyValue(text) {
322
+ return /(?:example|placeholder|your[_-]|xxx+|dummy|fake|changeme)/i.test(text);
323
+ }
324
+ toFailures(exposures) {
325
+ if (exposures.length === 0)
326
+ return [];
327
+ const blockThreshold = SEVERITY_ORDER[this.cfg.block_on_severity];
328
+ // Deduplicate: same file + line + varName from parallel patterns
329
+ const seen = new Set();
330
+ const unique = exposures.filter(e => {
331
+ const key = `${e.file}:${e.line}:${e.varName}`;
332
+ if (seen.has(key))
333
+ return false;
334
+ seen.add(key);
335
+ return true;
336
+ });
337
+ // Sort critical first
338
+ unique.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
339
+ const failures = [];
340
+ for (const exp of unique) {
341
+ if (SEVERITY_ORDER[exp.severity] > blockThreshold)
342
+ continue;
343
+ const ctx = exp.fileContext === 'frontend' ? '[definite frontend]' : '[possible frontend]';
344
+ const kind = exp.kind === 'literal' ? 'literal key' : 'env var ref';
345
+ failures.push(this.createFailure(`[${exp.cwe}] ${exp.serviceLabel} (${kind}) in ${ctx} file — ` +
346
+ `\`${exp.varName}\` at line ${exp.line}`, [exp.file], exp.hint, `Security: Frontend Secret Exposure — ${exp.serviceLabel}`, exp.line, exp.line, exp.severity));
347
+ }
348
+ if (unique.length > 0 && failures.length === 0) {
349
+ Logger.info(`Frontend Secret Exposure: ${unique.length} issue(s) below ` +
350
+ `${this.cfg.block_on_severity} threshold`);
351
+ }
352
+ return failures;
353
+ }
354
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { FrontendSecretExposureGate } from './frontend-secret-exposure.js';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import * as os from 'os';
6
+ describe('FrontendSecretExposureGate', () => {
7
+ let testDir;
8
+ beforeEach(() => {
9
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'frontend-secret-test-'));
10
+ });
11
+ afterEach(() => {
12
+ fs.rmSync(testDir, { recursive: true, force: true });
13
+ });
14
+ it('detects process.env secret usage in client-bundled file', async () => {
15
+ const filePath = path.join(testDir, 'src/components/Checkout.tsx');
16
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
17
+ fs.writeFileSync(filePath, `
18
+ export function Checkout() {
19
+ const key = process.env.STRIPE_SECRET_KEY;
20
+ return <div>{key}</div>;
21
+ }
22
+ `);
23
+ const gate = new FrontendSecretExposureGate();
24
+ const failures = await gate.run({ cwd: testDir });
25
+ expect(failures.length).toBeGreaterThan(0);
26
+ expect(failures[0].id).toBe('frontend-secret-exposure');
27
+ expect(failures[0].files).toContain('src/components/Checkout.tsx');
28
+ });
29
+ it('detects import.meta.env secret usage in frontend app path', async () => {
30
+ const filePath = path.join(testDir, 'src/app/page.tsx');
31
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
32
+ fs.writeFileSync(filePath, `
33
+ export default function Page() {
34
+ return <span>{import.meta.env.OPENAI_API_KEY}</span>;
35
+ }
36
+ `);
37
+ const gate = new FrontendSecretExposureGate();
38
+ const failures = await gate.run({ cwd: testDir });
39
+ expect(failures.length).toBeGreaterThan(0);
40
+ });
41
+ it('does not flag public env prefixes in client files', async () => {
42
+ const filePath = path.join(testDir, 'components/Header.tsx');
43
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
44
+ fs.writeFileSync(filePath, `
45
+ export const key = process.env.NEXT_PUBLIC_STRIPE_KEY;
46
+ `);
47
+ const gate = new FrontendSecretExposureGate();
48
+ const failures = await gate.run({ cwd: testDir });
49
+ expect(failures).toHaveLength(0);
50
+ });
51
+ it('does not flag server-only API route', async () => {
52
+ const filePath = path.join(testDir, 'pages/api/charge.ts');
53
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
54
+ fs.writeFileSync(filePath, `
55
+ export default function handler() {
56
+ return process.env.STRIPE_SECRET_KEY;
57
+ }
58
+ `);
59
+ const gate = new FrontendSecretExposureGate();
60
+ const failures = await gate.run({ cwd: testDir });
61
+ expect(failures).toHaveLength(0);
62
+ });
63
+ it('does not flag .server files', async () => {
64
+ const filePath = path.join(testDir, 'src/lib/payments.server.ts');
65
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
66
+ fs.writeFileSync(filePath, `
67
+ export const stripeSecret = process.env.STRIPE_SECRET_KEY;
68
+ `);
69
+ const gate = new FrontendSecretExposureGate();
70
+ const failures = await gate.run({ cwd: testDir });
71
+ expect(failures).toHaveLength(0);
72
+ });
73
+ it('respects explicit allowlist env names', async () => {
74
+ const filePath = path.join(testDir, 'src/views/App.tsx');
75
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
76
+ fs.writeFileSync(filePath, `
77
+ export const x = process.env.INTERNAL_TOKEN_FOR_DOCS;
78
+ `);
79
+ const gate = new FrontendSecretExposureGate({
80
+ allowlist_env_names: ['INTERNAL_TOKEN_FOR_DOCS'],
81
+ });
82
+ const failures = await gate.run({ cwd: testDir });
83
+ expect(failures).toHaveLength(0);
84
+ });
85
+ it('skips when disabled', async () => {
86
+ const filePath = path.join(testDir, 'src/components/Client.tsx');
87
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
88
+ fs.writeFileSync(filePath, `
89
+ export const x = process.env.OPENAI_API_KEY;
90
+ `);
91
+ const gate = new FrontendSecretExposureGate({ enabled: false });
92
+ const failures = await gate.run({ cwd: testDir });
93
+ expect(failures).toHaveLength(0);
94
+ });
95
+ });
@@ -47,9 +47,11 @@ export class HallucinatedImportsGate extends Gate {
47
47
  return [];
48
48
  const failures = [];
49
49
  const hallucinated = [];
50
+ const defaultPatterns = ['**/*.{ts,js,tsx,jsx,py,go,rb,cs,rs,java,kt}'];
51
+ const scanPatterns = context.patterns || defaultPatterns;
50
52
  const files = await FileScanner.findFiles({
51
53
  cwd: context.cwd,
52
- patterns: ['**/*.{ts,js,tsx,jsx,py,go,rb,cs,rs,java,kt}'],
54
+ patterns: scanPatterns,
53
55
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**',
54
56
  '**/examples/**',
55
57
  '**/studio-dist/**', '**/.next/**', '**/coverage/**',
@@ -41,9 +41,10 @@ export class InconsistentErrorHandlingGate extends Gate {
41
41
  return [];
42
42
  const failures = [];
43
43
  const handlers = [];
44
+ const scanPatterns = context.patterns || ['**/*.{ts,js,tsx,jsx}'];
44
45
  const files = await FileScanner.findFiles({
45
46
  cwd: context.cwd,
46
- patterns: ['**/*.{ts,js,tsx,jsx}'],
47
+ patterns: scanPatterns,
47
48
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/*.test.*', '**/*.spec.*'],
48
49
  });
49
50
  Logger.info(`Inconsistent Error Handling: Scanning ${files.length} files`);
@@ -45,9 +45,11 @@ export class PhantomApisGate extends Gate {
45
45
  return [];
46
46
  const failures = [];
47
47
  const phantoms = [];
48
+ const defaultPatterns = ['**/*.{ts,js,tsx,jsx,py,go,cs,java,kt}'];
49
+ const scanPatterns = context.patterns || defaultPatterns;
48
50
  const files = await FileScanner.findFiles({
49
51
  cwd: context.cwd,
50
- patterns: ['**/*.{ts,js,tsx,jsx,py,go,cs,java,kt}'],
52
+ patterns: scanPatterns,
51
53
  ignore: [...(context.ignore || []), '**/node_modules/**', '**/dist/**', '**/build/**',
52
54
  '**/*.test.*', '**/*.spec.*', '**/__tests__/**',
53
55
  '**/.venv/**', '**/venv/**', '**/vendor/**', '**/__pycache__/**',
@@ -39,7 +39,7 @@ export function extractIndentedBody(content, startIdx) {
39
39
  }
40
40
  export function isInsideTryBlock(lines, lineIdx) {
41
41
  let braceDepth = 0;
42
- for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - 30); j--) {
42
+ for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - 100); j--) {
43
43
  const prevLine = stripStrings(lines[j]);
44
44
  for (const ch of prevLine) {
45
45
  if (ch === '}')
@@ -56,7 +56,7 @@ export function isInsideTryBlock(lines, lineIdx) {
56
56
  }
57
57
  export function isInsidePythonTry(lines, lineIdx) {
58
58
  const lineIndent = lines[lineIdx].length - lines[lineIdx].trimStart().length;
59
- for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - 30); j--) {
59
+ for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - 100); j--) {
60
60
  const trimmed = lines[j].trim();
61
61
  if (trimmed === '')
62
62
  continue;
@@ -71,7 +71,7 @@ export function isInsidePythonTry(lines, lineIdx) {
71
71
  return false;
72
72
  }
73
73
  export function isInsideRubyRescue(lines, lineIdx) {
74
- for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - 30); j--) {
74
+ for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - 100); j--) {
75
75
  const trimmed = lines[j].trim();
76
76
  if (trimmed === 'begin')
77
77
  return true;
@@ -83,14 +83,14 @@ export function isInsideRubyRescue(lines, lineIdx) {
83
83
  return false;
84
84
  }
85
85
  export function hasCatchAhead(lines, idx) {
86
- for (let j = idx; j < Math.min(idx + 10, lines.length); j++) {
86
+ for (let j = idx; j < Math.min(idx + 50, lines.length); j++) {
87
87
  if (/\.catch\s*\(/.test(lines[j]))
88
88
  return true;
89
89
  }
90
90
  return false;
91
91
  }
92
92
  export function hasStatusCheckAhead(lines, idx) {
93
- for (let j = idx; j < Math.min(idx + 10, lines.length); j++) {
93
+ for (let j = idx; j < Math.min(idx + 50, lines.length); j++) {
94
94
  if (/\.\s*ok\b/.test(lines[j]) || /\.status(?:Text)?\b/.test(lines[j]))
95
95
  return true;
96
96
  }