praxis-sec 1.2.0 → 1.2.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.
Files changed (49) hide show
  1. package/cli/agents/abom-generator.js +1 -1
  2. package/cli/agents/agent-attestation-agent.js +10 -1
  3. package/cli/agents/agent-config-scanner.js +1 -1
  4. package/cli/agents/ai-infra-inventory-agent.js +482 -482
  5. package/cli/agents/base-agent.js +1 -1
  6. package/cli/agents/endpoint-agent-abuse-agent.js +1 -1
  7. package/cli/agents/html-reporter.js +3 -3
  8. package/cli/agents/index.js +2 -2
  9. package/cli/agents/injection-tester.js +8 -1
  10. package/cli/agents/memory-poisoning-agent.js +1 -1
  11. package/cli/agents/model-file-scanner.js +1 -1
  12. package/cli/agents/orchestrator.js +11 -6
  13. package/cli/agents/prompt-injection-prober.js +228 -228
  14. package/cli/bin/praxis.js +7 -3
  15. package/cli/commands/agent-fix.js +1091 -1245
  16. package/cli/commands/audit.js +1228 -1216
  17. package/cli/commands/baseline.js +1 -1
  18. package/cli/commands/benchmark.js +1 -1
  19. package/cli/commands/ci.js +45 -21
  20. package/cli/commands/deps.js +11 -5
  21. package/cli/commands/env-audit.js +1 -1
  22. package/cli/commands/fix.js +1 -1
  23. package/cli/commands/mcp.js +1 -1
  24. package/cli/commands/red-team.js +350 -350
  25. package/cli/commands/remediate.js +1 -1
  26. package/cli/commands/rotate.js +1 -1
  27. package/cli/commands/rules.js +1 -1
  28. package/cli/commands/scan.js +554 -554
  29. package/cli/commands/score.js +1 -1
  30. package/cli/commands/undo.js +22 -77
  31. package/cli/commands/vibe-check.js +1 -1
  32. package/cli/core/fix-plan.js +274 -0
  33. package/cli/core/fs.js +27 -0
  34. package/cli/core/git-clone.js +8 -6
  35. package/cli/core/glob.js +56 -0
  36. package/cli/core/output/html-theme.js +158 -158
  37. package/cli/core/output/sarif.js +2 -2
  38. package/cli/core/web/jobs.js +2 -2
  39. package/cli/core/web/server.js +19 -8
  40. package/cli/data/threatpacks/latest.json +41 -41
  41. package/cli/integrations/github-action.js +136 -0
  42. package/cli/utils/plugin-loader.js +15 -95
  43. package/cli/utils/rule-import.js +227 -227
  44. package/cli/utils/rule-registry.js +425 -425
  45. package/cli/utils/scan-fingerprint.js +1 -1
  46. package/cli/utils/score-history.js +118 -118
  47. package/docs/USAGE.md +16 -9
  48. package/docs/design/WEB-UI.md +4 -5
  49. package/package.json +13 -4
