envspect 0.1.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.
package/dist/cli.js ADDED
@@ -0,0 +1,721 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/scan.ts
7
+ import { existsSync as existsSync2 } from "fs";
8
+ import { resolve as resolve2 } from "path";
9
+ import { createSpinner } from "nanospinner";
10
+
11
+ // src/types/index.ts
12
+ var ExitCode = {
13
+ SUCCESS: 0,
14
+ SCAN_ISSUES_FOUND: 1,
15
+ AUDIT_SECRETS_FOUND: 2,
16
+ FILE_NOT_FOUND: 3,
17
+ CONFIG_ERROR: 4,
18
+ ERROR: 5
19
+ };
20
+
21
+ // src/core/parser.ts
22
+ import { readFile } from "fs/promises";
23
+ async function parseEnvFile(filePath) {
24
+ try {
25
+ const content = await readFile(filePath, "utf8");
26
+ return parseEnvContent(content, filePath);
27
+ } catch (error) {
28
+ throw new Error(`Failed to read file at ${filePath}: ${error.message}`);
29
+ }
30
+ }
31
+ function parseEnvContent(content, filePath = "unknown") {
32
+ const entries = [];
33
+ const errors = [];
34
+ const lines = content.split(/\r?\n/);
35
+ let i = 0;
36
+ while (i < lines.length) {
37
+ const rawLine = lines[i];
38
+ const trimmed = rawLine.trim();
39
+ if (!trimmed) {
40
+ i++;
41
+ continue;
42
+ }
43
+ if (trimmed.startsWith("#")) {
44
+ entries.push({
45
+ key: "",
46
+ value: "",
47
+ line: i + 1,
48
+ isComment: true,
49
+ comment: trimmed.substring(1).trim(),
50
+ raw: rawLine
51
+ });
52
+ i++;
53
+ continue;
54
+ }
55
+ const matchLine = trimmed.startsWith("export ") ? trimmed.substring(7).trimStart() : trimmed;
56
+ const eqIdx = matchLine.indexOf("=");
57
+ if (eqIdx === -1) {
58
+ errors.push({ line: i + 1, message: 'Malformed line, missing "="', raw: rawLine });
59
+ i++;
60
+ continue;
61
+ }
62
+ const key = matchLine.substring(0, eqIdx).trim();
63
+ let valueStr = matchLine.substring(eqIdx + 1).trim();
64
+ let comment = void 0;
65
+ if (valueStr.startsWith('"') || valueStr.startsWith("'") || valueStr.startsWith("`")) {
66
+ const quoteChar = valueStr[0];
67
+ if (quoteChar === '"' || quoteChar === "`") {
68
+ let endIndex = valueStr.indexOf(quoteChar, 1);
69
+ let valueLines = [valueStr];
70
+ let multilineRaw = [rawLine];
71
+ let currentLineIdx = i;
72
+ while (endIndex === -1 && currentLineIdx < lines.length - 1) {
73
+ currentLineIdx++;
74
+ const nextLine = lines[currentLineIdx];
75
+ valueLines.push(nextLine);
76
+ multilineRaw.push(nextLine);
77
+ endIndex = nextLine.indexOf(quoteChar);
78
+ }
79
+ if (endIndex === -1) {
80
+ errors.push({ line: i + 1, message: `Unmatched ${quoteChar}`, raw: multilineRaw.join("\n") });
81
+ i = currentLineIdx + 1;
82
+ continue;
83
+ } else {
84
+ valueStr = valueLines.join("\n");
85
+ const finalRaw = multilineRaw.join("\n");
86
+ const extractedValue = valueStr.substring(1, valueStr.lastIndexOf(quoteChar));
87
+ const afterQuote = valueStr.substring(valueStr.lastIndexOf(quoteChar) + 1).trim();
88
+ if (afterQuote.startsWith("#")) {
89
+ comment = afterQuote.substring(1).trim();
90
+ }
91
+ entries.push({
92
+ key,
93
+ value: extractedValue,
94
+ line: i + 1,
95
+ isComment: false,
96
+ comment,
97
+ raw: finalRaw
98
+ });
99
+ i = currentLineIdx + 1;
100
+ continue;
101
+ }
102
+ } else {
103
+ const endIndex = valueStr.indexOf(quoteChar, 1);
104
+ if (endIndex === -1) {
105
+ errors.push({ line: i + 1, message: "Unmatched single quote", raw: rawLine });
106
+ i++;
107
+ continue;
108
+ }
109
+ const extractedValue = valueStr.substring(1, endIndex);
110
+ const afterQuote = valueStr.substring(endIndex + 1).trim();
111
+ if (afterQuote.startsWith("#")) {
112
+ comment = afterQuote.substring(1).trim();
113
+ }
114
+ entries.push({
115
+ key,
116
+ value: extractedValue,
117
+ line: i + 1,
118
+ isComment: false,
119
+ comment,
120
+ raw: rawLine
121
+ });
122
+ i++;
123
+ continue;
124
+ }
125
+ }
126
+ const hashIdx = valueStr.indexOf("#");
127
+ let value = valueStr;
128
+ if (hashIdx !== -1) {
129
+ value = valueStr.substring(0, hashIdx).trim();
130
+ comment = valueStr.substring(hashIdx + 1).trim();
131
+ }
132
+ entries.push({
133
+ key,
134
+ value,
135
+ line: i + 1,
136
+ isComment: false,
137
+ comment,
138
+ raw: rawLine
139
+ });
140
+ i++;
141
+ }
142
+ return { entries, filePath, errors };
143
+ }
144
+
145
+ // src/core/differ.ts
146
+ function diffEnvFiles(source, target, options = {}) {
147
+ const sourceMap = /* @__PURE__ */ new Map();
148
+ const targetMap = /* @__PURE__ */ new Map();
149
+ for (const entry of source) {
150
+ if (!entry.isComment && entry.key) {
151
+ sourceMap.set(entry.key, entry);
152
+ }
153
+ }
154
+ for (const entry of target) {
155
+ if (!entry.isComment && entry.key) {
156
+ targetMap.set(entry.key, entry);
157
+ }
158
+ }
159
+ const missing = [];
160
+ const extra = [];
161
+ const empty = [];
162
+ let matched = 0;
163
+ for (const [key, entry] of sourceMap.entries()) {
164
+ if (!targetMap.has(key)) {
165
+ missing.push(entry);
166
+ } else {
167
+ matched++;
168
+ }
169
+ }
170
+ for (const [key, entry] of targetMap.entries()) {
171
+ if (!sourceMap.has(key)) {
172
+ extra.push(entry);
173
+ }
174
+ if (entry.value.trim() === "") {
175
+ empty.push(entry);
176
+ }
177
+ }
178
+ return {
179
+ missing,
180
+ extra,
181
+ empty,
182
+ matched,
183
+ sourceFile: "source",
184
+ targetFile: "target"
185
+ };
186
+ }
187
+
188
+ // src/utils/logger.ts
189
+ import pc from "picocolors";
190
+ var log = {
191
+ /** Informational message */
192
+ info: (msg) => console.log(pc.cyan("\u2139"), msg),
193
+ /** Success message */
194
+ success: (msg) => console.log(pc.green("\u2714"), msg),
195
+ /** Warning message */
196
+ warn: (msg) => console.log(pc.yellow("\u26A0"), msg),
197
+ /** Error message */
198
+ error: (msg) => console.error(pc.red("\u2716"), msg),
199
+ /** Debug message (only when ENVSPECT_DEBUG is set) */
200
+ debug: (msg) => {
201
+ if (process.env.ENVSPECT_DEBUG) {
202
+ console.log(pc.gray(`[debug] ${msg}`));
203
+ }
204
+ },
205
+ /** Dim/muted text */
206
+ dim: (msg) => console.log(pc.dim(msg)),
207
+ /** Bold header */
208
+ header: (msg) => console.log(`
209
+ ${pc.bold(pc.underline(msg))}
210
+ `),
211
+ /** Raw console.log (no prefix) */
212
+ raw: (msg) => console.log(msg),
213
+ /** Blank line */
214
+ newline: () => console.log()
215
+ };
216
+ var c = {
217
+ red: pc.red,
218
+ green: pc.green,
219
+ yellow: pc.yellow,
220
+ cyan: pc.cyan,
221
+ dim: pc.dim,
222
+ bold: pc.bold,
223
+ underline: pc.underline,
224
+ gray: pc.gray
225
+ };
226
+
227
+ // src/core/reporter.ts
228
+ function formatScanResult(result, format) {
229
+ const { diff, duration } = result;
230
+ if (format === "json") {
231
+ return JSON.stringify(result, null, 2);
232
+ }
233
+ if (format === "minimal") {
234
+ let out2 = "";
235
+ diff.missing.forEach((m) => out2 += `MISSING: ${m.key}
236
+ `);
237
+ diff.extra.forEach((e) => out2 += `EXTRA: ${e.key}
238
+ `);
239
+ diff.empty.forEach((e) => out2 += `EMPTY: ${e.key}
240
+ `);
241
+ return out2.trim();
242
+ }
243
+ let out = c.bold(`\u250C\u2500 Env Diff Report \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
244
+ `);
245
+ out += `\u2502 Source: ${diff.sourceFile}
246
+ `;
247
+ out += `\u2502 Target: ${diff.targetFile}
248
+ `;
249
+ out += `\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
250
+
251
+ `;
252
+ if (diff.missing.length > 0) {
253
+ out += c.red(c.bold(`Missing Keys (${diff.missing.length}):
254
+ `));
255
+ diff.missing.forEach((m) => out += ` - ${c.red(m.key)}
256
+ `);
257
+ }
258
+ if (diff.extra.length > 0) {
259
+ out += c.yellow(c.bold(`
260
+ Extra Keys (${diff.extra.length}):
261
+ `));
262
+ diff.extra.forEach((e) => out += ` - ${c.yellow(e.key)}
263
+ `);
264
+ }
265
+ if (diff.empty.length > 0) {
266
+ out += c.cyan(c.bold(`
267
+ Empty Keys (${diff.empty.length}):
268
+ `));
269
+ diff.empty.forEach((e) => out += ` - ${c.cyan(e.key)}
270
+ `);
271
+ }
272
+ out += c.gray(`
273
+ Matched: ${diff.matched} | Duration: ${duration.toFixed(2)}ms
274
+ `);
275
+ return out;
276
+ }
277
+ function formatAuditReport(report, format) {
278
+ if (format === "json") {
279
+ return JSON.stringify(report, null, 2);
280
+ }
281
+ if (format === "minimal") {
282
+ return report.findings.map((f) => `${f.filePath}:${f.line} [${f.rule.severity}] ${f.rule.name} - ${f.match}`).join("\n");
283
+ }
284
+ let out = c.bold(`\u250C\u2500 Audit Report \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
285
+ `);
286
+ out += `\u2502 Files Scanned: ${report.filesScanned}
287
+ `;
288
+ out += `\u2502 Findings: ${report.findings.length}
289
+ `;
290
+ out += `\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
291
+
292
+ `;
293
+ if (report.findings.length === 0) {
294
+ out += c.green("No secrets or issues found. \u2728\n");
295
+ } else {
296
+ report.findings.forEach((f) => {
297
+ let colorFn = c.cyan;
298
+ if (f.rule.severity === "critical") colorFn = c.red;
299
+ else if (f.rule.severity === "high") colorFn = c.yellow;
300
+ out += `${colorFn(c.bold(`[${f.rule.severity.toUpperCase()}]`))} ${f.rule.name} in ${f.filePath}:${f.line}
301
+ `;
302
+ out += ` Match: ${f.match}
303
+ `;
304
+ out += ` Context: ${c.dim(f.context)}
305
+
306
+ `;
307
+ });
308
+ }
309
+ out += c.gray(`Duration: ${report.duration.toFixed(2)}ms
310
+ `);
311
+ return out;
312
+ }
313
+
314
+ // src/utils/config.ts
315
+ import { readFile as readFile2 } from "fs/promises";
316
+ import { resolve, join } from "path";
317
+ import { existsSync } from "fs";
318
+ var CONFIG_FILES = [
319
+ ".envspectrc",
320
+ ".envspectrc.json",
321
+ "envspect.config.json"
322
+ ];
323
+ async function loadConfig(cwd) {
324
+ const root = resolve(cwd || process.cwd());
325
+ for (const fileName of CONFIG_FILES) {
326
+ const filePath = join(root, fileName);
327
+ if (!existsSync(filePath)) continue;
328
+ try {
329
+ const content = await readFile2(filePath, "utf-8");
330
+ const config = JSON.parse(content);
331
+ log.debug(`Loaded config from ${fileName}`);
332
+ return config;
333
+ } catch (err) {
334
+ log.warn(`Failed to parse config file: ${fileName}`);
335
+ log.debug(String(err));
336
+ return {};
337
+ }
338
+ }
339
+ log.debug("No config file found, using defaults");
340
+ return {};
341
+ }
342
+ var defaults = {
343
+ envFile: ".env",
344
+ exampleFile: ".env.example",
345
+ format: "table",
346
+ scan: {
347
+ strict: false
348
+ },
349
+ audit: {
350
+ include: ["**/*.{ts,tsx,js,jsx,py,rb,go,java,php,rs,yaml,yml,toml,json,xml,sh}"],
351
+ exclude: [
352
+ "**/node_modules/**",
353
+ "**/dist/**",
354
+ "**/build/**",
355
+ "**/.git/**",
356
+ "**/coverage/**",
357
+ "**/*.min.js",
358
+ "**/vendor/**",
359
+ "**/package-lock.json",
360
+ "**/pnpm-lock.yaml",
361
+ "**/yarn.lock"
362
+ ],
363
+ severity: "low"
364
+ }
365
+ };
366
+
367
+ // src/commands/scan.ts
368
+ async function runScan(options) {
369
+ const cwd = options.cwd || process.cwd();
370
+ const config = await loadConfig(cwd);
371
+ const finalOptions = {
372
+ envFile: options.envFile ?? config.envFile ?? defaults.envFile,
373
+ exampleFile: options.exampleFile ?? config.exampleFile ?? defaults.exampleFile,
374
+ strict: options.strict ?? config.scan?.strict ?? defaults.scan.strict,
375
+ format: options.format ?? config.format ?? defaults.format,
376
+ silent: options.silent ?? false,
377
+ cwd
378
+ };
379
+ const envPath = resolve2(cwd, finalOptions.envFile);
380
+ const examplePath = resolve2(cwd, finalOptions.exampleFile);
381
+ if (!existsSync2(envPath)) {
382
+ log.error(`Missing .env file at ${envPath}`);
383
+ process.exit(ExitCode.FILE_NOT_FOUND);
384
+ }
385
+ if (!existsSync2(examplePath)) {
386
+ log.error(`Missing .env.example file at ${examplePath}`);
387
+ process.exit(ExitCode.FILE_NOT_FOUND);
388
+ }
389
+ const spinner = createSpinner("Scanning environment files...").start();
390
+ try {
391
+ const start = performance.now();
392
+ const envResult = await parseEnvFile(envPath);
393
+ const exampleResult = await parseEnvFile(examplePath);
394
+ if (envResult.errors.length > 0 || exampleResult.errors.length > 0) {
395
+ spinner.error({ text: "Failed to parse environment files" });
396
+ process.exit(ExitCode.ERROR);
397
+ }
398
+ const diff = diffEnvFiles(exampleResult.entries, envResult.entries, {
399
+ strict: finalOptions.strict
400
+ });
401
+ diff.sourceFile = examplePath;
402
+ diff.targetFile = envPath;
403
+ const duration = performance.now() - start;
404
+ spinner.success({ text: "Scan complete" });
405
+ const output = formatScanResult({ diff, duration }, finalOptions.format);
406
+ console.log(output);
407
+ if (diff.missing.length > 0 || diff.extra.length > 0) {
408
+ process.exit(ExitCode.SCAN_ISSUES_FOUND);
409
+ } else {
410
+ process.exit(ExitCode.SUCCESS);
411
+ }
412
+ } catch (error) {
413
+ spinner.error({ text: "An unexpected error occurred during scan" });
414
+ if (error instanceof Error) {
415
+ log.error(error.message);
416
+ }
417
+ process.exit(ExitCode.ERROR);
418
+ }
419
+ }
420
+
421
+ // src/commands/audit.ts
422
+ import { resolve as resolve3 } from "path";
423
+ import { createSpinner as createSpinner2 } from "nanospinner";
424
+
425
+ // src/core/scanner.ts
426
+ import { readdir, readFile as readFile3 } from "fs/promises";
427
+ import { join as join2 } from "path";
428
+ var severityLevels = {
429
+ critical: 4,
430
+ high: 3,
431
+ medium: 2,
432
+ low: 1
433
+ };
434
+ function globToRegex(glob) {
435
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
436
+ return new RegExp(`^${escaped}$`);
437
+ }
438
+ function matchGlobs(path, globs) {
439
+ if (globs.length === 0) return true;
440
+ return globs.some((glob) => globToRegex(glob).test(path));
441
+ }
442
+ async function walkDir(dir, includeGlobs, excludeGlobs, fileList = [], cwd = dir) {
443
+ try {
444
+ const files = await readdir(dir, { withFileTypes: true });
445
+ for (const file of files) {
446
+ const res = join2(dir, file.name);
447
+ const relPath = res.substring(cwd.length + 1).replace(/\\/g, "/");
448
+ if (file.isDirectory()) {
449
+ if (!matchGlobs(relPath, excludeGlobs)) {
450
+ await walkDir(res, includeGlobs, excludeGlobs, fileList, cwd);
451
+ }
452
+ } else {
453
+ if (matchGlobs(relPath, includeGlobs) && !matchGlobs(relPath, excludeGlobs)) {
454
+ fileList.push(res);
455
+ }
456
+ }
457
+ }
458
+ } catch (error) {
459
+ }
460
+ return fileList;
461
+ }
462
+ async function scanDirectory(options) {
463
+ const start = performance.now();
464
+ const findings = [];
465
+ let filesScanned = 0;
466
+ const targetFiles = await walkDir(options.cwd, options.include, options.exclude, [], options.cwd);
467
+ const minSevLevel = severityLevels[options.minSeverity];
468
+ for (const file of targetFiles) {
469
+ filesScanned++;
470
+ try {
471
+ const content = await readFile3(file, "utf8");
472
+ const lines = content.split(/\r?\n/);
473
+ const fileName = file.replace(/\\/g, "/").split("/").pop() || "";
474
+ const isEnvFile = fileName.endsWith(".env") || fileName.startsWith(".env");
475
+ for (let i = 0; i < lines.length; i++) {
476
+ const line = lines[i];
477
+ for (const rule of options.rules) {
478
+ if (severityLevels[rule.severity] < minSevLevel) {
479
+ continue;
480
+ }
481
+ if (isEnvFile && !rule.allowInEnvFiles) {
482
+ continue;
483
+ }
484
+ const match = rule.pattern.exec(line);
485
+ if (match) {
486
+ const matchedText = match[0];
487
+ const redacted = matchedText.length > 4 ? matchedText.substring(0, 4) + "*".repeat(matchedText.length - 4) : "****";
488
+ findings.push({
489
+ rule,
490
+ filePath: file.substring(options.cwd.length + 1),
491
+ // relative path
492
+ line: i + 1,
493
+ match: redacted,
494
+ context: line.trim().substring(0, 100)
495
+ // max 100 chars
496
+ });
497
+ }
498
+ }
499
+ }
500
+ } catch (e) {
501
+ }
502
+ }
503
+ return {
504
+ findings,
505
+ filesScanned,
506
+ rulesApplied: options.rules.length,
507
+ duration: performance.now() - start
508
+ };
509
+ }
510
+
511
+ // src/rules/default-rules.ts
512
+ var defaultRules = [
513
+ // ── AWS ──
514
+ {
515
+ id: "aws-access-key",
516
+ name: "AWS Access Key ID",
517
+ description: "Detects AWS Access Key IDs (starts with AKIA)",
518
+ pattern: /(?:^|[^A-Za-z0-9/+=])AKIA[0-9A-Z]{16}(?:[^A-Za-z0-9/+=]|$)/,
519
+ severity: "critical",
520
+ allowInEnvFiles: true
521
+ },
522
+ {
523
+ id: "aws-secret-key",
524
+ name: "AWS Secret Access Key",
525
+ description: "Detects AWS Secret Access Keys (40 char base64)",
526
+ pattern: /(?:aws_secret_access_key|aws_secret)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/i,
527
+ severity: "critical",
528
+ allowInEnvFiles: true
529
+ },
530
+ // ── API Keys (Generic) ──
531
+ {
532
+ id: "generic-api-key",
533
+ name: "Generic API Key Assignment",
534
+ description: "Detects hardcoded API key assignments in source code",
535
+ pattern: /(?:api_key|apikey|api_secret|apisecret)\s*[=:]\s*['"][A-Za-z0-9_\-]{16,}['"]/i,
536
+ severity: "high",
537
+ allowInEnvFiles: true
538
+ },
539
+ // ── Private Keys ──
540
+ {
541
+ id: "private-key",
542
+ name: "Private Key",
543
+ description: "Detects PEM-encoded private keys",
544
+ pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
545
+ severity: "critical",
546
+ allowInEnvFiles: false
547
+ },
548
+ // ── GitHub ──
549
+ {
550
+ id: "github-token",
551
+ name: "GitHub Token",
552
+ description: "Detects GitHub personal access tokens and fine-grained tokens",
553
+ pattern: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}/,
554
+ severity: "critical",
555
+ allowInEnvFiles: true
556
+ },
557
+ // ── Stripe ──
558
+ {
559
+ id: "stripe-key",
560
+ name: "Stripe API Key",
561
+ description: "Detects Stripe secret and publishable keys",
562
+ pattern: /(?:sk|pk)_(?:test|live)_[A-Za-z0-9]{24,}/,
563
+ severity: "critical",
564
+ allowInEnvFiles: true
565
+ },
566
+ // ── JWT ──
567
+ {
568
+ id: "jwt-token",
569
+ name: "JWT Token",
570
+ description: "Detects hardcoded JWT tokens",
571
+ pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/,
572
+ severity: "high",
573
+ allowInEnvFiles: true
574
+ },
575
+ // ── Database URLs ──
576
+ {
577
+ id: "database-url",
578
+ name: "Database Connection String",
579
+ description: "Detects database connection strings with credentials",
580
+ pattern: /(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\/\/[^\s'"]+:[^\s'"]+@[^\s'"]+/i,
581
+ severity: "high",
582
+ allowInEnvFiles: true
583
+ },
584
+ // ── Passwords ──
585
+ {
586
+ id: "password-assignment",
587
+ name: "Password Assignment",
588
+ description: "Detects hardcoded password assignments in source code",
589
+ pattern: /(?:password|passwd|pwd|secret)\s*[=:]\s*['"][^'"]{8,}['"]/i,
590
+ severity: "high",
591
+ allowInEnvFiles: true
592
+ },
593
+ // ── Google ──
594
+ {
595
+ id: "google-api-key",
596
+ name: "Google API Key",
597
+ description: "Detects Google API keys",
598
+ pattern: /AIza[0-9A-Za-z_-]{35}/,
599
+ severity: "high",
600
+ allowInEnvFiles: true
601
+ },
602
+ // ── Slack ──
603
+ {
604
+ id: "slack-token",
605
+ name: "Slack Token",
606
+ description: "Detects Slack bot and user tokens",
607
+ pattern: /xox[bporsca]-[0-9]{10,}-[A-Za-z0-9-]+/,
608
+ severity: "high",
609
+ allowInEnvFiles: true
610
+ },
611
+ // ── SendGrid ──
612
+ {
613
+ id: "sendgrid-key",
614
+ name: "SendGrid API Key",
615
+ description: "Detects SendGrid API keys",
616
+ pattern: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/,
617
+ severity: "high",
618
+ allowInEnvFiles: true
619
+ },
620
+ // ── Twilio ──
621
+ {
622
+ id: "twilio-key",
623
+ name: "Twilio API Key",
624
+ description: "Detects Twilio Account SID and Auth tokens",
625
+ pattern: /(?:AC[a-z0-9]{32}|SK[a-z0-9]{32})/,
626
+ severity: "high",
627
+ allowInEnvFiles: true
628
+ },
629
+ // ── npm ──
630
+ {
631
+ id: "npm-token",
632
+ name: "npm Access Token",
633
+ description: "Detects npm access tokens",
634
+ pattern: /npm_[A-Za-z0-9]{36}/,
635
+ severity: "critical",
636
+ allowInEnvFiles: true
637
+ },
638
+ // ── Discord ──
639
+ {
640
+ id: "discord-token",
641
+ name: "Discord Bot Token",
642
+ description: "Detects Discord bot tokens",
643
+ pattern: /[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27,}/,
644
+ severity: "critical",
645
+ allowInEnvFiles: true
646
+ }
647
+ ];
648
+
649
+ // src/commands/audit.ts
650
+ async function runAudit(options) {
651
+ const cwd = options.cwd || process.cwd();
652
+ const config = await loadConfig(cwd);
653
+ const finalOptions = {
654
+ include: options.include ?? config.audit?.include ?? defaults.audit.include,
655
+ exclude: options.exclude ?? config.audit?.exclude ?? defaults.audit.exclude,
656
+ severity: options.severity ?? config.audit?.severity ?? defaults.audit.severity,
657
+ format: options.format ?? config.format ?? defaults.format,
658
+ silent: options.silent ?? false,
659
+ cwd
660
+ };
661
+ const spinner = createSpinner2("Auditing codebase for secrets...").start();
662
+ try {
663
+ const report = await scanDirectory({
664
+ cwd: resolve3(cwd),
665
+ rules: defaultRules,
666
+ include: finalOptions.include,
667
+ exclude: finalOptions.exclude,
668
+ minSeverity: finalOptions.severity
669
+ });
670
+ spinner.success({ text: "Audit complete" });
671
+ const output = formatAuditReport(report, finalOptions.format);
672
+ console.log(output);
673
+ if (report.findings.length > 0) {
674
+ process.exit(ExitCode.AUDIT_SECRETS_FOUND);
675
+ } else {
676
+ process.exit(ExitCode.SUCCESS);
677
+ }
678
+ } catch (error) {
679
+ spinner.error({ text: "An unexpected error occurred during audit" });
680
+ if (error instanceof Error) {
681
+ log.error(error.message);
682
+ }
683
+ process.exit(ExitCode.ERROR);
684
+ }
685
+ }
686
+
687
+ // src/commands/sync.ts
688
+ async function runSync() {
689
+ log.info(`Sync feature is coming in ${c.bold("v1.0")}!`);
690
+ log.dim("This will allow you to share encrypted .env files securely with your team.");
691
+ }
692
+
693
+ // src/cli.ts
694
+ var program = new Command();
695
+ program.name("envspect").description("Zero-config .env validation, secret leak detection, and team sync CLI").version("0.1.0").option("-f, --format <type>", "Output format (table, json, minimal)", "table").option("-s, --silent", "Suppress non-essential output", false).option("--cwd <path>", "Working directory", process.cwd());
696
+ program.command("scan").description("Compare .env against .env.example").option("--env <file>", "Path to .env file", ".env").option("--example <file>", "Path to .env.example file", ".env.example").option("--strict", "Enable strict mode (fails on extra variables in .env)", false).action(async (options) => {
697
+ const globalOpts = program.opts();
698
+ await runScan({
699
+ ...globalOpts,
700
+ envFile: options.env,
701
+ exampleFile: options.example,
702
+ strict: options.strict,
703
+ format: globalOpts.format
704
+ });
705
+ });
706
+ program.command("audit").description("Scan codebase for hardcoded secrets").option("--include <globs...>", "Glob patterns to include").option("--exclude <globs...>", "Glob patterns to exclude").option("--severity <level>", "Minimum severity level to report", "low").action(async (options) => {
707
+ const globalOpts = program.opts();
708
+ await runAudit({
709
+ ...globalOpts,
710
+ ...options,
711
+ format: globalOpts.format
712
+ });
713
+ });
714
+ program.command("sync").description("Encrypted .env sync for teams (coming soon)").action(async () => {
715
+ await runSync();
716
+ });
717
+ if (process.argv.length <= 2) {
718
+ program.help();
719
+ }
720
+ program.parse();
721
+ //# sourceMappingURL=cli.js.map