@@ -1,554 +1,554 @@
1
- /**
2
- * Scan Command
3
- * ============
4
- *
5
- * Scans a directory for leaked secrets using pattern matching + entropy scoring.
6
- *
7
- * USAGE:
8
- * praxis scan [path] Scan specified path (default: current directory)
9
- * praxis scan . -v Verbose mode (show files being scanned)
10
- * praxis scan . --json Output as JSON (for CI integration)
11
- * praxis scan . --include-tests Also scan test files (excluded by default)
12
- *
13
- * SUPPRESSING FALSE POSITIVES:
14
- * Add # praxis-ignore as a comment on the same line to suppress a finding.
15
- * Create a .praxisignore file (same syntax as .gitignore) to exclude paths.
16
- *
17
- * EXIT CODES:
18
- * 0 - No secrets found
19
- * 1 - Secrets found (or error)
20
- */
21
-
22
- import fs from 'fs';
23
- import path from 'path';
24
- import { renderFindingsSARIF } from '../core/output/sarif.js';
25
- import fg from 'fast-glob';
26
- import ora from 'ora';
27
- import chalk from 'chalk';
28
- import {
29
- SECRET_PATTERNS,
30
- SECURITY_PATTERNS,
31
- SKIP_DIRS,
32
- SKIP_EXTENSIONS,
33
- SKIP_FILENAMES,
34
- TEST_FILE_PATTERNS,
35
- MAX_FILE_SIZE,
36
- loadGitignorePatterns
37
- } from '../utils/patterns.js';
38
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
39
- import * as output from '../utils/output.js';
40
- import { CacheManager } from '../utils/cache-manager.js';
41
- import { isGitUrl, cloneGitRepo } from '../core/git-clone.js';
42
-
43
- // =============================================================================
44
- // CUSTOM PATTERNS (.praxis.json)
45
- // =============================================================================
46
-
47
- /**
48
- * Load custom patterns from .praxis.json in the project root.
49
- *
50
- * Format:
51
- * {
52
- * "patterns": [
53
- * {
54
- * "name": "My Internal Key",
55
- * "pattern": "MYAPP_[A-Z0-9]{32}",
56
- * "severity": "high",
57
- * "description": "Internal API key for myapp services."
58
- * }
59
- * ]
60
- * }
61
- */
62
- function loadCustomPatterns(rootPath) {
63
- const configPath = path.join(rootPath, '.praxis.json');
64
- if (!fs.existsSync(configPath)) return [];
65
-
66
- try {
67
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
68
- if (!Array.isArray(config.patterns)) return [];
69
-
70
- return config.patterns
71
- .filter(p => p.name && p.pattern)
72
- .map(p => ({
73
- name: `[custom] ${p.name}`,
74
- pattern: new RegExp(p.pattern, 'g'),
75
- severity: p.severity || 'high',
76
- description: p.description || `Custom pattern: ${p.name}`,
77
- custom: true,
78
- }));
79
- } catch (err) {
80
- output.warning(`.praxis.json parse error: ${err.message}`);
81
- return [];
82
- }
83
- }
84
-
85
- // =============================================================================
86
- // MAIN SCAN FUNCTION
87
- // =============================================================================
88
-
89
- export async function scanCommand(targetPath = '.', options = {}) {
90
- let gitClone = null;
91
- let effectivePath = targetPath;
92
-
93
- if (isGitUrl(targetPath)) {
94
- const gitSpinner = ora({ text: chalk.cyan(`Cloning remote Git repository: ${targetPath}...`), color: 'cyan' }).start();
95
- try {
96
- gitClone = cloneGitRepo(targetPath, {
97
- branch: options.branch,
98
- depth: options.depth,
99
- gitToken: options.gitToken,
100
- submodules: options.submodules,
101
- });
102
- effectivePath = gitClone.tempDir;
103
- gitSpinner.succeed(chalk.green(`Cloned ${gitClone.repoName} (${gitClone.displayUrl})`));
104
- } catch (err) {
105
- gitSpinner.fail(chalk.red(err.message));
106
- process.exit(1);
107
- }
108
- }
109
-
110
- const cleanup = () => {
111
- if (gitClone && !options.keepClone) {
112
- gitClone.cleanup();
113
- }
114
- };
115
-
116
- const absolutePath = path.resolve(effectivePath);
117
-
118
- // Validate path exists
119
- if (!fs.existsSync(absolutePath)) {
120
- cleanup();
121
- output.error(`Path does not exist: ${absolutePath}`);
122
- process.exit(1);
123
- }
124
-
125
- // Load .praxisignore patterns
126
- const ignorePatterns = loadIgnoreFile(absolutePath);
127
-
128
- // Load custom patterns from .praxis.json
129
- const customPatterns = loadCustomPatterns(absolutePath);
130
- const allPatterns = [...SECRET_PATTERNS, ...SECURITY_PATTERNS, ...customPatterns];
131
-
132
- if (customPatterns.length > 0 && options.verbose) {
133
- output.info(`Loaded ${customPatterns.length} custom pattern(s) from .praxis.json`);
134
- }
135
-
136
- // Start spinner
137
- const spinner = ora({
138
- text: 'Scanning for secrets and vulnerabilities...',
139
- color: 'cyan'
140
- }).start();
141
-
142
- try {
143
- // Find all files
144
- const files = await findFiles(absolutePath, ignorePatterns, options);
145
-
146
- // Cache: determine which files changed
147
- const useCache = options.cache !== false;
148
- const cache = new CacheManager(absolutePath);
149
- const cacheData = useCache ? cache.load() : null;
150
- let filesToScan = files;
151
- let cacheDiff = null;
152
- const cachedResults = [];
153
-
154
- if (cacheData) {
155
- cacheDiff = cache.diff(files);
156
- filesToScan = cacheDiff.changedFiles;
157
-
158
- // Group cached findings by file
159
- const cachedByFile = {};
160
- for (const f of cacheDiff.cachedFindings) {
161
- if (!cachedByFile[f.file]) cachedByFile[f.file] = [];
162
- cachedByFile[f.file].push({
163
- line: f.line,
164
- column: f.column,
165
- matched: f.matched,
166
- patternName: f.rule || f.title,
167
- severity: f.severity,
168
- confidence: f.confidence,
169
- description: f.description,
170
- category: f.category,
171
- });
172
- }
173
- for (const [file, findings] of Object.entries(cachedByFile)) {
174
- cachedResults.push({ file, findings });
175
- }
176
- }
177
-
178
- const cacheNote = cacheDiff && filesToScan.length < files.length
179
- ? ` (${filesToScan.length} changed, ${cacheDiff.unchangedCount} cached)`
180
- : '';
181
- spinner.text = `Scanning ${filesToScan.length} files${cacheNote}...`;
182
-
183
- // Scan each file
184
- const results = [];
185
- let scannedCount = 0;
186
-
187
- for (const file of filesToScan) {
188
- const findings = await scanFile(file, allPatterns);
189
- if (findings.length > 0) {
190
- results.push({ file, findings });
191
- }
192
-
193
- scannedCount++;
194
- if (options.verbose) {
195
- spinner.text = `Scanned ${scannedCount}/${filesToScan.length}: ${path.relative(absolutePath, file)}`;
196
- }
197
- }
198
-
199
- // Merge with cached results
200
- const allResults = [...results, ...cachedResults];
201
-
202
- // Save cache
203
- if (useCache) {
204
- try {
205
- const allFindings = [];
206
- for (const { file, findings } of allResults) {
207
- for (const f of findings) {
208
- allFindings.push({
209
- file,
210
- line: f.line,
211
- column: f.column,
212
- severity: f.severity,
213
- category: f.category || 'secrets',
214
- rule: f.patternName,
215
- title: f.patternName,
216
- description: f.description,
217
- matched: f.matched,
218
- confidence: f.confidence,
219
- });
220
- }
221
- }
222
- cache.save(files, allFindings, null, null);
223
- } catch {
224
- // Silent
225
- }
226
- }
227
-
228
- spinner.stop();
229
-
230
- // Output results
231
- if (options.sarif) {
232
- console.log(renderSARIF(allResults, absolutePath));
233
- } else if (options.json) {
234
- outputJSON(allResults, files.length);
235
- } else {
236
- outputPretty(allResults, files.length, absolutePath);
237
- }
238
-
239
- // Exit with appropriate code
240
- cleanup();
241
- const hasFindings = allResults.length > 0;
242
- process.exit(hasFindings ? 1 : 0);
243
-
244
- } catch (err) {
245
- cleanup();
246
- spinner.fail('Scan failed');
247
- output.error(err.message);
248
- process.exit(1);
249
- }
250
- }
251
-
252
- // =============================================================================
253
- // .PRAXISIGNORE LOADING
254
- // =============================================================================
255
-
256
- /**
257
- * Load ignore patterns from .praxisignore file.
258
- * Same syntax as .gitignore — glob patterns, one per line, # for comments.
259
- */
260
- function loadIgnoreFile(rootPath) {
261
- const ignorePath = path.join(rootPath, '.praxisignore');
262
-
263
- if (!fs.existsSync(ignorePath)) return [];
264
-
265
- try {
266
- return fs.readFileSync(ignorePath, 'utf-8')
267
- .split('\n')
268
- .map(line => line.trim())
269
- .filter(line => line && !line.startsWith('#'));
270
- } catch {
271
- return [];
272
- }
273
- }
274
-
275
- /**
276
- * Check if a file path matches any ignore pattern.
277
- * Supports: exact paths, glob patterns, and directory prefixes.
278
- */
279
- function isIgnoredByFile(filePath, rootPath, ignorePatterns) {
280
- if (ignorePatterns.length === 0) return false;
281
-
282
- const relPath = path.relative(rootPath, filePath).replace(/\\/g, '/');
283
-
284
- return ignorePatterns.some(pattern => {
285
- // Directory prefix match: "tests/" ignores everything under tests/
286
- if (pattern.endsWith('/')) {
287
- return relPath.startsWith(pattern) || relPath.includes('/' + pattern);
288
- }
289
- // Simple glob: "**/fixtures/**" or "src/secrets.js"
290
- const escaped = pattern
291
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
292
- .replace(/\*/g, '[^/]*')
293
- .replace(/\?/g, '[^/]');
294
- return new RegExp(`(^|/)${escaped}($|/)`).test(relPath);
295
- });
296
- }
297
-
298
- // =============================================================================
299
- // FILE DISCOVERY
300
- // =============================================================================
301
-
302
- async function findFiles(rootPath, ignorePatterns, options = {}) {
303
- // Build ignore patterns from SKIP_DIRS
304
- const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
305
-
306
- // Respect .gitignore patterns
307
- const gitignoreGlobs = loadGitignorePatterns(rootPath);
308
- globIgnore.push(...gitignoreGlobs);
309
-
310
- // Find all files
311
- const files = await fg('**/*', {
312
- cwd: rootPath,
313
- absolute: true,
314
- onlyFiles: true,
315
- ignore: globIgnore,
316
- dot: true
317
- });
318
-
319
- const filtered = [];
320
-
321
- for (const file of files) {
322
- // Skip by extension
323
- const ext = path.extname(file).toLowerCase();
324
- if (SKIP_EXTENSIONS.has(ext)) continue;
325
- if (SKIP_FILENAMES.has(path.basename(file))) continue;
326
-
327
- // Handle compound extensions like .min.js
328
- const basename = path.basename(file);
329
- if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
330
-
331
- // Skip test files by default (--include-tests to override)
332
- if (!options.includeTests && isTestFile(file)) continue;
333
-
334
- // Skip files matching .praxisignore
335
- if (isIgnoredByFile(file, rootPath, ignorePatterns)) continue;
336
-
337
- // Skip by size
338
- try {
339
- const stats = fs.statSync(file);
340
- if (stats.size > MAX_FILE_SIZE) continue;
341
- } catch {
342
- continue;
343
- }
344
-
345
- filtered.push(file);
346
- }
347
-
348
- return filtered;
349
- }
350
-
351
- function isTestFile(filePath) {
352
- return TEST_FILE_PATTERNS.some(pattern => pattern.test(filePath));
353
- }
354
-
355
- // =============================================================================
356
- // FILE SCANNING
357
- // =============================================================================
358
-
359
- async function scanFile(filePath, patterns = SECRET_PATTERNS) {
360
- const findings = [];
361
-
362
- try {
363
- const content = fs.readFileSync(filePath, 'utf-8');
364
- const lines = content.split('\n');
365
-
366
- for (let lineNum = 0; lineNum < lines.length; lineNum++) {
367
- const line = lines[lineNum];
368
-
369
- // Inline suppression: # praxis-ignore on the same line
370
- if (/praxis-ignore/i.test(line)) continue;
371
-
372
- for (const pattern of patterns) {
373
- // Reset regex state (important for global regexes)
374
- pattern.pattern.lastIndex = 0;
375
-
376
- let match;
377
- while ((match = pattern.pattern.exec(line)) !== null) {
378
- // For generic patterns, apply entropy check to filter placeholders
379
- if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) {
380
- continue;
381
- }
382
-
383
- const confidence = getConfidence(pattern, match[0]);
384
-
385
- findings.push({
386
- line: lineNum + 1,
387
- column: match.index + 1,
388
- matched: match[0],
389
- patternName: pattern.name,
390
- severity: pattern.severity,
391
- confidence,
392
- description: pattern.description,
393
- category: pattern.category || 'secret'
394
- });
395
- }
396
- }
397
- }
398
- } catch {
399
- // Skip files that can't be read (binary, permissions, etc.)
400
- }
401
-
402
- // Deduplicate: multiple patterns can match the same secret on the same line
403
- // (e.g. Stripe and Clerk both match sk_live_...). Keep one finding per
404
- // unique (line, matched-text) pair — first match wins (patterns are ordered
405
- // by severity: critical → high → medium).
406
- const seen = new Set();
407
- return findings.filter(f => {
408
- const key = `${f.line}:${f.matched}`;
409
- if (seen.has(key)) return false;
410
- seen.add(key);
411
- return true;
412
- });
413
- }
414
-
415
- // =============================================================================
416
- // OUTPUT FORMATTING
417
- // =============================================================================
418
-
419
- function outputPretty(results, filesScanned, rootPath) {
420
- // Separate findings into secrets and code vulnerabilities
421
- const secretResults = [];
422
- const vulnResults = [];
423
-
424
- for (const { file, findings } of results) {
425
- const secrets = findings.filter(f => f.category !== 'vulnerability');
426
- const vulns = findings.filter(f => f.category === 'vulnerability');
427
- if (secrets.length > 0) secretResults.push({ file, findings: secrets });
428
- if (vulns.length > 0) vulnResults.push({ file, findings: vulns });
429
- }
430
-
431
- const stats = {
432
- total: 0,
433
- critical: 0,
434
- high: 0,
435
- medium: 0,
436
- secretsTotal: 0,
437
- vulnsTotal: 0,
438
- filesScanned
439
- };
440
-
441
- for (const { findings } of results) {
442
- for (const f of findings) {
443
- stats.total++;
444
- stats[f.severity] = (stats[f.severity] || 0) + 1;
445
- if (f.category === 'vulnerability') stats.vulnsTotal++;
446
- else stats.secretsTotal++;
447
- }
448
- }
449
-
450
- output.header('Scan Results');
451
-
452
- if (results.length === 0) {
453
- output.success('No secrets or vulnerabilities detected in your codebase!');
454
- console.log();
455
- console.log(chalk.gray('Note: Uses pattern matching + entropy scoring. Test files excluded by default.'));
456
- console.log(chalk.gray('Tip: Run with --include-tests to also scan test files.'));
457
- console.log(chalk.gray('Tip: Add a .praxisignore file to exclude paths.'));
458
- } else {
459
- // ── Secrets section ────────────────────────────────────────────────────
460
- if (secretResults.length > 0) {
461
- console.log();
462
- console.log(chalk.red.bold(` Secrets (${stats.secretsTotal})`));
463
- console.log(chalk.red(' ' + '─'.repeat(58)));
464
-
465
- for (const { file, findings } of secretResults) {
466
- const relPath = path.relative(rootPath, file);
467
- for (const f of findings) {
468
- output.finding(relPath, f.line, f.patternName, f.severity, f.matched, f.description, f.confidence);
469
- }
470
- }
471
- }
472
-
473
- // ── Code Vulnerabilities section ───────────────────────────────────────
474
- if (vulnResults.length > 0) {
475
- console.log();
476
- console.log(chalk.yellow.bold(` Code Vulnerabilities (${stats.vulnsTotal})`));
477
- console.log(chalk.yellow(' ' + '─'.repeat(58)));
478
-
479
- for (const { file, findings } of vulnResults) {
480
- const relPath = path.relative(rootPath, file);
481
- for (const f of findings) {
482
- output.vulnerabilityFinding(relPath, f.line, f.patternName, f.severity, f.matched, f.description);
483
- }
484
- }
485
- }
486
-
487
- // Remind about suppressions
488
- console.log();
489
- console.log(chalk.gray('Suppress a finding: add # praxis-ignore as a comment on that line'));
490
- console.log(chalk.gray('Exclude a path: add it to .praxisignore'));
491
- console.log();
492
- console.log(chalk.gray('Track findings over time: ') + chalk.cyan(''));
493
-
494
- if (secretResults.length > 0) output.recommendations();
495
- if (vulnResults.length > 0) output.vulnRecommendations();
496
- }
497
-
498
- output.summary(stats);
499
- }
500
-
501
- function outputJSON(results, filesScanned) {
502
- const jsonOutput = {
503
- success: results.length === 0,
504
- filesScanned,
505
- totalFindings: 0,
506
- findings: []
507
- };
508
-
509
- for (const { file, findings } of results) {
510
- for (const f of findings) {
511
- jsonOutput.totalFindings++;
512
- jsonOutput.findings.push({
513
- file,
514
- line: f.line,
515
- column: f.column,
516
- category: f.category || 'secret',
517
- severity: f.severity,
518
- confidence: f.confidence,
519
- type: f.patternName,
520
- matched: f.category === 'vulnerability' ? f.matched : output.maskSecret(f.matched),
521
- description: f.description
522
- });
523
- }
524
- }
525
-
526
- console.log(JSON.stringify(jsonOutput, null, 2));
527
- }
528
-
529
- // =============================================================================
530
- // SARIF OUTPUT (GitHub Code Scanning compatible)
531
- // =============================================================================
532
-
533
- /**
534
- * Output findings in SARIF 2.1.0 format.
535
- * Feed this into GitHub's Security tab:
536
- * npx praxis-sec scan . --sarif > results.sarif
537
- *
538
- * Then upload via:
539
- * github/codeql-action/upload-sarif@v3
540
- */
541
- /**
542
- * SARIF output for GitHub Code Scanning.
543
- *
544
- * Delegates to the shared serializer in `cli/core/output/sarif.js` (P-IMP-062); this used
545
- * to be a private copy. That copy also disagreed with itself — it gave a `low` finding
546
- * rule-level `note` but result-level `warning` — and hardcoded the SARIF spec version
547
- * `2.1.0` as the tool driver version.
548
- *
549
- * `allResults` arrives as `[{ file, findings }]`, so findings are flattened with their
550
- * file attached before serializing. `--sarif` writes to stdout; redirect it.
551
- */
552
- function renderSARIF(allResults, rootPath) {
553
- return renderFindingsSARIF(allResults, { rootPath });
554
- }
1
+ /**
2
+ * Scan Command
3
+ * ============
4
+ *
5
+ * Scans a directory for leaked secrets using pattern matching + entropy scoring.
6
+ *
7
+ * USAGE:
8
+ * praxis scan [path] Scan specified path (default: current directory)
9
+ * praxis scan . -v Verbose mode (show files being scanned)
10
+ * praxis scan . --json Output as JSON (for CI integration)
11
+ * praxis scan . --include-tests Also scan test files (excluded by default)
12
+ *
13
+ * SUPPRESSING FALSE POSITIVES:
14
+ * Add # praxis-ignore as a comment on the same line to suppress a finding.
15
+ * Create a .praxisignore file (same syntax as .gitignore) to exclude paths.
16
+ *
17
+ * EXIT CODES:
18
+ * 0 - No secrets found
19
+ * 1 - Secrets found (or error)
20
+ */
21
+
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import { renderFindingsSARIF } from '../core/output/sarif.js';
25
+ import fg from '../core/glob.js';
26
+ import ora from 'ora';
27
+ import chalk from 'chalk';
28
+ import {
29
+ SECRET_PATTERNS,
30
+ SECURITY_PATTERNS,
31
+ SKIP_DIRS,
32
+ SKIP_EXTENSIONS,
33
+ SKIP_FILENAMES,
34
+ TEST_FILE_PATTERNS,
35
+ MAX_FILE_SIZE,
36
+ loadGitignorePatterns
37
+ } from '../utils/patterns.js';
38
+ import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
39
+ import * as output from '../utils/output.js';
40
+ import { CacheManager } from '../utils/cache-manager.js';
41
+ import { isGitUrl, cloneGitRepo } from '../core/git-clone.js';
42
+
43
+ // =============================================================================
44
+ // CUSTOM PATTERNS (.praxis.json)
45
+ // =============================================================================
46
+
47
+ /**
48
+ * Load custom patterns from .praxis.json in the project root.
49
+ *
50
+ * Format:
51
+ * {
52
+ * "patterns": [
53
+ * {
54
+ * "name": "My Internal Key",
55
+ * "pattern": "MYAPP_[A-Z0-9]{32}",
56
+ * "severity": "high",
57
+ * "description": "Internal API key for myapp services."
58
+ * }
59
+ * ]
60
+ * }
61
+ */
62
+ function loadCustomPatterns(rootPath) {
63
+ const configPath = path.join(rootPath, '.praxis.json');
64
+ if (!fs.existsSync(configPath)) return [];
65
+
66
+ try {
67
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
68
+ if (!Array.isArray(config.patterns)) return [];
69
+
70
+ return config.patterns
71
+ .filter(p => p.name && p.pattern)
72
+ .map(p => ({
73
+ name: `[custom] ${p.name}`,
74
+ pattern: new RegExp(p.pattern, 'g'),
75
+ severity: p.severity || 'high',
76
+ description: p.description || `Custom pattern: ${p.name}`,
77
+ custom: true,
78
+ }));
79
+ } catch (err) {
80
+ output.warning(`.praxis.json parse error: ${err.message}`);
81
+ return [];
82
+ }
83
+ }
84
+
85
+ // =============================================================================
86
+ // MAIN SCAN FUNCTION
87
+ // =============================================================================
88
+
89
+ export async function scanCommand(targetPath = '.', options = {}) {
90
+ let gitClone = null;
91
+ let effectivePath = targetPath;
92
+
93
+ if (isGitUrl(targetPath)) {
94
+ const gitSpinner = ora({ text: chalk.cyan(`Cloning remote Git repository: ${targetPath}...`), color: 'cyan' }).start();
95
+ try {
96
+ gitClone = cloneGitRepo(targetPath, {
97
+ branch: options.branch,
98
+ depth: options.depth,
99
+ gitToken: options.gitToken,
100
+ submodules: options.submodules,
101
+ });
102
+ effectivePath = gitClone.tempDir;
103
+ gitSpinner.succeed(chalk.green(`Cloned ${gitClone.repoName} (${gitClone.displayUrl})`));
104
+ } catch (err) {
105
+ gitSpinner.fail(chalk.red(err.message));
106
+ process.exit(1);
107
+ }
108
+ }
109
+
110
+ const cleanup = () => {
111
+ if (gitClone && !options.keepClone) {
112
+ gitClone.cleanup();
113
+ }
114
+ };
115
+
116
+ const absolutePath = path.resolve(effectivePath);
117
+
118
+ // Validate path exists
119
+ if (!fs.existsSync(absolutePath)) {
120
+ cleanup();
121
+ output.error(`Path does not exist: ${absolutePath}`);
122
+ process.exit(1);
123
+ }
124
+
125
+ // Load .praxisignore patterns
126
+ const ignorePatterns = loadIgnoreFile(absolutePath);
127
+
128
+ // Load custom patterns from .praxis.json
129
+ const customPatterns = loadCustomPatterns(absolutePath);
130
+ const allPatterns = [...SECRET_PATTERNS, ...SECURITY_PATTERNS, ...customPatterns];
131
+
132
+ if (customPatterns.length > 0 && options.verbose) {
133
+ output.info(`Loaded ${customPatterns.length} custom pattern(s) from .praxis.json`);
134
+ }
135
+
136
+ // Start spinner
137
+ const spinner = ora({
138
+ text: 'Scanning for secrets and vulnerabilities...',
139
+ color: 'cyan'
140
+ }).start();
141
+
142
+ try {
143
+ // Find all files
144
+ const files = await findFiles(absolutePath, ignorePatterns, options);
145
+
146
+ // Cache: determine which files changed
147
+ const useCache = options.cache !== false;
148
+ const cache = new CacheManager(absolutePath);
149
+ const cacheData = useCache ? cache.load() : null;
150
+ let filesToScan = files;
151
+ let cacheDiff = null;
152
+ const cachedResults = [];
153
+
154
+ if (cacheData) {
155
+ cacheDiff = cache.diff(files);
156
+ filesToScan = cacheDiff.changedFiles;
157
+
158
+ // Group cached findings by file
159
+ const cachedByFile = {};
160
+ for (const f of cacheDiff.cachedFindings) {
161
+ if (!cachedByFile[f.file]) cachedByFile[f.file] = [];
162
+ cachedByFile[f.file].push({
163
+ line: f.line,
164
+ column: f.column,
165
+ matched: f.matched,
166
+ patternName: f.rule || f.title,
167
+ severity: f.severity,
168
+ confidence: f.confidence,
169
+ description: f.description,
170
+ category: f.category,
171
+ });
172
+ }
173
+ for (const [file, findings] of Object.entries(cachedByFile)) {
174
+ cachedResults.push({ file, findings });
175
+ }
176
+ }
177
+
178
+ const cacheNote = cacheDiff && filesToScan.length < files.length
179
+ ? ` (${filesToScan.length} changed, ${cacheDiff.unchangedCount} cached)`
180
+ : '';
181
+ spinner.text = `Scanning ${filesToScan.length} files${cacheNote}...`;
182
+
183
+ // Scan each file
184
+ const results = [];
185
+ let scannedCount = 0;
186
+
187
+ for (const file of filesToScan) {
188
+ const findings = await scanFile(file, allPatterns);
189
+ if (findings.length > 0) {
190
+ results.push({ file, findings });
191
+ }
192
+
193
+ scannedCount++;
194
+ if (options.verbose) {
195
+ spinner.text = `Scanned ${scannedCount}/${filesToScan.length}: ${path.relative(absolutePath, file)}`;
196
+ }
197
+ }
198
+
199
+ // Merge with cached results
200
+ const allResults = [...results, ...cachedResults];
201
+
202
+ // Save cache
203
+ if (useCache) {
204
+ try {
205
+ const allFindings = [];
206
+ for (const { file, findings } of allResults) {
207
+ for (const f of findings) {
208
+ allFindings.push({
209
+ file,
210
+ line: f.line,
211
+ column: f.column,
212
+ severity: f.severity,
213
+ category: f.category || 'secrets',
214
+ rule: f.patternName,
215
+ title: f.patternName,
216
+ description: f.description,
217
+ matched: f.matched,
218
+ confidence: f.confidence,
219
+ });
220
+ }
221
+ }
222
+ cache.save(files, allFindings, null, null);
223
+ } catch {
224
+ // Silent
225
+ }
226
+ }
227
+
228
+ spinner.stop();
229
+
230
+ // Output results
231
+ if (options.sarif) {
232
+ console.log(renderSARIF(allResults, absolutePath));
233
+ } else if (options.json) {
234
+ outputJSON(allResults, files.length);
235
+ } else {
236
+ outputPretty(allResults, files.length, absolutePath);
237
+ }
238
+
239
+ // Exit with appropriate code
240
+ cleanup();
241
+ const hasFindings = allResults.length > 0;
242
+ process.exit(hasFindings ? 1 : 0);
243
+
244
+ } catch (err) {
245
+ cleanup();
246
+ spinner.fail('Scan failed');
247
+ output.error(err.message);
248
+ process.exit(1);
249
+ }
250
+ }
251
+
252
+ // =============================================================================
253
+ // .PRAXISIGNORE LOADING
254
+ // =============================================================================
255
+
256
+ /**
257
+ * Load ignore patterns from .praxisignore file.
258
+ * Same syntax as .gitignore — glob patterns, one per line, # for comments.
259
+ */
260
+ function loadIgnoreFile(rootPath) {
261
+ const ignorePath = path.join(rootPath, '.praxisignore');
262
+
263
+ if (!fs.existsSync(ignorePath)) return [];
264
+
265
+ try {
266
+ return fs.readFileSync(ignorePath, 'utf-8')
267
+ .split('\n')
268
+ .map(line => line.trim())
269
+ .filter(line => line && !line.startsWith('#'));
270
+ } catch {
271
+ return [];
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Check if a file path matches any ignore pattern.
277
+ * Supports: exact paths, glob patterns, and directory prefixes.
278
+ */
279
+ function isIgnoredByFile(filePath, rootPath, ignorePatterns) {
280
+ if (ignorePatterns.length === 0) return false;
281
+
282
+ const relPath = path.relative(rootPath, filePath).replace(/\\/g, '/');
283
+
284
+ return ignorePatterns.some(pattern => {
285
+ // Directory prefix match: "tests/" ignores everything under tests/
286
+ if (pattern.endsWith('/')) {
287
+ return relPath.startsWith(pattern) || relPath.includes('/' + pattern);
288
+ }
289
+ // Simple glob: "**/fixtures/**" or "src/secrets.js"
290
+ const escaped = pattern
291
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
292
+ .replace(/\*/g, '[^/]*')
293
+ .replace(/\?/g, '[^/]');
294
+ return new RegExp(`(^|/)${escaped}($|/)`).test(relPath);
295
+ });
296
+ }
297
+
298
+ // =============================================================================
299
+ // FILE DISCOVERY
300
+ // =============================================================================
301
+
302
+ async function findFiles(rootPath, ignorePatterns, options = {}) {
303
+ // Build ignore patterns from SKIP_DIRS
304
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
305
+
306
+ // Respect .gitignore patterns
307
+ const gitignoreGlobs = loadGitignorePatterns(rootPath);
308
+ globIgnore.push(...gitignoreGlobs);
309
+
310
+ // Find all files
311
+ const files = await fg('**/*', {
312
+ cwd: rootPath,
313
+ absolute: true,
314
+ onlyFiles: true,
315
+ ignore: globIgnore,
316
+ dot: true
317
+ });
318
+
319
+ const filtered = [];
320
+
321
+ for (const file of files) {
322
+ // Skip by extension
323
+ const ext = path.extname(file).toLowerCase();
324
+ if (SKIP_EXTENSIONS.has(ext)) continue;
325
+ if (SKIP_FILENAMES.has(path.basename(file))) continue;
326
+
327
+ // Handle compound extensions like .min.js
328
+ const basename = path.basename(file);
329
+ if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
330
+
331
+ // Skip test files by default (--include-tests to override)
332
+ if (!options.includeTests && isTestFile(file)) continue;
333
+
334
+ // Skip files matching .praxisignore
335
+ if (isIgnoredByFile(file, rootPath, ignorePatterns)) continue;
336
+
337
+ // Skip by size
338
+ try {
339
+ const stats = fs.statSync(file);
340
+ if (stats.size > MAX_FILE_SIZE) continue;
341
+ } catch {
342
+ continue;
343
+ }
344
+
345
+ filtered.push(file);
346
+ }
347
+
348
+ return filtered;
349
+ }
350
+
351
+ function isTestFile(filePath) {
352
+ return TEST_FILE_PATTERNS.some(pattern => pattern.test(filePath));
353
+ }
354
+
355
+ // =============================================================================
356
+ // FILE SCANNING
357
+ // =============================================================================
358
+
359
+ async function scanFile(filePath, patterns = SECRET_PATTERNS) {
360
+ const findings = [];
361
+
362
+ try {
363
+ const content = fs.readFileSync(filePath, 'utf-8');
364
+ const lines = content.split('\n');
365
+
366
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
367
+ const line = lines[lineNum];
368
+
369
+ // Inline suppression: # praxis-ignore on the same line
370
+ if (/praxis-ignore/i.test(line)) continue;
371
+
372
+ for (const pattern of patterns) {
373
+ // Reset regex state (important for global regexes)
374
+ pattern.pattern.lastIndex = 0;
375
+
376
+ let match;
377
+ while ((match = pattern.pattern.exec(line)) !== null) {
378
+ // For generic patterns, apply entropy check to filter placeholders
379
+ if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) {
380
+ continue;
381
+ }
382
+
383
+ const confidence = getConfidence(pattern, match[0]);
384
+
385
+ findings.push({
386
+ line: lineNum + 1,
387
+ column: match.index + 1,
388
+ matched: match[0],
389
+ patternName: pattern.name,
390
+ severity: pattern.severity,
391
+ confidence,
392
+ description: pattern.description,
393
+ category: pattern.category || 'secret'
394
+ });
395
+ }
396
+ }
397
+ }
398
+ } catch {
399
+ // Skip files that can't be read (binary, permissions, etc.)
400
+ }
401
+
402
+ // Deduplicate: multiple patterns can match the same secret on the same line
403
+ // (e.g. Stripe and Clerk both match sk_live_...). Keep one finding per
404
+ // unique (line, matched-text) pair — first match wins (patterns are ordered
405
+ // by severity: critical → high → medium).
406
+ const seen = new Set();
407
+ return findings.filter(f => {
408
+ const key = `${f.line}:${f.matched}`;
409
+ if (seen.has(key)) return false;
410
+ seen.add(key);
411
+ return true;
412
+ });
413
+ }
414
+
415
+ // =============================================================================
416
+ // OUTPUT FORMATTING
417
+ // =============================================================================
418
+
419
+ function outputPretty(results, filesScanned, rootPath) {
420
+ // Separate findings into secrets and code vulnerabilities
421
+ const secretResults = [];
422
+ const vulnResults = [];
423
+
424
+ for (const { file, findings } of results) {
425
+ const secrets = findings.filter(f => f.category !== 'vulnerability');
426
+ const vulns = findings.filter(f => f.category === 'vulnerability');
427
+ if (secrets.length > 0) secretResults.push({ file, findings: secrets });
428
+ if (vulns.length > 0) vulnResults.push({ file, findings: vulns });
429
+ }
430
+
431
+ const stats = {
432
+ total: 0,
433
+ critical: 0,
434
+ high: 0,
435
+ medium: 0,
436
+ secretsTotal: 0,
437
+ vulnsTotal: 0,
438
+ filesScanned
439
+ };
440
+
441
+ for (const { findings } of results) {
442
+ for (const f of findings) {
443
+ stats.total++;
444
+ stats[f.severity] = (stats[f.severity] || 0) + 1;
445
+ if (f.category === 'vulnerability') stats.vulnsTotal++;
446
+ else stats.secretsTotal++;
447
+ }
448
+ }
449
+
450
+ output.header('Scan Results');
451
+
452
+ if (results.length === 0) {
453
+ output.success('No secrets or vulnerabilities detected in your codebase!');
454
+ console.log();
455
+ console.log(chalk.gray('Note: Uses pattern matching + entropy scoring. Test files excluded by default.'));
456
+ console.log(chalk.gray('Tip: Run with --include-tests to also scan test files.'));
457
+ console.log(chalk.gray('Tip: Add a .praxisignore file to exclude paths.'));
458
+ } else {
459
+ // ── Secrets section ────────────────────────────────────────────────────
460
+ if (secretResults.length > 0) {
461
+ console.log();
462
+ console.log(chalk.red.bold(` Secrets (${stats.secretsTotal})`));
463
+ console.log(chalk.red(' ' + '─'.repeat(58)));
464
+
465
+ for (const { file, findings } of secretResults) {
466
+ const relPath = path.relative(rootPath, file);
467
+ for (const f of findings) {
468
+ output.finding(relPath, f.line, f.patternName, f.severity, f.matched, f.description, f.confidence);
469
+ }
470
+ }
471
+ }
472
+
473
+ // ── Code Vulnerabilities section ───────────────────────────────────────
474
+ if (vulnResults.length > 0) {
475
+ console.log();
476
+ console.log(chalk.yellow.bold(` Code Vulnerabilities (${stats.vulnsTotal})`));
477
+ console.log(chalk.yellow(' ' + '─'.repeat(58)));
478
+
479
+ for (const { file, findings } of vulnResults) {
480
+ const relPath = path.relative(rootPath, file);
481
+ for (const f of findings) {
482
+ output.vulnerabilityFinding(relPath, f.line, f.patternName, f.severity, f.matched, f.description);
483
+ }
484
+ }
485
+ }
486
+
487
+ // Remind about suppressions
488
+ console.log();
489
+ console.log(chalk.gray('Suppress a finding: add # praxis-ignore as a comment on that line'));
490
+ console.log(chalk.gray('Exclude a path: add it to .praxisignore'));
491
+ console.log();
492
+ console.log(chalk.gray('Track findings over time: ') + chalk.cyan(''));
493
+
494
+ if (secretResults.length > 0) output.recommendations();
495
+ if (vulnResults.length > 0) output.vulnRecommendations();
496
+ }
497
+
498
+ output.summary(stats);
499
+ }
500
+
501
+ function outputJSON(results, filesScanned) {
502
+ const jsonOutput = {
503
+ success: results.length === 0,
504
+ filesScanned,
505
+ totalFindings: 0,
506
+ findings: []
507
+ };
508
+
509
+ for (const { file, findings } of results) {
510
+ for (const f of findings) {
511
+ jsonOutput.totalFindings++;
512
+ jsonOutput.findings.push({
513
+ file,
514
+ line: f.line,
515
+ column: f.column,
516
+ category: f.category || 'secret',
517
+ severity: f.severity,
518
+ confidence: f.confidence,
519
+ type: f.patternName,
520
+ matched: f.category === 'vulnerability' ? f.matched : output.maskSecret(f.matched),
521
+ description: f.description
522
+ });
523
+ }
524
+ }
525
+
526
+ console.log(JSON.stringify(jsonOutput, null, 2));
527
+ }
528
+
529
+ // =============================================================================
530
+ // SARIF OUTPUT (GitHub Code Scanning compatible)
531
+ // =============================================================================
532
+
533
+ /**
534
+ * Output findings in SARIF 2.1.0 format.
535
+ * Feed this into GitHub's Security tab:
536
+ * npx praxis-sec scan . --sarif > results.sarif
537
+ *
538
+ * Then upload via:
539
+ * github/codeql-action/upload-sarif@v3
540
+ */
541
+ /**
542
+ * SARIF output for GitHub Code Scanning.
543
+ *
544
+ * Delegates to the shared serializer in `cli/core/output/sarif.js`; this used
545
+ * to be a private copy. That copy also disagreed with itself — it gave a `low` finding
546
+ * rule-level `note` but result-level `warning` — and hardcoded the SARIF spec version
547
+ * `2.1.0` as the tool driver version.
548
+ *
549
+ * `allResults` arrives as `[{ file, findings }]`, so findings are flattened with their
550
+ * file attached before serializing. `--sarif` writes to stdout; redirect it.
551
+ */
552
+ function renderSARIF(allResults, rootPath) {
553
+ return renderFindingsSARIF(allResults, { rootPath });
554
+ }