praxis-sec 1.2.1 → 1.2.4

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 (53) hide show
  1. package/README.md +84 -115
  2. package/ai-defense/cost-protection.md +6 -0
  3. package/ai-defense/llm-security-checklist.md +6 -0
  4. package/ai-defense/system-prompt-armor.md +7 -1
  5. package/assets/praxis-architecture.svg +304 -0
  6. package/assets/praxis-logo.svg +38 -0
  7. package/checklists/launch-day.md +6 -7
  8. package/cli/agents/agent-telemetry-agent.js +2 -0
  9. package/cli/agents/api-fuzzer.js +2 -2
  10. package/cli/agents/git-history-scanner.js +14 -15
  11. package/cli/agents/html-reporter.js +9 -8
  12. package/cli/agents/mcp-security-agent.js +600 -594
  13. package/cli/agents/memory-poisoning-agent.js +1 -5
  14. package/cli/agents/orchestrator.js +375 -360
  15. package/cli/commands/agent-fix.js +3 -1
  16. package/cli/commands/audit.js +1272 -1228
  17. package/cli/commands/autofix.js +32 -13
  18. package/cli/commands/baseline.js +4 -2
  19. package/cli/commands/benchmark.js +2 -1
  20. package/cli/commands/ci.js +12 -8
  21. package/cli/commands/diff.js +2 -1
  22. package/cli/commands/env-audit.js +4 -2
  23. package/cli/commands/fix.js +2 -1
  24. package/cli/commands/legal.js +2 -1
  25. package/cli/commands/mcp.js +54 -51
  26. package/cli/commands/openclaw.js +3 -6
  27. package/cli/commands/red-team.js +2 -1
  28. package/cli/commands/remediate.js +2 -1
  29. package/cli/commands/rotate.js +2 -1
  30. package/cli/commands/scan-mcp.js +20 -9
  31. package/cli/commands/scan-standard.js +3 -6
  32. package/cli/commands/scan.js +15 -7
  33. package/cli/commands/score.js +2 -1
  34. package/cli/commands/vibe-check.js +4 -2
  35. package/cli/commands/watch.js +8 -6
  36. package/cli/core/glob.js +7 -5
  37. package/cli/core/output/json.js +56 -48
  38. package/cli/core/paths.js +91 -0
  39. package/cli/core/web/jobs.js +2 -0
  40. package/cli/data/documented-secret-examples.json +14 -0
  41. package/cli/utils/cache-manager.js +2 -1
  42. package/cli/utils/entropy.js +19 -0
  43. package/cli/utils/hermes-tool-registry.js +11 -9
  44. package/configs/firebase/security-checklist.md +3 -3
  45. package/configs/supabase/security-checklist.md +19 -21
  46. package/docs/RELEASE-1.2.4.md +85 -0
  47. package/docs/RELEASING.md +51 -0
  48. package/docs/THIRD_PARTY_NOTICES.md +8 -0
  49. package/docs/THREAT_INTEL.md +4 -2
  50. package/docs/USAGE.md +97 -76
  51. package/package.json +6 -4
  52. package/snippets/README.md +6 -0
  53. package/snippets/auth/jwt-checklist.md +14 -13
@@ -1,1228 +1,1272 @@
1
- /**
2
- * Audit Command — Full Security Audit
3
- * =====================================
4
- *
5
- * One command to run everything: secrets, agents, deps, score, and
6
- * generate a comprehensive report with a prioritized remediation plan.
7
- *
8
- * USAGE:
9
- * npx praxis-sec audit [path] Full audit with HTML report
10
- * npx praxis-sec audit . --json JSON output
11
- * npx praxis-sec audit . --html report.html Custom report path
12
- * npx praxis-sec audit . --no-deps Skip dependency audit
13
- */
14
-
15
- import fs from 'fs';
16
- import path from 'path';
17
- import { renderFindingsSARIF } from '../core/output/sarif.js';
18
- import chalk from 'chalk';
19
- import ora from 'ora';
20
- import fg from '../core/glob.js';
21
- import { buildOrchestrator, buildOrchestratorAsync } from '../agents/index.js';
22
- import { LegalRiskAgent } from '../agents/legal-risk-agent.js';
23
- import { ScoringEngine } from '../agents/scoring-engine.js';
24
- import { PolicyEngine } from '../agents/policy-engine.js';
25
- import { HTMLReporter } from '../agents/html-reporter.js';
26
- import { SBOMGenerator } from '../agents/sbom-generator.js';
27
- import { autoDetectProvider } from '../providers/llm-provider.js';
28
- import { runDepsAudit } from './deps.js';
29
- import {
30
- SECRET_PATTERNS,
31
- SECURITY_PATTERNS,
32
- SKIP_DIRS,
33
- SKIP_EXTENSIONS,
34
- SKIP_FILENAMES,
35
- MAX_FILE_SIZE,
36
- loadGitignorePatterns
37
- } from '../utils/patterns.js';
38
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
39
- import { printBanner } from '../utils/output.js';
40
- import { CacheManager } from '../utils/cache-manager.js';
41
- import { filterBaseline } from './baseline.js';
42
- import { SecurityMemory } from '../utils/security-memory.js';
43
- import { ScanPlaybook } from '../utils/scan-playbook.js';
44
- import { generatePDF, generatePrintHTML, isChromeAvailable } from '../utils/pdf-generator.js';
45
- import { SecretsVerifier } from '../utils/secrets-verifier.js';
46
- import { applyInlineAnnotations } from './autofix.js';
47
- import { buildScanFingerprint } from '../utils/scan-fingerprint.js';
48
- import { isGitUrl, cloneGitRepo, redactGitUrl } from '../core/git-clone.js';
49
-
50
- // =============================================================================
51
- // CONSTANTS
52
- // =============================================================================
53
-
54
- const ALL_PATTERNS = [...SECRET_PATTERNS, ...SECURITY_PATTERNS];
55
-
56
- const SEV_ORDER = ['critical', 'high', 'medium', 'low'];
57
-
58
- const CATEGORY_LABELS = {
59
- secrets: 'Secrets',
60
- injection: 'Code Vulnerabilities',
61
- deps: 'Dependencies',
62
- auth: 'Auth & Access Control',
63
- config: 'Configuration',
64
- 'supply-chain': 'Supply Chain',
65
- api: 'API Security',
66
- llm: 'AI/LLM Security',
67
- legal: 'Legal Risk',
68
- };
69
-
70
- const EFFORT_MAP = {
71
- secrets: 'low',
72
- config: 'low',
73
- deps: 'medium',
74
- injection: 'medium',
75
- auth: 'medium',
76
- 'supply-chain': 'medium',
77
- api: 'medium',
78
- llm: 'high',
79
- legal: 'low',
80
- };
81
-
82
- // =============================================================================
83
- // MAIN COMMAND
84
- // =============================================================================
85
-
86
- export async function auditCommand(targetPath = '.', options = {}) {
87
- const machineOutput = options.json || options.sarif || options.csv || options.md;
88
- let gitClone = null;
89
- let effectivePath = targetPath;
90
-
91
- // ── Remote Git Repository Support ─────────────────────────────────────────
92
- if (isGitUrl(targetPath)) {
93
- if (!machineOutput) printBanner();
94
- const gitSpinner = machineOutput ? null : ora({ text: chalk.cyan(`Cloning remote Git repository: ${redactGitUrl(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
- gitHistory: options.gitHistory,
102
- });
103
- effectivePath = gitClone.tempDir;
104
- if (gitSpinner) gitSpinner.succeed(chalk.green(`Cloned ${gitClone.repoName} (${gitClone.displayUrl}) to temporary workspace`));
105
- } catch (err) {
106
- if (gitSpinner) gitSpinner.fail(chalk.red(err.message));
107
- else console.error(chalk.red(err.message));
108
- process.exitCode = 1;
109
- return;
110
- }
111
- }
112
-
113
- try {
114
- const absolutePath = path.resolve(effectivePath);
115
-
116
- if (!fs.existsSync(absolutePath)) {
117
- console.error(chalk.red(` Path does not exist: ${absolutePath}`));
118
- process.exitCode = 1;
119
- return;
120
- }
121
-
122
- if (!machineOutput && !gitClone) {
123
- printBanner();
124
- }
125
-
126
- // ── Cache Layer ──────────────────────────────────────────────────────────
127
- // --deep forces a fresh scan so taint analysis covers every finding,
128
- // not just the ones from files changed since the last cached run.
129
- const useCache = options.cache !== false && !options.deep;
130
- const cache = new CacheManager(absolutePath);
131
- let cacheData = useCache ? cache.load() : null;
132
- let cacheDiff = null;
133
- let allFiles = [];
134
-
135
- // ── Phase 1: Secret Scan ──────────────────────────────────────────────────
136
- const secretSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 1/4] Scanning for secrets...'), color: 'cyan' }).start();
137
- let secretFindings = [];
138
- let filesScanned = 0;
139
-
140
- try {
141
- allFiles = await findFiles(absolutePath);
142
- filesScanned = allFiles.length;
143
-
144
- // Determine which files need scanning (incremental if cache exists)
145
- let filesToScan = allFiles;
146
- let cachedSecretFindings = [];
147
-
148
- if (cacheData) {
149
- cacheDiff = cache.diff(allFiles);
150
- filesToScan = cacheDiff.changedFiles;
151
- // Reuse cached findings for unchanged files (secrets only)
152
- cachedSecretFindings = cacheDiff.cachedFindings.filter(
153
- f => f.category === 'secrets' || f.category === 'secret'
154
- );
155
- }
156
-
157
- for (const file of filesToScan) {
158
- const fileResults = scanFileForSecrets(file);
159
- for (const f of fileResults) {
160
- secretFindings.push({
161
- file,
162
- line: f.line,
163
- column: f.column,
164
- severity: f.severity,
165
- category: f.category || 'secrets',
166
- rule: f.patternName,
167
- title: f.patternName.replace(/_/g, ' '),
168
- description: f.description,
169
- matched: f.matched,
170
- confidence: f.confidence,
171
- fix: file.match(/\.env(\..*)?$/)
172
- ? `Ensure .env is in .gitignore and use a secrets manager for production`
173
- : `Move to environment variable or secrets manager`,
174
- });
175
- }
176
- }
177
-
178
- // Downgrade .env findings if the file is gitignored (properly managed)
179
- const gitignoreContent = (() => {
180
- try { return fs.readFileSync(path.join(absolutePath, '.gitignore'), 'utf-8'); } catch { return ''; }
181
- })();
182
- const envIsGitignored = gitignoreContent.split('\n')
183
- .map(l => l.trim())
184
- .some(l => /^\.env(\s|$)/.test(l) || l === '*.env' || l === '.env*' || l === '.env.local' || l === '.env.production');
185
-
186
- if (envIsGitignored) {
187
- for (const f of secretFindings) {
188
- if (f.file.match(/\.env(\..*)?$/) && !f.file.includes('node_modules')) {
189
- f.severity = 'low';
190
- f.confidence = 'low';
191
- f.fix = 'Already gitignored — ensure secrets manager is used for production deploys';
192
- }
193
- }
194
- }
195
-
196
- // Downgrade secrets in test files (intentional test fixtures)
197
- const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/|\/fixtures?\/)/i;
198
- for (const f of secretFindings) {
199
- if (TEST_PATH.test(f.file)) {
200
- f.confidence = 'low';
201
- }
202
- }
203
-
204
- // Merge with cached findings for unchanged files
205
- secretFindings = [...secretFindings, ...cachedSecretFindings];
206
-
207
- const cacheNote = cacheDiff && cacheDiff.changedFiles.length < allFiles.length
208
- ? ` (${cacheDiff.changedFiles.length} changed, ${cacheDiff.unchangedCount} cached)`
209
- : '';
210
-
211
- if (secretSpinner) secretSpinner.succeed(
212
- secretFindings.length === 0
213
- ? chalk.green(`[Phase 1/4] Secrets: clean${cacheNote}`)
214
- : chalk.red(`[Phase 1/4] Secrets: ${secretFindings.length} found${cacheNote}`)
215
- );
216
- } catch (err) {
217
- if (secretSpinner) secretSpinner.fail(chalk.red(`[Phase 1/4] Secret scan failed: ${err.message}`));
218
- }
219
-
220
- // ── Phase 2: Agent Scan ───────────────────────────────────────────────────
221
- const orchestrator = await buildOrchestratorAsync(absolutePath, { quiet: true, trustPlugins: !gitClone && options.trustPlugins === true });
222
-
223
- // --hermes-only: filter to llm + supply-chain category agents only
224
- if (options.hermesOnly && orchestrator.agents) {
225
- const hermesCategories = new Set(['llm', 'supply-chain']);
226
- orchestrator.agents = orchestrator.agents.filter(a =>
227
- hermesCategories.has(a.category) || hermesCategories.has(a.constructor?.category)
228
- );
229
- }
230
-
231
- const registeredAgentCount = orchestrator.agents?.length || 15;
232
- const agentSpinner = machineOutput ? null : ora({ text: chalk.white(`[Phase 2/4] Running ${registeredAgentCount} security agents...`), color: 'cyan' }).start();
233
- let agentFindings = [];
234
- let recon = null;
235
- let agentResults = [];
236
-
237
- try {
238
- // Suppress individual agent spinners by using quiet mode
239
- // Pass changedFiles for incremental scanning if cache is valid
240
- const orchestratorOpts = { quiet: true };
241
- if (options.deep) orchestratorOpts.deep = true;
242
- if (options.local) orchestratorOpts.local = true;
243
- if (options.model) orchestratorOpts.model = options.model;
244
- if (options.provider) orchestratorOpts.provider = options.provider;
245
- if (options.baseUrl) orchestratorOpts.baseUrl = options.baseUrl;
246
- if (options.budget) orchestratorOpts.budget = options.budget;
247
- if (options.verbose) orchestratorOpts.verbose = true;
248
- // Deliberately NOT passing `changedFiles` to the orchestrator.
249
- //
250
- // BaseAgent.getFilesToScan() returns `context.changedFiles || context.files`,
251
- // so passing it makes an agent scan only changed files — and on an unchanged
252
- // tree that list is empty, so the agent reports nothing at all. The cache
253
- // cannot cover for that: the restore below is filtered to
254
- // `category === 'secrets'`, so agent findings for unchanged files were never
255
- // coming back. A warm scan silently lost every finding from
256
- // PromptInjectionProberAgent, VibeCodingAgent and ExceptionHandlerAgent —
257
- // 9 findings on this tree, under-reporting on the second run of any project.
258
- //
259
- // 25 of 28 agents already ignore `changedFiles` and read `context.files`, so
260
- // the hint only ever narrowed three of them while making them wrong. Phase 1
261
- // (secrets) is where the cache actually pays for itself, and it still uses
262
- // it. Passing an unsafe optimisation is worse than not optimising.
263
- const results = await orchestrator.runAll(absolutePath, orchestratorOpts); // praxis-ignore — orchestrator result, not LLM output triggering actions
264
- recon = results.recon;
265
- agentFindings = results.findings;
266
- agentResults = results.agentResults;
267
-
268
- const totalAgentFindings = agentFindings.length;
269
- const agentCount = agentResults.filter(a => a.success).length;
270
- if (agentSpinner) agentSpinner.succeed(
271
- totalAgentFindings === 0
272
- ? chalk.green(`[Phase 2/4] ${agentCount} agents: clean`)
273
- : chalk.yellow(`[Phase 2/4] ${agentCount} agents: ${totalAgentFindings} finding(s)`)
274
- );
275
- } catch (err) {
276
- if (agentSpinner) agentSpinner.fail(chalk.red(`[Phase 2/4] Agent scan failed: ${err.message}`));
277
- }
278
-
279
- // ── Phase 3: Dependency Audit ─────────────────────────────────────────────
280
- let depVulns = [];
281
- if (options.deps !== false) {
282
- const depSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 3/4] Auditing dependencies...'), color: 'cyan' }).start();
283
- try {
284
- const depResult = await runDepsAudit(absolutePath);
285
- depVulns = depResult.vulns || [];
286
- if (depSpinner) depSpinner.succeed(
287
- depVulns.length === 0
288
- ? chalk.green('[Phase 3/4] Dependencies: clean')
289
- : chalk.red(`[Phase 3/4] Dependencies: ${depVulns.length} CVE(s)`)
290
- );
291
- } catch {
292
- if (depSpinner) depSpinner.succeed(chalk.gray('[Phase 3/4] Dependencies: skipped (no manifest)'));
293
- }
294
- } else if (!machineOutput) {
295
- console.log(chalk.gray(' [Phase 3/4] Dependencies: skipped (--no-deps)'));
296
- }
297
-
298
- // ── Phase 3b: Legal Risk Scan (opt-in) ───────────────────────────────────
299
- let legalFindings = [];
300
- if (options.includeLegal) {
301
- const legalSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 3b] Legal risk scan…'), color: 'cyan' }).start();
302
- try {
303
- const legalAgent = new LegalRiskAgent();
304
- legalFindings = await legalAgent.analyze({ rootPath: absolutePath, files: allFiles });
305
- if (legalSpinner) legalSpinner.succeed(
306
- legalFindings.length === 0
307
- ? chalk.green('[Phase 3b] Legal: clean')
308
- : chalk.yellow(`[Phase 3b] Legal: ${legalFindings.length} finding(s)`)
309
- );
310
- } catch {
311
- if (legalSpinner) legalSpinner.succeed(chalk.gray('[Phase 3b] Legal: skipped'));
312
- }
313
- }
314
-
315
- // ── Phase 4: Merge, Score, and Build Plan ─────────────────────────────────
316
- const scoreSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 4/4] Computing security score...'), color: 'cyan' }).start();
317
-
318
- // Merge secret findings + agent findings + legal findings, deduplicate
319
- const allFindings = deduplicateFindings([...secretFindings, ...agentFindings, ...legalFindings]);
320
-
321
- // Apply policy
322
- const policy = PolicyEngine.load(absolutePath);
323
- let filteredFindings = policy.applyPolicy(allFindings);
324
-
325
- // Apply baseline filter (only show new findings)
326
- if (options.baseline) {
327
- const beforeCount = filteredFindings.length;
328
- filteredFindings = filterBaseline(filteredFindings, absolutePath);
329
- if (!machineOutput && beforeCount !== filteredFindings.length) {
330
- console.log(chalk.gray(` Baseline: ${beforeCount - filteredFindings.length} known finding(s) filtered, ${filteredFindings.length} new`));
331
- }
332
- }
333
-
334
- // ── Security Memory Filter ──────────────────────────────────────────────
335
- // Auto-learn false positives from deep analysis results, then suppress
336
- // any finding that memory recognises from a previous scan.
337
- const secMemory = new SecurityMemory(absolutePath);
338
- if (options.deep) {
339
- // After deep analysis ran, learn any new false positives
340
- const newFPs = secMemory.learnFromAnalysis(filteredFindings);
341
- if (newFPs > 0 && !machineOutput) {
342
- console.log(chalk.gray(` Memory: ${newFPs} new false positive(s) learned and will be suppressed in future scans`));
343
- }
344
- }
345
- const { kept: memFiltered, suppressedCount: memSuppressed } = secMemory.filter(filteredFindings);
346
- filteredFindings = memFiltered;
347
- if (memSuppressed > 0 && !machineOutput) {
348
- console.log(chalk.gray(` Memory: ${memSuppressed} previously-confirmed false positive(s) suppressed`));
349
- }
350
-
351
- // Count suppressions (praxis-ignore comments)
352
- const suppressions = countSuppressions(allFiles);
353
-
354
- // Score
355
- const scoringEngine = new ScoringEngine();
356
- const scoreResult = scoringEngine.compute(filteredFindings, depVulns);
357
- // Round score to 1 decimal place to avoid floating-point noise (e.g., 63.300000000000004)
358
- scoreResult.score = Math.round(scoreResult.score * 10) / 10;
359
- scoringEngine.saveToHistory(absolutePath, scoreResult, suppressions);
360
-
361
- // Score and filtered findings must exist before the best-effort playbook update.
362
- try {
363
- const playbook = new ScanPlaybook(absolutePath);
364
- const suppressedRules = secMemory.list().map(entry => entry.rule).filter(Boolean);
365
- playbook.update(recon, { score: scoreResult.score, grade: scoreResult.grade?.letter || scoreResult.grade, totalFindings: filteredFindings.length }, filteredFindings, suppressedRules);
366
- } catch { /* non-fatal */ }
367
-
368
- const gradeColor = scoreResult.score >= 75 ? chalk.green.bold : scoreResult.score >= 60 ? chalk.yellow.bold : chalk.red.bold;
369
- if (scoreSpinner) scoreSpinner.succeed(
370
- chalk.white('[Phase 4/4] Score: ') + gradeColor(`${scoreResult.score}/100 ${scoreResult.grade.letter}`)
371
- );
372
-
373
- // ── AI Classification (optional, with LLM cache) ───────────────────────
374
- if (options.ai !== false && !options.noAi) {
375
- const provider = autoDetectProvider(absolutePath, {
376
- provider: options.provider,
377
- baseUrl: options.baseUrl,
378
- model: options.model,
379
- think: options.think || false,
380
- });
381
- if (provider && filteredFindings.length > 0 && filteredFindings.length <= 50) {
382
- const aiSpinner = machineOutput ? null : ora({ text: `Classifying with ${provider.name}...`, color: 'cyan' }).start();
383
- try {
384
- // Check LLM cache for existing classifications
385
- const llmCache = cache.loadLLMClassifications();
386
- const uncachedFindings = [];
387
- let cachedCount = 0;
388
-
389
- for (const finding of filteredFindings) {
390
- const key = cache.getLLMCacheKey(finding);
391
- const cached = llmCache[key];
392
- if (cached) {
393
- finding.aiClassification = cached.classification;
394
- finding.aiReason = cached.reason;
395
- finding.aiFix = cached.fix;
396
- cachedCount++;
397
- } else {
398
- uncachedFindings.push(finding);
399
- }
400
- }
401
-
402
- // Only send uncached findings to LLM
403
- if (uncachedFindings.length > 0) {
404
- const classifications = await provider.classify(uncachedFindings);
405
- const newCacheEntries = {};
406
- for (const cl of classifications) {
407
- const finding = filteredFindings.find(f => `${f.file}:${f.line}` === cl.id);
408
- if (finding) {
409
- finding.aiClassification = cl.classification;
410
- finding.aiReason = cl.reason;
411
- finding.aiFix = cl.fix;
412
- const key = cache.getLLMCacheKey(finding);
413
- newCacheEntries[key] = {
414
- classification: cl.classification,
415
- reason: cl.reason,
416
- fix: cl.fix,
417
- cachedAt: new Date().toISOString(),
418
- };
419
- }
420
- }
421
- cache.saveLLMClassifications(newCacheEntries);
422
- }
423
-
424
- const cacheNote = cachedCount > 0 ? `, ${cachedCount} cached` : '';
425
- if (aiSpinner) aiSpinner.succeed(chalk.green(`AI classification complete (${provider.name}${cacheNote})`));
426
- } catch (err) {
427
- if (aiSpinner) aiSpinner.fail(chalk.yellow(`AI classification failed: ${err.message}`));
428
- }
429
- }
430
- }
431
-
432
- // ── Secrets Verification (optional, --verify flag) ─────────────────────
433
- if (options.verify) {
434
- const verifySpinner = machineOutput ? null : ora({ text: 'Verifying leaked secrets against provider APIs...', color: 'cyan' }).start();
435
- try {
436
- const verifier = new SecretsVerifier();
437
- const verifyResults = await verifier.verify(filteredFindings);
438
- const activeCount = verifyResults.filter(r => r.result.active === true).length;
439
- const inactiveCount = verifyResults.filter(r => r.result.active === false).length;
440
- if (verifySpinner) {
441
- verifySpinner.succeed(chalk.green(
442
- `Secrets verified: ${activeCount} active, ${inactiveCount} inactive, ${verifyResults.length - activeCount - inactiveCount} unknown`
443
- ));
444
- }
445
- // Show active secrets warning
446
- if (activeCount > 0 && !machineOutput) {
447
- console.log(chalk.red.bold(' ⚠ ACTIVE SECRETS DETECTED — rotate immediately:'));
448
- for (const r of verifyResults.filter(r => r.result.active === true)) {
449
- const rel = path.relative(absolutePath, r.finding.file).replace(/\\/g, '/');
450
- console.log(chalk.red(` ${r.result.provider}: ${rel}:${r.finding.line} — ${r.result.info}`));
451
- }
452
- }
453
- } catch (err) {
454
- if (verifySpinner) verifySpinner.fail(chalk.yellow(`Secrets verification failed: ${err.message}`));
455
- }
456
- }
457
-
458
- // ── Save Cache ──────────────────────────────────────────────────────────
459
- if (useCache) {
460
- try {
461
- // Merge agent findings back for cache (secret + agent findings from changed files)
462
- // plus cached findings from unchanged files
463
- const cachedAgentFindings = cacheData && cacheDiff
464
- ? cacheDiff.cachedFindings.filter(f => f.category !== 'secrets' && f.category !== 'secret')
465
- : [];
466
- const allFindingsForCache = [...secretFindings, ...agentFindings, ...cachedAgentFindings];
467
- cache.save(allFiles, deduplicateFindings(allFindingsForCache), recon, scoreResult);
468
- } catch {
469
- // Silent — caching should never break a scan
470
- }
471
- }
472
-
473
- // ── Build Remediation Plan ────────────────────────────────────────────────
474
- const remediationPlan = buildRemediationPlan(filteredFindings, depVulns, absolutePath);
475
-
476
- // Skip all output and file generation for inner agentic re-scans
477
- if (options._agenticInner) {
478
- return { score: scoreResult.score, findings: filteredFindings };
479
- }
480
-
481
- // ── Output ────────────────────────────────────────────────────────────────
482
- if (!machineOutput) console.log();
483
-
484
- if (options.csv) {
485
- outputCSV(filteredFindings, depVulns, scoreResult, absolutePath);
486
- } else if (options.md) {
487
- outputMarkdown(scoreResult, filteredFindings, depVulns, remediationPlan, absolutePath);
488
- } else if (options.json) {
489
- outputJSON(scoreResult, filteredFindings, depVulns, recon, agentResults, remediationPlan, suppressions, options.compare ? scoringEngine.loadHistory(absolutePath) : null, filesScanned);
490
- } else if (options.sarif) {
491
- outputSARIF(filteredFindings, absolutePath);
492
- } else {
493
- printReport(scoreResult, filteredFindings, depVulns, recon, remediationPlan, absolutePath, filesScanned);
494
- }
495
-
496
- // ── HTML Report — always on for human output, or when --html is explicit ──
497
- const explicitHtml = typeof options.html === 'string' || options.html === true;
498
- if (explicitHtml || (!options.json && !options.sarif && !options.csv && !options.md)) {
499
- const htmlPath = typeof options.html === 'string' ? options.html : 'praxis-report.html';
500
- const reporter = new HTMLReporter();
501
- reporter.generateToFile(scoreResult, filteredFindings, recon, absolutePath, htmlPath, agentResults, filesScanned);
502
-
503
- // If --html-dir or --suite specified, also generate granular multi-page report suite
504
- if (options.htmlDir || options['html-dir'] || options.suite) {
505
- const suiteDir = typeof (options.htmlDir || options['html-dir'] || options.suite) === 'string'
506
- ? (options.htmlDir || options['html-dir'] || options.suite)
507
- : 'report';
508
- reporter.generateReportSuite(scoreResult, filteredFindings, depVulns, recon, remediationPlan, absolutePath, suiteDir, agentResults, filesScanned);
509
- }
510
-
511
- // Keep stdout pure JSON/SARIF when combined with machine output
512
- const note = machineOutput ? console.error : console.log;
513
- note();
514
- note(chalk.cyan(` Full report: ${chalk.white.bold(htmlPath)}`));
515
- note(chalk.gray(` Dashboard: `) + chalk.cyan(''));
516
-
517
- // PDF export
518
- if (options.pdf) {
519
- const pdfPath = typeof options.pdf === 'string' ? options.pdf : 'praxis-report.pdf';
520
- const result = generatePDF(path.resolve(htmlPath), path.resolve(pdfPath));
521
- if (result) {
522
- note(chalk.cyan(` PDF report: ${chalk.white.bold(pdfPath)}`));
523
- } else {
524
- // Fallback: print-optimized HTML
525
- const fallbackPath = pdfPath.replace(/\.pdf$/, '.print.html');
526
- generatePrintHTML(path.resolve(htmlPath), path.resolve(fallbackPath));
527
- note(chalk.yellow(` Chrome not found — saved print-optimized HTML: ${fallbackPath}`));
528
- note(chalk.gray(' Open in a browser and Print → Save as PDF'));
529
- }
530
- }
531
- }
532
-
533
- if (!machineOutput && !options.csv && !options.md) {
534
- // ── Policy Violations ──────────────────────────────────────────────────
535
- const violations = policy.evaluate(scoreResult, filteredFindings, {
536
- agentResults,
537
- depsRun: options.deps !== false,
538
- secretsRun: true,
539
- depVulns,
540
- });
541
- if (violations.length > 0) {
542
- console.log();
543
- console.log(chalk.red.bold(' Policy Violations:'));
544
- for (const v of violations.slice(0, 5)) {
545
- console.log(chalk.red(` ✗ ${v.message}`));
546
- }
547
- }
548
-
549
- // ── Trend ───────────────────────────────────────────────────────────────
550
- const trend = scoringEngine.getTrend(absolutePath, scoreResult.score);
551
- if (trend) {
552
- const arrow = trend.diff > 0 ? chalk.green('↑') : trend.diff < 0 ? chalk.red('↓') : chalk.gray('→');
553
- const roundedDiff = Math.round(trend.diff * 10) / 10;
554
- const diffLabel = roundedDiff === 0 ? chalk.gray('no change') : chalk.white(`${roundedDiff > 0 ? '+' : ''}${roundedDiff}`);
555
- console.log(chalk.gray(` Trend: ${trend.previousScore} → ${trend.currentScore} ${arrow} (`) + diffLabel + chalk.gray(')'));
556
- }
557
-
558
- // ── Detailed Comparison ────────────────────────────────────────────────
559
- if (options.compare) {
560
- printComparison(scoringEngine, absolutePath, scoreResult);
561
- }
562
-
563
- console.log();
564
- console.log(chalk.cyan('═'.repeat(60)));
565
- console.log();
566
- }
567
-
568
- // ── Agentic Loop (--agentic) ────────────────────────────────────────────
569
- // Scan → annotate fixes → re-scan cycle until score >= target or maxIter.
570
- // NOTE: process.exit() is deferred until after the loop so all iterations
571
- // can run. The inner re-scans use _agenticInner: true to skip process.exit.
572
- if (options.agentic && !options._agenticInner) {
573
- const maxIter = typeof options.agentic === 'number' ? options.agentic : 3;
574
- const targetScore = options.agenticTarget ?? 75;
575
- let iteration = 1;
576
- let currentScore = scoreResult.score;
577
- let currentFindings = filteredFindings;
578
-
579
- if (!machineOutput) {
580
- console.log();
581
- console.log(chalk.cyan.bold(` Agentic mode: scan→fix→verify loop (max ${maxIter} iterations, target score: ${targetScore})`));
582
- }
583
-
584
- while (currentScore < targetScore && iteration <= maxIter) {
585
- if (!machineOutput) {
586
- console.log(chalk.cyan(`\n ─── Agentic iteration ${iteration}/${maxIter} (current score: ${currentScore}) ───`));
587
- }
588
-
589
- const actionable = currentFindings.filter(f => f.fix && f.severity !== 'low');
590
- if (actionable.length === 0) {
591
- if (!machineOutput) console.log(chalk.gray(' No auto-fixable findings — stopping agentic loop.'));
592
- break;
593
- }
594
-
595
- // Delegate annotation to autofix module (handles comment style, idempotency, NEVER_EDIT list)
596
- const fixCount = applyInlineAnnotations(actionable);
597
- if (!machineOutput) {
598
- console.log(chalk.yellow(` Annotated ${fixCount} finding(s). Re-scanning...`));
599
- }
600
- if (fixCount === 0) break;
601
-
602
- // Re-scan without recursing into the agentic loop or calling process.exit
603
- const innerResult = await runAuditInner(targetPath, {
604
- ...options,
605
- agentic: false,
606
- _agenticInner: true,
607
- json: false,
608
- sarif: false,
609
- csv: false,
610
- md: false,
611
- html: false,
612
- pdf: false,
613
- quiet: true,
614
- });
615
-
616
- const prevScore = currentScore;
617
- currentScore = innerResult?.score ?? currentScore;
618
- currentFindings = innerResult?.findings ?? currentFindings;
619
-
620
- if (!machineOutput) {
621
- const diff = currentScore - prevScore;
622
- const arrow = diff > 0 ? chalk.green(`↑ +${diff.toFixed(1)}`) : diff < 0 ? chalk.red(`↓ ${diff.toFixed(1)}`) : chalk.gray('→ 0');
623
- console.log(chalk.cyan(` Re-scan score: ${currentScore} ${arrow}`));
624
- }
625
-
626
- iteration++;
627
- }
628
-
629
- if (!machineOutput) {
630
- if (currentScore >= targetScore) {
631
- console.log(chalk.green.bold(`\n Agentic loop complete — target score ${targetScore} reached (${currentScore}).`));
632
- } else {
633
- console.log(chalk.yellow(`\n Agentic loop stopped after ${iteration - 1} iteration(s). Final score: ${currentScore}`));
634
- }
635
- }
636
- }
637
-
638
- // ── Exit code logic ─────────────────────────────────────────────────────
639
- // Only gate the exit code when --fail-below is explicitly requested;
640
- // a plain scan reports findings and exits 0 (CI gating belongs to `scan ci`).
641
- // Use process.exitCode (natural event-loop drain) instead of process.exit()
642
- // so in-flight libuv handles (plugin dynamic imports, etc.) close cleanly.
643
- if (options.failBelow !== undefined) {
644
- let threshold = 75;
645
- if (options.failBelow === 'baseline') {
646
- // Read baseline score from .praxis/hermes-baseline.json
647
- const baselinePath = path.join(absolutePath, '.praxis', 'hermes-baseline.json');
648
- try {
649
- const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
650
- threshold = baseline.score || 0;
651
- if (!machineOutput) {
652
- console.log(chalk.gray(` Baseline threshold: ${threshold}/100 (from ${baselinePath})`));
653
- }
654
- } catch {
655
- if (!machineOutput) {
656
- console.log(chalk.yellow(` Warning: could not read baseline — using score 0 as threshold`));
657
- }
658
- threshold = 0;
659
- }
660
- } else {
661
- const parsed = parseInt(options.failBelow, 10);
662
- threshold = Number.isNaN(parsed) ? 75 : parsed;
663
- }
664
- process.exitCode = scoreResult.score >= threshold ? 0 : 1;
665
- }
666
- } finally {
667
- if (gitClone) {
668
- if (!options.keepClone) {
669
- gitClone.cleanup();
670
- } else if (!machineOutput) {
671
- console.log(chalk.gray(`\n Cloned repository retained at: ${gitClone.tempDir}`));
672
- }
673
- }
674
- }
675
- }
676
-
677
- /**
678
- * Run a lightweight inner audit that returns { score, findings } without
679
- * calling process.exit(). Used exclusively by the --agentic loop.
680
- */
681
- async function runAuditInner(targetPath, options) {
682
- try {
683
- return await auditCommand(targetPath, options);
684
- } catch {
685
- return null;
686
- }
687
- }
688
-
689
- // =============================================================================
690
- // REMEDIATION PLAN BUILDER
691
- // =============================================================================
692
-
693
- function buildRemediationPlan(findings, depVulns, rootPath) {
694
- const plan = [];
695
- let priority = 1;
696
-
697
- // Exclude low-confidence findings (test files, docs, comments) from remediation plan
698
- const actionable = findings.filter(f => f.confidence !== 'low');
699
-
700
- // Priority order: secrets first, then by severity
701
- const secretFindings = actionable.filter(f => f.category === 'secrets' || f.category === 'secret');
702
- const otherFindings = actionable.filter(f => f.category !== 'secrets' && f.category !== 'secret');
703
-
704
- // Group and sort
705
- for (const sev of SEV_ORDER) {
706
- // Secrets at this severity — group .env findings by file
707
- const sevSecrets = secretFindings.filter(s => s.severity === sev);
708
- const envGroups = new Map();
709
- const nonEnvSecrets = [];
710
-
711
- for (const f of sevSecrets) {
712
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
713
- if (f.file.match(/\.env(\..*)?$/)) {
714
- if (!envGroups.has(relFile)) envGroups.set(relFile, []);
715
- envGroups.get(relFile).push(f);
716
- } else {
717
- nonEnvSecrets.push(f);
718
- }
719
- }
720
-
721
- // One plan item per .env file
722
- for (const [relFile, envFindings] of envGroups) {
723
- const names = envFindings.map(f => f.title || f.rule).join(', ');
724
- plan.push({
725
- priority: priority++,
726
- severity: sev,
727
- category: 'secrets',
728
- categoryLabel: 'SECRETS',
729
- title: `${envFindings.length} secret${envFindings.length > 1 ? 's' : ''} in ${relFile} (${names})`,
730
- file: relFile,
731
- action: envFindings[0].fix || 'Ensure .env is in .gitignore and use a secrets manager for production',
732
- effort: 'low',
733
- });
734
- }
735
-
736
- // Individual items for non-.env secrets
737
- for (const f of nonEnvSecrets) {
738
- plan.push({
739
- priority: priority++,
740
- severity: sev,
741
- category: 'secrets',
742
- categoryLabel: 'SECRETS',
743
- title: f.title || f.rule,
744
- file: `${path.relative(rootPath, f.file).replace(/\\/g, '/')}:${f.line}`,
745
- action: f.aiFix || f.fix || f.description,
746
- effort: 'low',
747
- });
748
- }
749
-
750
- // Other findings at this severity
751
- for (const f of otherFindings.filter(s => s.severity === sev)) {
752
- plan.push({
753
- priority: priority++,
754
- severity: sev,
755
- category: f.category,
756
- categoryLabel: (CATEGORY_LABELS[f.category] || f.category).toUpperCase(),
757
- title: f.title || f.rule,
758
- file: `${path.relative(rootPath, f.file).replace(/\\/g, '/')}:${f.line}`,
759
- action: f.aiFix || f.fix || f.description,
760
- effort: EFFORT_MAP[f.category] || 'medium',
761
- });
762
- }
763
-
764
- // Dep vulns at this severity
765
- for (const d of depVulns.filter(v => v.severity === sev || (sev === 'medium' && v.severity === 'moderate'))) {
766
- plan.push({
767
- priority: priority++,
768
- severity: sev,
769
- category: 'deps',
770
- categoryLabel: 'DEPENDENCIES',
771
- title: `Vulnerable: ${d.package || d.id}`,
772
- file: 'package.json',
773
- action: d.description ? `${d.description.slice(0, 80)}` : 'Update to patched version',
774
- effort: 'medium',
775
- });
776
- }
777
- }
778
-
779
- return plan;
780
- }
781
-
782
- // =============================================================================
783
- // CONSOLE OUTPUT
784
- // =============================================================================
785
-
786
- function printReport(scoreResult, findings, depVulns, recon, plan, rootPath, filesScanned) {
787
- const GRADE_COLOR = { A: chalk.green.bold, B: chalk.cyan.bold, C: chalk.yellow.bold, D: chalk.red, F: chalk.red.bold };
788
- const SEV_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
789
- const SEV_LABEL = { critical: 'CRITICAL — fix immediately', high: 'HIGH — fix before deploy', medium: 'MEDIUM — fix soon', low: 'LOW — review when possible' };
790
-
791
- // ── Score ─────────────────────────────────────────────────────────────────
792
- const gradeColor = GRADE_COLOR[scoreResult.grade.letter] || chalk.white;
793
- const scoreColor = scoreResult.score >= 75 ? chalk.green.bold : scoreResult.score >= 60 ? chalk.yellow.bold : chalk.red.bold;
794
-
795
- console.log(chalk.cyan(' ' + '═'.repeat(56)));
796
- console.log(
797
- chalk.white.bold(' Security Score: ') +
798
- scoreColor(`${scoreResult.score}/100 `) +
799
- gradeColor(scoreResult.grade.letter) +
800
- chalk.gray(` — ${scoreResult.grade.label}`)
801
- );
802
- console.log(chalk.cyan(' ' + '═'.repeat(56)));
803
- console.log();
804
-
805
- // ── Category Breakdown ────────────────────────────────────────────────────
806
- console.log(chalk.white.bold(' Category Breakdown'));
807
- console.log(chalk.gray(' ' + '─'.repeat(56)));
808
-
809
- for (const [key, cat] of Object.entries(scoreResult.categories)) {
810
- const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
811
- const icon = count === 0 ? chalk.green('✔') : chalk.red('✘');
812
- const status = count === 0 ? chalk.green('clean') : chalk.red(`${count} issue(s)`);
813
- const deduction = cat.deduction > 0 ? chalk.red(`-${Math.round(cat.deduction * 10) / 10} pts`) : chalk.gray('+0');
814
- console.log(` ${icon} ${chalk.white(cat.label.padEnd(22))} ${status.padEnd(25)} ${deduction}`);
815
- }
816
-
817
- // Deps row — only print if not already included in scoreResult.categories
818
- const hasDepsCategory = Object.values(scoreResult.categories).some(c => c.label?.toLowerCase().includes('depend'));
819
- if (!hasDepsCategory) {
820
- const depIcon = depVulns.length === 0 ? chalk.green('✔') : chalk.red('✘');
821
- const depStatus = depVulns.length === 0 ? chalk.green('clean') : chalk.red(`${depVulns.length} CVE(s)`);
822
- console.log(` ${depIcon} ${chalk.white('Dependencies'.padEnd(22))} ${depStatus}`);
823
- }
824
-
825
- console.log(chalk.gray(`\n Files scanned: ${filesScanned} | Findings: ${findings.length} | CVEs: ${depVulns.length}`));
826
-
827
- // ── Remediation Plan ──────────────────────────────────────────────────────
828
- if (plan.length > 0) {
829
- console.log();
830
- console.log(chalk.cyan(' ' + '═'.repeat(56)));
831
- console.log(chalk.cyan.bold(' Remediation Plan'));
832
- console.log(chalk.cyan(' ' + '═'.repeat(56)));
833
-
834
- let currentSev = null;
835
- let shown = 0;
836
- const maxItems = 30;
837
-
838
- for (const item of plan) {
839
- if (shown >= maxItems) {
840
- console.log(chalk.gray(`\n ... and ${plan.length - maxItems} more items in the full report`));
841
- break;
842
- }
843
-
844
- if (item.severity !== currentSev) {
845
- currentSev = item.severity;
846
- console.log();
847
- console.log(chalk.white.bold(` ${SEV_ICON[currentSev] || '⚪'} ${SEV_LABEL[currentSev] || currentSev.toUpperCase()}`));
848
- console.log(chalk.gray(' ' + '─'.repeat(56)));
849
- }
850
-
851
- console.log(
852
- chalk.white(` ${String(item.priority).padStart(2)}.`) +
853
- chalk.gray(` [${item.categoryLabel}] `) +
854
- chalk.white(item.title)
855
- );
856
- console.log(
857
- chalk.gray(` ${item.file}`) +
858
- chalk.gray(' → ') +
859
- chalk.green((item.action || '').slice(0, 70))
860
- );
861
- shown++;
862
- }
863
- } else {
864
- console.log();
865
- console.log(chalk.green.bold(' All clear — safe to ship!'));
866
- }
867
-
868
- // ── Attack Surface ────────────────────────────────────────────────────────
869
- if (recon) {
870
- console.log();
871
- console.log(chalk.gray(' Attack Surface:'));
872
- if (recon.frameworks?.length) console.log(chalk.gray(` Frameworks: ${recon.frameworks.join(', ')}`));
873
- if (recon.databases?.length) console.log(chalk.gray(` Databases: ${recon.databases.join(', ')}`));
874
- if (recon.authPatterns?.length) console.log(chalk.gray(` Auth: ${recon.authPatterns.join(', ')}`));
875
- if (recon.apiRoutes?.length) console.log(chalk.gray(` API Routes: ${recon.apiRoutes.length} discovered`));
876
- }
877
- }
878
-
879
- // =============================================================================
880
- // JSON OUTPUT
881
- // =============================================================================
882
-
883
- function outputJSON(scoreResult, findings, depVulns, recon, agentResults, remediationPlan, suppressions, history, filesScanned = null) {
884
- const output = {
885
- score: scoreResult.score,
886
- grade: scoreResult.grade.letter,
887
- gradeLabel: scoreResult.grade.label,
888
- totalFindings: findings.length,
889
- totalDepVulns: depVulns.length,
890
- categories: Object.fromEntries(
891
- Object.entries(scoreResult.categories).map(([k, v]) => [k, {
892
- label: v.label,
893
- findingCount: Object.values(v.counts).reduce((a, b) => a + b, 0),
894
- deduction: v.deduction,
895
- counts: v.counts,
896
- }])
897
- ),
898
- findings: findings.map(f => {
899
- const normFile = String(f.file || '')
900
- .replace(/\\/g, '/')
901
- .replace(/^[a-zA-Z]:\/+/, '')
902
- .replace(/^.*\/Praxis\/showcase-target\//, 'showcase-target/')
903
- .replace(/^.*\/Praxis\//, '');
904
- return {
905
- file: normFile, line: f.line, severity: f.severity, category: f.category,
906
- rule: f.rule, title: f.title, description: f.description, fix: f.fix,
907
- cwe: f.cwe, owasp: f.owasp,
908
- ...(f.eaa ? { eaa: f.eaa } : {}),
909
- ...(f.confidence ? { confidence: f.confidence } : {}),
910
- ...(f.standards ? { standards: f.standards } : {}),
911
- ...(f.deepAnalysis ? { deepAnalysis: f.deepAnalysis } : {}),
912
- ...(f.aiClassification ? {
913
- aiClassification: f.aiClassification,
914
- ...(f.aiReason ? { aiReason: f.aiReason } : {}),
915
- ...(f.aiFix ? { aiFix: f.aiFix } : {}),
916
- } : {}),
917
- };
918
- }),
919
- depVulns: depVulns.map(d => ({
920
- severity: d.severity, package: d.package || d.id, description: d.description,
921
- })),
922
- remediationPlan,
923
- recon,
924
- agents: agentResults,
925
- // Provenance: which tool, runtime and vendored data produced these numbers.
926
- fingerprint: buildScanFingerprint({ filesScanned }),
927
- };
928
- if (scoreResult.compliance) output.compliance = scoreResult.compliance;
929
- if (scoreResult.standardsSummary) output.standardsSummary = scoreResult.standardsSummary;
930
- if (scoreResult.agenticSummary) output.agenticSummary = scoreResult.agenticSummary;
931
- if (suppressions) output.suppressions = suppressions;
932
- if (history && history.length >= 2) {
933
- const prev = history[history.length - 2];
934
- output.comparison = {
935
- previousScore: prev.score,
936
- previousGrade: prev.grade,
937
- previousDate: prev.timestamp,
938
- diff: scoreResult.score - prev.score,
939
- categoryComparison: Object.fromEntries(
940
- Object.entries(scoreResult.categories).map(([k, v]) => {
941
- const prevCat = prev.categoryScores?.[k];
942
- return [k, {
943
- label: v.label,
944
- current: -v.deduction,
945
- previous: prevCat ? -prevCat.deduction : 0,
946
- delta: prevCat ? prevCat.deduction - v.deduction : 0,
947
- }];
948
- })
949
- ),
950
- };
951
- }
952
- console.log(JSON.stringify(output, null, 2));
953
- }
954
-
955
- // =============================================================================
956
- // SARIF OUTPUT
957
- // =============================================================================
958
-
959
- /**
960
- * SARIF for GitHub Code Scanning.
961
- *
962
- * Delegates to the shared serializer in `cli/core/output/sarif.js`. This was
963
- * a private copy, hardcoding a driver version of `4.0.0` while the package was at a
964
- * different number entirely, and it carried no `security-severity` — so the default
965
- * `praxis scan` lost severity fidelity in Code Scanning.
966
- */
967
- function outputSARIF(findings, rootPath) {
968
- console.log(renderFindingsSARIF(findings, { rootPath }));
969
- }
970
-
971
- // =============================================================================
972
- // FILE SCANNING (inline from scan.js to avoid circular deps)
973
- // =============================================================================
974
-
975
- // Walk up from `start` looking for `name`. Returns the absolute path or null.
976
- // Bounded to 8 ancestors to avoid runaway loops on weird filesystems.
977
- function findUpwards(start, name) {
978
- let dir = path.resolve(start);
979
- for (let i = 0; i < 8; i++) {
980
- const candidate = path.join(dir, name);
981
- if (fs.existsSync(candidate)) return candidate;
982
- const parent = path.dirname(dir);
983
- if (parent === dir) return null;
984
- dir = parent;
985
- }
986
- return null;
987
- }
988
-
989
- async function findFiles(rootPath) {
990
- const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
991
-
992
- // Respect .gitignore patterns
993
- const gitignoreGlobs = loadGitignorePatterns(rootPath);
994
- globIgnore.push(...gitignoreGlobs);
995
-
996
- // Load .praxisignore — walk up to the project root so subdirectory scans
997
- // still honor the repo-level ignore file.
998
- const ignorePath = findUpwards(rootPath, '.praxisignore');
999
- if (ignorePath) {
1000
- try {
1001
- const patterns = fs.readFileSync(ignorePath, 'utf-8')
1002
- .split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
1003
- for (const p of patterns) {
1004
- if (p.endsWith('/')) { globIgnore.push(`**/${p}**`); }
1005
- else { globIgnore.push(`**/${p}`); globIgnore.push(p); }
1006
- }
1007
- } catch { /* skip */ }
1008
- }
1009
-
1010
- const files = await fg('**/*', {
1011
- cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
1012
- });
1013
-
1014
- return files.filter(file => {
1015
- const ext = path.extname(file).toLowerCase();
1016
- if (SKIP_EXTENSIONS.has(ext)) return false;
1017
- if (SKIP_FILENAMES.has(path.basename(file))) return false;
1018
- if (path.basename(file).endsWith('.min.js') || path.basename(file).endsWith('.min.css')) return false;
1019
- try { if (fs.statSync(file).size > MAX_FILE_SIZE) return false; } catch { return false; }
1020
- return true;
1021
- });
1022
- }
1023
-
1024
- function scanFileForSecrets(filePath) {
1025
- const findings = [];
1026
- try {
1027
- const content = fs.readFileSync(filePath, 'utf-8');
1028
- const lines = content.split('\n');
1029
- for (let lineNum = 0; lineNum < lines.length; lineNum++) {
1030
- const line = lines[lineNum];
1031
- if (/praxis-ignore/i.test(line)) continue;
1032
- for (const pattern of SECRET_PATTERNS) {
1033
- pattern.pattern.lastIndex = 0;
1034
- let match;
1035
- while ((match = pattern.pattern.exec(line)) !== null) {
1036
- if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
1037
- findings.push({
1038
- line: lineNum + 1, column: match.index + 1, matched: match[0],
1039
- patternName: pattern.name, severity: pattern.severity,
1040
- confidence: getConfidence(pattern, match[0]),
1041
- description: pattern.description, category: pattern.category || 'secret'
1042
- });
1043
- }
1044
- }
1045
- }
1046
- } catch { /* skip */ }
1047
-
1048
- const seen = new Set();
1049
- return findings.filter(f => {
1050
- const key = `${f.line}:${f.matched}`;
1051
- if (seen.has(key)) return false;
1052
- seen.add(key);
1053
- return true;
1054
- });
1055
- }
1056
-
1057
- function deduplicateFindings(findings) {
1058
- const seen = new Set();
1059
- return findings.filter(f => {
1060
- const key = `${f.file}:${f.line}:${f.rule}`;
1061
- if (seen.has(key)) return false;
1062
- seen.add(key);
1063
- return true;
1064
- });
1065
- }
1066
-
1067
- // =============================================================================
1068
- // SUPPRESSION COUNTING
1069
- // =============================================================================
1070
-
1071
- function countSuppressions(files) {
1072
- const suppressions = {};
1073
- let total = 0;
1074
- for (const file of files) {
1075
- try {
1076
- const content = fs.readFileSync(file, 'utf-8');
1077
- const lines = content.split('\n');
1078
- for (const line of lines) {
1079
- if (/praxis-ignore/i.test(line)) {
1080
- total++;
1081
- // Try to extract rule name from comment: praxis-ignore RULE_NAME
1082
- const match = line.match(/praxis-ignore\s+(\w+)/i);
1083
- const rule = match ? match[1] : '_unspecified';
1084
- suppressions[rule] = (suppressions[rule] || 0) + 1;
1085
- }
1086
- }
1087
- } catch { /* skip */ }
1088
- }
1089
- return total > 0 ? { total, rules: suppressions } : null;
1090
- }
1091
-
1092
- // =============================================================================
1093
- // CSV OUTPUT
1094
- // =============================================================================
1095
-
1096
- function outputCSV(findings, depVulns, scoreResult, rootPath) {
1097
- const escape = (s) => {
1098
- if (!s) return '';
1099
- const str = String(s).replace(/"/g, '""');
1100
- return str.includes(',') || str.includes('"') || str.includes('\n') ? `"${str}"` : str;
1101
- };
1102
-
1103
- console.log('severity,category,rule,file,line,title,description,fix');
1104
- for (const f of findings) {
1105
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
1106
- console.log([
1107
- escape(f.severity), escape(f.category), escape(f.rule),
1108
- escape(relFile), f.line || '', escape(f.title),
1109
- escape(f.description), escape(f.fix),
1110
- ].join(','));
1111
- }
1112
- for (const d of depVulns) {
1113
- console.log([
1114
- escape(d.severity), 'deps', escape(d.id || d.package),
1115
- 'package.json', '', escape(`Vulnerable: ${d.package || d.id}`),
1116
- escape(d.description), escape('Update to patched version'),
1117
- ].join(','));
1118
- }
1119
- }
1120
-
1121
- // =============================================================================
1122
- // MARKDOWN OUTPUT
1123
- // =============================================================================
1124
-
1125
- function outputMarkdown(scoreResult, findings, depVulns, remediationPlan, rootPath) {
1126
- const lines = [];
1127
- lines.push('# Praxis Security Report');
1128
- lines.push('');
1129
- lines.push(`**Score: ${scoreResult.score}/100 (${scoreResult.grade.letter})** — ${scoreResult.grade.label}`);
1130
- lines.push('');
1131
- lines.push(`> Generated: ${new Date().toISOString()}`);
1132
- lines.push('');
1133
-
1134
- // Category breakdown
1135
- lines.push('## Category Breakdown');
1136
- lines.push('');
1137
- lines.push('| Category | Issues | Deduction |');
1138
- lines.push('|----------|--------|-----------|');
1139
- for (const [, cat] of Object.entries(scoreResult.categories)) {
1140
- const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
1141
- lines.push(`| ${cat.label} | ${count} | -${cat.deduction} |`);
1142
- }
1143
- lines.push('');
1144
-
1145
- // Findings by severity
1146
- for (const sev of SEV_ORDER) {
1147
- const sevFindings = findings.filter(f => f.severity === sev);
1148
- if (sevFindings.length === 0) continue;
1149
-
1150
- lines.push(`## ${sev.charAt(0).toUpperCase() + sev.slice(1)} (${sevFindings.length})`);
1151
- lines.push('');
1152
- lines.push('| File | Rule | Description | Fix |');
1153
- lines.push('|------|------|-------------|-----|');
1154
- for (const f of sevFindings) {
1155
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
1156
- lines.push(`| ${relFile}:${f.line} | ${f.rule} | ${(f.description || '').slice(0, 80)} | ${(f.fix || '').slice(0, 60)} |`);
1157
- }
1158
- lines.push('');
1159
- }
1160
-
1161
- // Dep vulns
1162
- if (depVulns.length > 0) {
1163
- lines.push('## Dependency Vulnerabilities');
1164
- lines.push('');
1165
- lines.push('| Severity | Package | Description |');
1166
- lines.push('|----------|---------|-------------|');
1167
- for (const d of depVulns) {
1168
- lines.push(`| ${d.severity} | ${d.package || d.id} | ${(d.description || '').slice(0, 80)} |`);
1169
- }
1170
- lines.push('');
1171
- }
1172
-
1173
- console.log(lines.join('\n'));
1174
- }
1175
-
1176
- // =============================================================================
1177
- // COMPARISON OUTPUT
1178
- // =============================================================================
1179
-
1180
- function printComparison(scoringEngine, rootPath, scoreResult) {
1181
- const history = scoringEngine.loadHistory(rootPath);
1182
- if (history.length < 2) {
1183
- console.log(chalk.gray('\n No previous scan to compare against.'));
1184
- return;
1185
- }
1186
-
1187
- const prev = history[history.length - 2];
1188
- console.log();
1189
- console.log(chalk.cyan.bold(' Detailed Comparison'));
1190
- console.log(chalk.gray(' ' + '─'.repeat(56)));
1191
- console.log(chalk.gray(` Previous scan: ${new Date(prev.timestamp).toLocaleString()}`));
1192
- console.log();
1193
- console.log(chalk.white(' Category'.padEnd(26)) + chalk.white('Previous'.padEnd(12)) + chalk.white('Current'.padEnd(12)) + chalk.white('Delta'));
1194
- console.log(chalk.gray(' ' + '─'.repeat(56)));
1195
-
1196
- for (const [key, cat] of Object.entries(scoreResult.categories)) {
1197
- const prevCat = prev.categoryScores?.[key];
1198
- const prevDed = prevCat ? prevCat.deduction : 0;
1199
- const curDed = cat.deduction;
1200
- const delta = prevDed - curDed;
1201
-
1202
- let deltaStr;
1203
- if (delta > 0) deltaStr = chalk.green(`+${delta} improved`);
1204
- else if (delta < 0) deltaStr = chalk.red(`${delta} regressed`);
1205
- else deltaStr = chalk.gray('→ unchanged');
1206
-
1207
- console.log(
1208
- ` ${chalk.white(cat.label.padEnd(24))}` +
1209
- `${chalk.gray(String(-prevDed).padEnd(12))}` +
1210
- `${chalk.gray(String(-curDed).padEnd(12))}` +
1211
- deltaStr
1212
- );
1213
- }
1214
-
1215
- const overallDiff = scoreResult.score - prev.score;
1216
- let overallDelta;
1217
- if (overallDiff > 0) overallDelta = chalk.green(`+${overallDiff} improved`);
1218
- else if (overallDiff < 0) overallDelta = chalk.red(`${overallDiff} regressed`);
1219
- else overallDelta = chalk.gray('→ unchanged');
1220
-
1221
- console.log(chalk.gray(' ' + '─'.repeat(56)));
1222
- console.log(
1223
- ` ${chalk.white.bold('Overall'.padEnd(24))}` +
1224
- `${chalk.gray(`${prev.score}/100 ${prev.grade}`.padEnd(12))}` +
1225
- `${chalk.gray(`${scoreResult.score}/100 ${scoreResult.grade.letter}`.padEnd(12))}` +
1226
- overallDelta
1227
- );
1228
- }
1
+ /**
2
+ * Audit Command — Full Security Audit
3
+ * =====================================
4
+ *
5
+ * One command to run everything: secrets, agents, deps, score, and
6
+ * generate a comprehensive report with a prioritized remediation plan.
7
+ *
8
+ * USAGE:
9
+ * npx praxis-sec audit [path] Full audit with HTML report
10
+ * npx praxis-sec audit . --json JSON output
11
+ * npx praxis-sec audit . --html report.html Custom report path
12
+ * npx praxis-sec audit . --no-deps Skip dependency audit
13
+ */
14
+
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import { renderFindingsSARIF } from '../core/output/sarif.js';
18
+ import chalk from 'chalk';
19
+ import ora from 'ora';
20
+ import { displayPath } from '../core/paths.js';
21
+ import { validateDir } from '../core/fs.js';
22
+ import fg from '../core/glob.js';
23
+ import { buildOrchestrator, buildOrchestratorAsync } from '../agents/index.js';
24
+ import { LegalRiskAgent } from '../agents/legal-risk-agent.js';
25
+ import { ScoringEngine } from '../agents/scoring-engine.js';
26
+ import { PolicyEngine } from '../agents/policy-engine.js';
27
+ import { HTMLReporter } from '../agents/html-reporter.js';
28
+ import { SBOMGenerator } from '../agents/sbom-generator.js';
29
+ import { autoDetectProvider } from '../providers/llm-provider.js';
30
+ import { runDepsAudit } from './deps.js';
31
+ import {
32
+ SECRET_PATTERNS,
33
+ SECURITY_PATTERNS,
34
+ SKIP_DIRS,
35
+ SKIP_EXTENSIONS,
36
+ SKIP_FILENAMES,
37
+ MAX_FILE_SIZE,
38
+ loadGitignorePatterns
39
+ } from '../utils/patterns.js';
40
+ import { isHighEntropyMatch, getConfidence, isDocumentedSecretExample } from '../utils/entropy.js';
41
+ import { printBanner } from '../utils/output.js';
42
+ import { CacheManager } from '../utils/cache-manager.js';
43
+ import { filterBaseline } from './baseline.js';
44
+ import { SecurityMemory } from '../utils/security-memory.js';
45
+ import { ScanPlaybook } from '../utils/scan-playbook.js';
46
+ import { generatePDF, generatePrintHTML, isChromeAvailable } from '../utils/pdf-generator.js';
47
+ import { SecretsVerifier } from '../utils/secrets-verifier.js';
48
+ import { applyInlineAnnotations } from './autofix.js';
49
+ import { buildScanFingerprint } from '../utils/scan-fingerprint.js';
50
+ import { isGitUrl, cloneGitRepo, redactGitUrl } from '../core/git-clone.js';
51
+
52
+ // =============================================================================
53
+ // CONSTANTS
54
+ // =============================================================================
55
+
56
+ const ALL_PATTERNS = [...SECRET_PATTERNS, ...SECURITY_PATTERNS];
57
+
58
+ const SEV_ORDER = ['critical', 'high', 'medium', 'low'];
59
+
60
+ const CATEGORY_LABELS = {
61
+ secrets: 'Secrets',
62
+ injection: 'Code Vulnerabilities',
63
+ deps: 'Dependencies',
64
+ auth: 'Auth & Access Control',
65
+ config: 'Configuration',
66
+ 'supply-chain': 'Supply Chain',
67
+ api: 'API Security',
68
+ llm: 'AI/LLM Security',
69
+ legal: 'Legal Risk',
70
+ };
71
+
72
+ const EFFORT_MAP = {
73
+ secrets: 'low',
74
+ config: 'low',
75
+ deps: 'medium',
76
+ injection: 'medium',
77
+ auth: 'medium',
78
+ 'supply-chain': 'medium',
79
+ api: 'medium',
80
+ llm: 'high',
81
+ legal: 'low',
82
+ };
83
+
84
+ // =============================================================================
85
+ // MAIN COMMAND
86
+ // =============================================================================
87
+
88
+ export async function auditCommand(targetPath = '.', options = {}) {
89
+ const machineOutput = options.json || options.sarif || options.csv || options.md;
90
+ let gitClone = null;
91
+ let effectivePath = targetPath;
92
+
93
+ // ── Remote Git Repository Support ─────────────────────────────────────────
94
+ if (isGitUrl(targetPath)) {
95
+ if (!machineOutput) printBanner();
96
+ const gitSpinner = machineOutput ? null : ora({ text: chalk.cyan(`Cloning remote Git repository: ${redactGitUrl(targetPath)}...`), color: 'cyan' }).start();
97
+ try {
98
+ gitClone = cloneGitRepo(targetPath, {
99
+ branch: options.branch,
100
+ depth: options.depth,
101
+ gitToken: options.gitToken,
102
+ submodules: options.submodules,
103
+ gitHistory: options.gitHistory,
104
+ });
105
+ effectivePath = gitClone.tempDir;
106
+ if (gitSpinner) gitSpinner.succeed(chalk.green(`Cloned ${gitClone.repoName} (${gitClone.displayUrl}) to temporary workspace`));
107
+ } catch (err) {
108
+ if (gitSpinner) gitSpinner.fail(chalk.red(err.message));
109
+ else console.error(chalk.red(err.message));
110
+ process.exitCode = 1;
111
+ return;
112
+ }
113
+ }
114
+
115
+ try {
116
+ const absolutePath = validateDir(effectivePath, { exitOnMissing: false });
117
+
118
+ if (!absolutePath) {
119
+ console.error(chalk.red(' Full scans require an existing directory.'));
120
+ process.exitCode = 1;
121
+ return;
122
+ }
123
+
124
+ if (!machineOutput && !gitClone) {
125
+ printBanner();
126
+ }
127
+
128
+ // ── Cache Layer ──────────────────────────────────────────────────────────
129
+ // --deep forces a fresh scan so taint analysis covers every finding,
130
+ // not just the ones from files changed since the last cached run.
131
+ const useCache = options.cache !== false && !options.deep;
132
+ const cache = new CacheManager(absolutePath);
133
+ let cacheData = useCache ? cache.load() : null;
134
+ let cacheDiff = null;
135
+ let allFiles = [];
136
+ const scanErrors = [];
137
+
138
+ // ── Phase 1: Secret Scan ──────────────────────────────────────────────────
139
+ const secretSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 1/4] Scanning for secrets...'), color: 'cyan' }).start();
140
+ let secretFindings = [];
141
+ let filesScanned = 0;
142
+
143
+ try {
144
+ allFiles = await findFiles(absolutePath);
145
+ filesScanned = allFiles.length;
146
+
147
+ // Determine which files need scanning (incremental if cache exists)
148
+ let filesToScan = allFiles;
149
+ let cachedSecretFindings = [];
150
+
151
+ if (cacheData) {
152
+ cacheDiff = cache.diff(allFiles);
153
+ filesToScan = cacheDiff.changedFiles;
154
+ // Reuse cached findings for unchanged files (secrets only)
155
+ cachedSecretFindings = cacheDiff.cachedFindings.filter(
156
+ f => f.category === 'secrets' || f.category === 'secret'
157
+ );
158
+ }
159
+
160
+ for (const file of filesToScan) {
161
+ const fileResults = scanFileForSecrets(file);
162
+ for (const f of fileResults) {
163
+ secretFindings.push({
164
+ // Normalised here so secret findings are indistinguishable from agent
165
+ // findings by the time anything renders them.
166
+ file: displayPath(file, absolutePath),
167
+ line: f.line,
168
+ column: f.column,
169
+ severity: f.severity,
170
+ category: f.category || 'secrets',
171
+ rule: f.patternName,
172
+ title: f.patternName.replace(/_/g, ' '),
173
+ description: f.description,
174
+ matched: f.matched,
175
+ confidence: f.confidence,
176
+ fix: file.match(/\.env(\..*)?$/)
177
+ ? `Ensure .env is in .gitignore and use a secrets manager for production`
178
+ : `Move to environment variable or secrets manager`,
179
+ });
180
+ }
181
+ }
182
+
183
+ // Downgrade .env findings if the file is gitignored (properly managed)
184
+ const gitignoreContent = (() => {
185
+ try { return fs.readFileSync(path.join(absolutePath, '.gitignore'), 'utf-8'); } catch { return ''; }
186
+ })();
187
+ const envIsGitignored = gitignoreContent.split('\n')
188
+ .map(l => l.trim())
189
+ .some(l => /^\.env(\s|$)/.test(l) || l === '*.env' || l === '.env*' || l === '.env.local' || l === '.env.production');
190
+
191
+ if (envIsGitignored) {
192
+ for (const f of secretFindings) {
193
+ if (f.file.match(/\.env(\..*)?$/) && !f.file.includes('node_modules')) {
194
+ f.severity = 'low';
195
+ f.confidence = 'low';
196
+ f.fix = 'Already gitignored — ensure secrets manager is used for production deploys';
197
+ }
198
+ }
199
+ }
200
+
201
+ // Downgrade secrets in test files (intentional test fixtures)
202
+ const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/|\/fixtures?\/)/i;
203
+ for (const f of secretFindings) {
204
+ if (TEST_PATH.test(f.file)) {
205
+ f.confidence = 'low';
206
+ }
207
+ }
208
+
209
+ // Merge with cached findings for unchanged files
210
+ secretFindings = [...secretFindings, ...cachedSecretFindings];
211
+
212
+ const cacheNote = cacheDiff && cacheDiff.changedFiles.length < allFiles.length
213
+ ? ` (${cacheDiff.changedFiles.length} changed, ${cacheDiff.unchangedCount} cached)`
214
+ : '';
215
+
216
+ if (secretSpinner) secretSpinner.succeed(
217
+ secretFindings.length === 0
218
+ ? chalk.green(`[Phase 1/4] Secrets: clean${cacheNote}`)
219
+ : chalk.red(`[Phase 1/4] Secrets: ${secretFindings.length} found${cacheNote}`)
220
+ );
221
+ } catch (err) {
222
+ scanErrors.push({ phase: 'secrets', message: err.message });
223
+ if (secretSpinner) secretSpinner.fail(chalk.red(`[Phase 1/4] Secret scan failed: ${err.message}`));
224
+ }
225
+
226
+ // ── Phase 2: Agent Scan ───────────────────────────────────────────────────
227
+ const orchestrator = await buildOrchestratorAsync(absolutePath, { quiet: true, trustPlugins: !gitClone && options.trustPlugins === true });
228
+
229
+ // --hermes-only: filter to llm + supply-chain category agents only
230
+ if (options.hermesOnly && orchestrator.agents) {
231
+ const hermesCategories = new Set(['llm', 'supply-chain']);
232
+ orchestrator.agents = orchestrator.agents.filter(a =>
233
+ hermesCategories.has(a.category) || hermesCategories.has(a.constructor?.category)
234
+ );
235
+ }
236
+
237
+ const registeredAgentCount = orchestrator.agents?.length || 15;
238
+ const agentSpinner = machineOutput ? null : ora({ text: chalk.white(`[Phase 2/4] Running ${registeredAgentCount} security agents...`), color: 'cyan' }).start();
239
+ let agentFindings = [];
240
+ let recon = null;
241
+ let agentResults = [];
242
+
243
+ try {
244
+ // Suppress individual agent spinners by using quiet mode
245
+ // Pass changedFiles for incremental scanning if cache is valid
246
+ const orchestratorOpts = { quiet: true, timeout: options.timeout };
247
+ if (options.deep) orchestratorOpts.deep = true;
248
+ if (options.local) orchestratorOpts.local = true;
249
+ if (options.model) orchestratorOpts.model = options.model;
250
+ if (options.provider) orchestratorOpts.provider = options.provider;
251
+ if (options.baseUrl) orchestratorOpts.baseUrl = options.baseUrl;
252
+ if (options.budget) orchestratorOpts.budget = options.budget;
253
+ if (options.verbose) orchestratorOpts.verbose = true;
254
+ // Deliberately NOT passing `changedFiles` to the orchestrator.
255
+ //
256
+ // BaseAgent.getFilesToScan() returns `context.changedFiles || context.files`,
257
+ // so passing it makes an agent scan only changed files — and on an unchanged
258
+ // tree that list is empty, so the agent reports nothing at all. The cache
259
+ // cannot cover for that: the restore below is filtered to
260
+ // `category === 'secrets'`, so agent findings for unchanged files were never
261
+ // coming back. A warm scan silently lost every finding from
262
+ // PromptInjectionProberAgent, VibeCodingAgent and ExceptionHandlerAgent —
263
+ // 9 findings on this tree, under-reporting on the second run of any project.
264
+ //
265
+ // 25 of 28 agents already ignore `changedFiles` and read `context.files`, so
266
+ // the hint only ever narrowed three of them while making them wrong. Phase 1
267
+ // (secrets) is where the cache actually pays for itself, and it still uses
268
+ // it. Passing an unsafe optimisation is worse than not optimising.
269
+ const results = await orchestrator.runAll(absolutePath, orchestratorOpts); // praxis-ignore — orchestrator result, not LLM output triggering actions
270
+ recon = results.recon;
271
+ agentFindings = results.findings;
272
+ agentResults = results.agentResults;
273
+ for (const agent of agentResults.filter(agent => !agent.success)) {
274
+ scanErrors.push({ phase: 'agents', message: `${agent.agent}: ${agent.error || 'scan failed'}` });
275
+ }
276
+
277
+ const totalAgentFindings = agentFindings.length;
278
+ const agentCount = agentResults.filter(a => a.success).length;
279
+ if (agentSpinner) agentSpinner.succeed(
280
+ totalAgentFindings === 0
281
+ ? chalk.green(`[Phase 2/4] ${agentCount} agents: clean`)
282
+ : chalk.yellow(`[Phase 2/4] ${agentCount} agents: ${totalAgentFindings} finding(s)`)
283
+ );
284
+ } catch (err) {
285
+ scanErrors.push({ phase: 'agents', message: err.message });
286
+ if (agentSpinner) agentSpinner.fail(chalk.red(`[Phase 2/4] Agent scan failed: ${err.message}`));
287
+ }
288
+
289
+ // ── Phase 3: Dependency Audit ─────────────────────────────────────────────
290
+ let depVulns = [];
291
+ let dependencyAudit = options.deps === false ? 'skipped' : 'not-applicable';
292
+ if (options.deps !== false) {
293
+ const depSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 3/4] Auditing dependencies...'), color: 'cyan' }).start();
294
+ try {
295
+ const depResult = await runDepsAudit(absolutePath);
296
+ depVulns = depResult.vulns || [];
297
+ dependencyAudit = depResult.error ? 'failed' : (depResult.pm ? 'complete' : 'not-applicable');
298
+ if (depResult.error) {
299
+ scanErrors.push({ phase: 'dependencies', message: depResult.error });
300
+ if (depSpinner) depSpinner.fail(chalk.red('[Phase 3/4] Dependency audit incomplete'));
301
+ } else if (depSpinner) depSpinner.succeed(
302
+ depVulns.length === 0
303
+ ? chalk.green('[Phase 3/4] Dependencies: clean')
304
+ : chalk.red(`[Phase 3/4] Dependencies: ${depVulns.length} CVE(s)`)
305
+ );
306
+ } catch (err) {
307
+ dependencyAudit = 'failed';
308
+ scanErrors.push({ phase: 'dependencies', message: err.message });
309
+ if (depSpinner) depSpinner.fail(chalk.red('[Phase 3/4] Dependency audit incomplete'));
310
+ }
311
+ } else if (!machineOutput) {
312
+ console.log(chalk.gray(' [Phase 3/4] Dependencies: skipped (--no-deps)'));
313
+ }
314
+
315
+ // ── Phase 3b: Legal Risk Scan (opt-in) ───────────────────────────────────
316
+ let legalFindings = [];
317
+ if (options.includeLegal) {
318
+ const legalSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 3b] Legal risk scan…'), color: 'cyan' }).start();
319
+ try {
320
+ const legalAgent = new LegalRiskAgent();
321
+ legalFindings = await legalAgent.analyze({ rootPath: absolutePath, files: allFiles });
322
+ if (legalSpinner) legalSpinner.succeed(
323
+ legalFindings.length === 0
324
+ ? chalk.green('[Phase 3b] Legal: clean')
325
+ : chalk.yellow(`[Phase 3b] Legal: ${legalFindings.length} finding(s)`)
326
+ );
327
+ } catch (err) {
328
+ scanErrors.push({ phase: 'legal', message: err.message });
329
+ if (legalSpinner) legalSpinner.fail(chalk.red('[Phase 3b] Legal scan incomplete'));
330
+ }
331
+ }
332
+
333
+ // ── Phase 4: Merge, Score, and Build Plan ─────────────────────────────────
334
+ const scoreSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 4/4] Computing security score...'), color: 'cyan' }).start();
335
+
336
+ // Merge secret findings + agent findings + legal findings, deduplicate
337
+ const allFindings = deduplicateFindings([...secretFindings, ...agentFindings, ...legalFindings]);
338
+
339
+ // Apply policy
340
+ const policy = PolicyEngine.load(absolutePath);
341
+ let filteredFindings = policy.applyPolicy(allFindings);
342
+
343
+ // Apply baseline filter (only show new findings)
344
+ if (options.baseline) {
345
+ const beforeCount = filteredFindings.length;
346
+ filteredFindings = filterBaseline(filteredFindings, absolutePath);
347
+ if (!machineOutput && beforeCount !== filteredFindings.length) {
348
+ console.log(chalk.gray(` Baseline: ${beforeCount - filteredFindings.length} known finding(s) filtered, ${filteredFindings.length} new`));
349
+ }
350
+ }
351
+
352
+ // ── Security Memory Filter ──────────────────────────────────────────────
353
+ // Auto-learn false positives from deep analysis results, then suppress
354
+ // any finding that memory recognises from a previous scan.
355
+ const secMemory = new SecurityMemory(absolutePath);
356
+ if (options.deep) {
357
+ // After deep analysis ran, learn any new false positives
358
+ const newFPs = secMemory.learnFromAnalysis(filteredFindings);
359
+ if (newFPs > 0 && !machineOutput) {
360
+ console.log(chalk.gray(` Memory: ${newFPs} new false positive(s) learned and will be suppressed in future scans`));
361
+ }
362
+ }
363
+ const { kept: memFiltered, suppressedCount: memSuppressed } = secMemory.filter(filteredFindings);
364
+ filteredFindings = memFiltered;
365
+ if (memSuppressed > 0 && !machineOutput) {
366
+ console.log(chalk.gray(` Memory: ${memSuppressed} previously-confirmed false positive(s) suppressed`));
367
+ }
368
+
369
+ // Count suppressions (praxis-ignore comments)
370
+ const suppressions = countSuppressions(allFiles);
371
+
372
+ // Score
373
+ const scoringEngine = new ScoringEngine();
374
+ const scoreResult = scoringEngine.compute(filteredFindings, depVulns);
375
+ const scanComplete = scanErrors.length === 0;
376
+ scoreResult.scanComplete = scanComplete;
377
+ scoreResult.scanErrors = scanErrors;
378
+ scoreResult.dependencyAudit = dependencyAudit;
379
+ if (!scanComplete) {
380
+ console.error('[praxis] Scan incomplete; the score reflects only available results.');
381
+ for (const error of scanErrors) console.error(`[praxis] ${error.phase}: ${error.message}`);
382
+ }
383
+ // Round score to 1 decimal place to avoid floating-point noise (e.g., 63.300000000000004)
384
+ scoreResult.score = Math.round(scoreResult.score * 10) / 10;
385
+ if (scanComplete) scoringEngine.saveToHistory(absolutePath, scoreResult, suppressions);
386
+
387
+ // Score and filtered findings must exist before the best-effort playbook update.
388
+ try {
389
+ if (scanComplete) {
390
+ const playbook = new ScanPlaybook(absolutePath);
391
+ const suppressedRules = secMemory.list().map(entry => entry.rule).filter(Boolean);
392
+ playbook.update(recon, { score: scoreResult.score, grade: scoreResult.grade?.letter || scoreResult.grade, totalFindings: filteredFindings.length }, filteredFindings, suppressedRules);
393
+ }
394
+ } catch { /* non-fatal */ }
395
+
396
+ const gradeColor = scoreResult.score >= 75 ? chalk.green.bold : scoreResult.score >= 60 ? chalk.yellow.bold : chalk.red.bold;
397
+ if (scoreSpinner) scoreSpinner.succeed(
398
+ chalk.white('[Phase 4/4] Score: ') + gradeColor(`${scoreResult.score}/100 ${scoreResult.grade.letter}`)
399
+ );
400
+
401
+ // ── AI Classification (optional, with LLM cache) ───────────────────────
402
+ if (options.ai !== false && !options.noAi) {
403
+ const provider = autoDetectProvider(absolutePath, {
404
+ provider: options.provider,
405
+ baseUrl: options.baseUrl,
406
+ model: options.model,
407
+ think: options.think || false,
408
+ });
409
+ if (provider && filteredFindings.length > 0 && filteredFindings.length <= 50) {
410
+ const aiSpinner = machineOutput ? null : ora({ text: `Classifying with ${provider.name}...`, color: 'cyan' }).start();
411
+ try {
412
+ // Check LLM cache for existing classifications
413
+ const llmCache = cache.loadLLMClassifications();
414
+ const uncachedFindings = [];
415
+ let cachedCount = 0;
416
+
417
+ for (const finding of filteredFindings) {
418
+ const key = cache.getLLMCacheKey(finding);
419
+ const cached = llmCache[key];
420
+ if (cached) {
421
+ finding.aiClassification = cached.classification;
422
+ finding.aiReason = cached.reason;
423
+ finding.aiFix = cached.fix;
424
+ cachedCount++;
425
+ } else {
426
+ uncachedFindings.push(finding);
427
+ }
428
+ }
429
+
430
+ // Only send uncached findings to LLM
431
+ if (uncachedFindings.length > 0) {
432
+ const classifications = await provider.classify(uncachedFindings);
433
+ const newCacheEntries = {};
434
+ for (const cl of classifications) {
435
+ const finding = filteredFindings.find(f => `${f.file}:${f.line}` === cl.id);
436
+ if (finding) {
437
+ finding.aiClassification = cl.classification;
438
+ finding.aiReason = cl.reason;
439
+ finding.aiFix = cl.fix;
440
+ const key = cache.getLLMCacheKey(finding);
441
+ newCacheEntries[key] = {
442
+ classification: cl.classification,
443
+ reason: cl.reason,
444
+ fix: cl.fix,
445
+ cachedAt: new Date().toISOString(),
446
+ };
447
+ }
448
+ }
449
+ cache.saveLLMClassifications(newCacheEntries);
450
+ }
451
+
452
+ const cacheNote = cachedCount > 0 ? `, ${cachedCount} cached` : '';
453
+ if (aiSpinner) aiSpinner.succeed(chalk.green(`AI classification complete (${provider.name}${cacheNote})`));
454
+ } catch (err) {
455
+ if (aiSpinner) aiSpinner.fail(chalk.yellow(`AI classification failed: ${err.message}`));
456
+ }
457
+ }
458
+ }
459
+
460
+ // ── Secrets Verification (optional, --verify flag) ─────────────────────
461
+ if (options.verify) {
462
+ const verifySpinner = machineOutput ? null : ora({ text: 'Verifying leaked secrets against provider APIs...', color: 'cyan' }).start();
463
+ try {
464
+ const verifier = new SecretsVerifier();
465
+ const verifyResults = await verifier.verify(filteredFindings);
466
+ const activeCount = verifyResults.filter(r => r.result.active === true).length;
467
+ const inactiveCount = verifyResults.filter(r => r.result.active === false).length;
468
+ if (verifySpinner) {
469
+ verifySpinner.succeed(chalk.green(
470
+ `Secrets verified: ${activeCount} active, ${inactiveCount} inactive, ${verifyResults.length - activeCount - inactiveCount} unknown`
471
+ ));
472
+ }
473
+ // Show active secrets warning
474
+ if (activeCount > 0 && !machineOutput) {
475
+ console.log(chalk.red.bold(' ⚠ ACTIVE SECRETS DETECTED — rotate immediately:'));
476
+ for (const r of verifyResults.filter(r => r.result.active === true)) {
477
+ const rel = path.relative(absolutePath, r.finding.file).replace(/\\/g, '/');
478
+ console.log(chalk.red(` ${r.result.provider}: ${rel}:${r.finding.line} — ${r.result.info}`));
479
+ }
480
+ }
481
+ } catch (err) {
482
+ if (verifySpinner) verifySpinner.fail(chalk.yellow(`Secrets verification failed: ${err.message}`));
483
+ }
484
+ }
485
+
486
+ // ── Save Cache ──────────────────────────────────────────────────────────
487
+ if (useCache && scanComplete) {
488
+ try {
489
+ // Merge agent findings back for cache (secret + agent findings from changed files)
490
+ // plus cached findings from unchanged files
491
+ const cachedAgentFindings = cacheData && cacheDiff
492
+ ? cacheDiff.cachedFindings.filter(f => f.category !== 'secrets' && f.category !== 'secret')
493
+ : [];
494
+ const allFindingsForCache = [...secretFindings, ...agentFindings, ...cachedAgentFindings];
495
+ cache.save(allFiles, deduplicateFindings(allFindingsForCache), recon, scoreResult);
496
+ } catch {
497
+ // Silent — caching should never break a scan
498
+ }
499
+ }
500
+
501
+ // ── Build Remediation Plan ────────────────────────────────────────────────
502
+ const remediationPlan = buildRemediationPlan(filteredFindings, depVulns, absolutePath);
503
+
504
+ // Skip all output and file generation for inner agentic re-scans
505
+ if (options._agenticInner) {
506
+ return { score: scoreResult.score, findings: filteredFindings, scanComplete, scanErrors, dependencyAudit };
507
+ }
508
+
509
+ // ── Agentic Loop (--agentic) ────────────────────────────────────────────
510
+ // Scan → annotate fixes → re-scan cycle until score >= target or maxIter.
511
+ // NOTE: process.exit() is deferred until after the loop so all iterations
512
+ // can run. The inner re-scans use _agenticInner: true to skip process.exit.
513
+ let verificationComplete = true;
514
+ if (options.agentic && !options._agenticInner && scanComplete) {
515
+ const maxIter = typeof options.agentic === 'number' ? options.agentic : 3;
516
+ const targetScore = options.agenticTarget ?? 75;
517
+ let iteration = 1;
518
+ let currentScore = scoreResult.score;
519
+ let currentFindings = filteredFindings;
520
+
521
+ if (!machineOutput) {
522
+ console.log();
523
+ console.log(chalk.cyan.bold(` Agentic mode: scan→fix→verify loop (max ${maxIter} iterations, target score: ${targetScore})`));
524
+ }
525
+
526
+ while (currentScore < targetScore && iteration <= maxIter) {
527
+ if (!machineOutput) {
528
+ console.log(chalk.cyan(`\n ─── Agentic iteration ${iteration}/${maxIter} (current score: ${currentScore}) ───`));
529
+ }
530
+
531
+ const actionable = currentFindings.filter(f => f.fix && f.severity !== 'low');
532
+ if (actionable.length === 0) {
533
+ if (!machineOutput) console.log(chalk.gray(' No auto-fixable findings — stopping agentic loop.'));
534
+ break;
535
+ }
536
+
537
+ // Delegate annotation to autofix module (handles comment style, idempotency, NEVER_EDIT list)
538
+ const fixCount = applyInlineAnnotations(actionable, absolutePath);
539
+ if (!machineOutput) {
540
+ console.log(chalk.yellow(` Annotated ${fixCount} finding(s). Re-scanning...`));
541
+ }
542
+ if (fixCount === 0) break;
543
+
544
+ // Re-scan without recursing into the agentic loop or calling process.exit
545
+ const innerResult = await runAuditInner(targetPath, {
546
+ ...options,
547
+ agentic: false,
548
+ _agenticInner: true,
549
+ json: true,
550
+ sarif: false,
551
+ csv: false,
552
+ md: false,
553
+ html: false,
554
+ pdf: false,
555
+ quiet: true,
556
+ });
557
+
558
+ const prevScore = currentScore;
559
+ if (innerResult?.scanComplete !== true) {
560
+ console.error('[praxis] Agentic loop stopped: verification scan incomplete.');
561
+ verificationComplete = false;
562
+ scoreResult.scanComplete = false;
563
+ scanErrors.push({ phase: 'verification', message: 'Agentic verification scan incomplete' });
564
+ process.exitCode = 1;
565
+ break;
566
+ }
567
+ currentScore = innerResult?.score ?? currentScore;
568
+ currentFindings = innerResult?.findings ?? currentFindings;
569
+
570
+ if (!machineOutput) {
571
+ const diff = currentScore - prevScore;
572
+ const arrow = diff > 0 ? chalk.green(`↑ +${diff.toFixed(1)}`) : diff < 0 ? chalk.red(`↓ ${diff.toFixed(1)}`) : chalk.gray('→ 0');
573
+ console.log(chalk.cyan(` Re-scan score: ${currentScore} ${arrow}`));
574
+ }
575
+
576
+ iteration++;
577
+ }
578
+
579
+ if (!machineOutput) {
580
+ if (verificationComplete && currentScore >= targetScore) {
581
+ console.log(chalk.green.bold(`\n Agentic loop complete — target score ${targetScore} reached (${currentScore}).`));
582
+ } else {
583
+ console.log(chalk.yellow(`\n Agentic loop stopped after ${iteration - 1} iteration(s). Final score: ${currentScore}`));
584
+ }
585
+ }
586
+ }
587
+
588
+ // ── Output ────────────────────────────────────────────────────────────────
589
+ if (!machineOutput) console.log();
590
+
591
+ if (options.csv) {
592
+ outputCSV(filteredFindings, depVulns, scoreResult, absolutePath);
593
+ } else if (options.md) {
594
+ outputMarkdown(scoreResult, filteredFindings, depVulns, remediationPlan, absolutePath);
595
+ } else if (options.json) {
596
+ outputJSON(scoreResult, filteredFindings, depVulns, recon, agentResults, remediationPlan, suppressions, options.compare ? scoringEngine.loadHistory(absolutePath) : null, filesScanned, absolutePath);
597
+ } else if (options.sarif) {
598
+ outputSARIF(filteredFindings, absolutePath);
599
+ } else {
600
+ printReport(scoreResult, filteredFindings, depVulns, recon, remediationPlan, absolutePath, filesScanned);
601
+ }
602
+
603
+ // ── HTML Report — always on for human output, or when --html is explicit ──
604
+ const explicitHtml = typeof options.html === 'string' || options.html === true;
605
+ if (explicitHtml || (!options.json && !options.sarif && !options.csv && !options.md)) {
606
+ const htmlPath = typeof options.html === 'string' ? options.html : 'praxis-report.html';
607
+ const reporter = new HTMLReporter();
608
+ reporter.generateToFile(scoreResult, filteredFindings, recon, absolutePath, htmlPath, agentResults, filesScanned);
609
+
610
+ // If --html-dir or --suite specified, also generate granular multi-page report suite
611
+ if (options.htmlDir || options['html-dir'] || options.suite) {
612
+ const suiteDir = typeof (options.htmlDir || options['html-dir'] || options.suite) === 'string'
613
+ ? (options.htmlDir || options['html-dir'] || options.suite)
614
+ : 'report';
615
+ reporter.generateReportSuite(scoreResult, filteredFindings, depVulns, recon, remediationPlan, absolutePath, suiteDir, agentResults, filesScanned);
616
+ }
617
+
618
+ // Keep stdout pure JSON/SARIF when combined with machine output
619
+ const note = machineOutput ? console.error : console.log;
620
+ note();
621
+ note(chalk.cyan(` Full report: ${chalk.white.bold(htmlPath)}`));
622
+ note(chalk.gray(` Dashboard: `) + chalk.cyan(''));
623
+
624
+ // PDF export
625
+ if (options.pdf) {
626
+ const pdfPath = typeof options.pdf === 'string' ? options.pdf : 'praxis-report.pdf';
627
+ const result = generatePDF(path.resolve(htmlPath), path.resolve(pdfPath));
628
+ if (result) {
629
+ note(chalk.cyan(` PDF report: ${chalk.white.bold(pdfPath)}`));
630
+ } else {
631
+ // Fallback: print-optimized HTML
632
+ const fallbackPath = pdfPath.replace(/\.pdf$/, '.print.html');
633
+ generatePrintHTML(path.resolve(htmlPath), path.resolve(fallbackPath));
634
+ note(chalk.yellow(` Chrome not found — saved print-optimized HTML: ${fallbackPath}`));
635
+ note(chalk.gray(' Open in a browser and Print → Save as PDF'));
636
+ }
637
+ }
638
+ }
639
+
640
+ if (!machineOutput && !options.csv && !options.md) {
641
+ // ── Policy Violations ──────────────────────────────────────────────────
642
+ const violations = policy.evaluate(scoreResult, filteredFindings, {
643
+ agentResults,
644
+ depsRun: options.deps !== false,
645
+ secretsRun: true,
646
+ depVulns,
647
+ });
648
+ if (violations.length > 0) {
649
+ console.log();
650
+ console.log(chalk.red.bold(' Policy Violations:'));
651
+ for (const v of violations.slice(0, 5)) {
652
+ console.log(chalk.red(` ✗ ${v.message}`));
653
+ }
654
+ }
655
+
656
+ // ── Trend ───────────────────────────────────────────────────────────────
657
+ const trend = scoringEngine.getTrend(absolutePath, scoreResult.score);
658
+ if (trend) {
659
+ const arrow = trend.diff > 0 ? chalk.green('↑') : trend.diff < 0 ? chalk.red('↓') : chalk.gray('→');
660
+ const roundedDiff = Math.round(trend.diff * 10) / 10;
661
+ const diffLabel = roundedDiff === 0 ? chalk.gray('no change') : chalk.white(`${roundedDiff > 0 ? '+' : ''}${roundedDiff}`);
662
+ console.log(chalk.gray(` Trend: ${trend.previousScore} → ${trend.currentScore} ${arrow} (`) + diffLabel + chalk.gray(')'));
663
+ }
664
+
665
+ // ── Detailed Comparison ────────────────────────────────────────────────
666
+ if (options.compare) {
667
+ printComparison(scoringEngine, absolutePath, scoreResult);
668
+ }
669
+
670
+ console.log();
671
+ console.log(chalk.cyan('═'.repeat(60)));
672
+ console.log();
673
+ }
674
+
675
+ // ── Exit code logic ─────────────────────────────────────────────────────
676
+ // Only gate the exit code when --fail-below is explicitly requested;
677
+ // a plain scan reports findings and exits 0 (CI gating belongs to `scan ci`).
678
+ // Use process.exitCode (natural event-loop drain) instead of process.exit()
679
+ // so in-flight libuv handles (plugin dynamic imports, etc.) close cleanly.
680
+ if (!scanComplete || !verificationComplete) process.exitCode = 1;
681
+ if (scanComplete && verificationComplete && options.failBelow !== undefined) {
682
+ let threshold = 75;
683
+ if (options.failBelow === 'baseline') {
684
+ // Read baseline score from .praxis/hermes-baseline.json
685
+ const baselinePath = path.join(absolutePath, '.praxis', 'hermes-baseline.json');
686
+ try {
687
+ const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
688
+ threshold = baseline.score || 0;
689
+ if (!machineOutput) {
690
+ console.log(chalk.gray(` Baseline threshold: ${threshold}/100 (from ${baselinePath})`));
691
+ }
692
+ } catch {
693
+ if (!machineOutput) {
694
+ console.log(chalk.yellow(` Warning: could not read baseline — using score 0 as threshold`));
695
+ }
696
+ threshold = 0;
697
+ }
698
+ } else {
699
+ const parsed = parseInt(options.failBelow, 10);
700
+ threshold = Number.isNaN(parsed) ? 75 : parsed;
701
+ }
702
+ process.exitCode = scoreResult.score >= threshold ? 0 : 1;
703
+ }
704
+ } finally {
705
+ if (gitClone) {
706
+ if (!options.keepClone) {
707
+ gitClone.cleanup();
708
+ } else if (!machineOutput) {
709
+ console.log(chalk.gray(`\n Cloned repository retained at: ${gitClone.tempDir}`));
710
+ }
711
+ }
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Run a lightweight inner audit that returns { score, findings } without
717
+ * calling process.exit(). Used exclusively by the --agentic loop.
718
+ */
719
+ async function runAuditInner(targetPath, options) {
720
+ try {
721
+ return await auditCommand(targetPath, options);
722
+ } catch {
723
+ return null;
724
+ }
725
+ }
726
+
727
+ // =============================================================================
728
+ // REMEDIATION PLAN BUILDER
729
+ // =============================================================================
730
+
731
+ function buildRemediationPlan(findings, depVulns, rootPath) {
732
+ const plan = [];
733
+ let priority = 1;
734
+
735
+ // Exclude low-confidence findings (test files, docs, comments) from remediation plan
736
+ const actionable = findings.filter(f => f.confidence !== 'low');
737
+
738
+ // Priority order: secrets first, then by severity
739
+ const secretFindings = actionable.filter(f => f.category === 'secrets' || f.category === 'secret');
740
+ const otherFindings = actionable.filter(f => f.category !== 'secrets' && f.category !== 'secret');
741
+
742
+ // Group and sort
743
+ for (const sev of SEV_ORDER) {
744
+ // Secrets at this severity — group .env findings by file
745
+ const sevSecrets = secretFindings.filter(s => s.severity === sev);
746
+ const envGroups = new Map();
747
+ const nonEnvSecrets = [];
748
+
749
+ for (const f of sevSecrets) {
750
+ const relFile = displayPath(f.file, rootPath);
751
+ if (f.file.match(/\.env(\..*)?$/)) {
752
+ if (!envGroups.has(relFile)) envGroups.set(relFile, []);
753
+ envGroups.get(relFile).push(f);
754
+ } else {
755
+ nonEnvSecrets.push(f);
756
+ }
757
+ }
758
+
759
+ // One plan item per .env file
760
+ for (const [relFile, envFindings] of envGroups) {
761
+ const names = envFindings.map(f => f.title || f.rule).join(', ');
762
+ plan.push({
763
+ priority: priority++,
764
+ severity: sev,
765
+ category: 'secrets',
766
+ categoryLabel: 'SECRETS',
767
+ title: `${envFindings.length} secret${envFindings.length > 1 ? 's' : ''} in ${relFile} (${names})`,
768
+ file: relFile,
769
+ action: envFindings[0].fix || 'Ensure .env is in .gitignore and use a secrets manager for production',
770
+ effort: 'low',
771
+ });
772
+ }
773
+
774
+ // Individual items for non-.env secrets
775
+ for (const f of nonEnvSecrets) {
776
+ plan.push({
777
+ priority: priority++,
778
+ severity: sev,
779
+ category: 'secrets',
780
+ categoryLabel: 'SECRETS',
781
+ title: f.title || f.rule,
782
+ file: `${displayPath(f.file, rootPath)}:${f.line}`,
783
+ action: f.aiFix || f.fix || f.description,
784
+ effort: 'low',
785
+ });
786
+ }
787
+
788
+ // Other findings at this severity
789
+ for (const f of otherFindings.filter(s => s.severity === sev)) {
790
+ plan.push({
791
+ priority: priority++,
792
+ severity: sev,
793
+ category: f.category,
794
+ categoryLabel: (CATEGORY_LABELS[f.category] || f.category).toUpperCase(),
795
+ title: f.title || f.rule,
796
+ file: `${displayPath(f.file, rootPath)}:${f.line}`,
797
+ action: f.aiFix || f.fix || f.description,
798
+ effort: EFFORT_MAP[f.category] || 'medium',
799
+ });
800
+ }
801
+
802
+ // Dep vulns at this severity
803
+ for (const d of depVulns.filter(v => v.severity === sev || (sev === 'medium' && v.severity === 'moderate'))) {
804
+ plan.push({
805
+ priority: priority++,
806
+ severity: sev,
807
+ category: 'deps',
808
+ categoryLabel: 'DEPENDENCIES',
809
+ title: `Vulnerable: ${d.package || d.id}`,
810
+ file: 'package.json',
811
+ action: d.description ? `${d.description.slice(0, 80)}` : 'Update to patched version',
812
+ effort: 'medium',
813
+ });
814
+ }
815
+ }
816
+
817
+ return plan;
818
+ }
819
+
820
+ // =============================================================================
821
+ // CONSOLE OUTPUT
822
+ // =============================================================================
823
+
824
+ function printReport(scoreResult, findings, depVulns, recon, plan, rootPath, filesScanned) {
825
+ const GRADE_COLOR = { A: chalk.green.bold, B: chalk.cyan.bold, C: chalk.yellow.bold, D: chalk.red, F: chalk.red.bold };
826
+ const SEV_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
827
+ const SEV_LABEL = { critical: 'CRITICAL — fix immediately', high: 'HIGH — fix before deploy', medium: 'MEDIUM — fix soon', low: 'LOW — review when possible' };
828
+
829
+ // ── Score ─────────────────────────────────────────────────────────────────
830
+ const gradeColor = GRADE_COLOR[scoreResult.grade.letter] || chalk.white;
831
+ const scoreColor = scoreResult.score >= 75 ? chalk.green.bold : scoreResult.score >= 60 ? chalk.yellow.bold : chalk.red.bold;
832
+
833
+ console.log(chalk.cyan(' ' + '═'.repeat(56)));
834
+ console.log(
835
+ chalk.white.bold(' Security Score: ') +
836
+ scoreColor(`${scoreResult.score}/100 `) +
837
+ gradeColor(scoreResult.grade.letter) +
838
+ chalk.gray(` — ${scoreResult.grade.label}`)
839
+ );
840
+ console.log(chalk.cyan(' ' + '═'.repeat(56)));
841
+ console.log();
842
+
843
+ // ── Category Breakdown ────────────────────────────────────────────────────
844
+ console.log(chalk.white.bold(' Category Breakdown'));
845
+ console.log(chalk.gray(' ' + '─'.repeat(56)));
846
+
847
+ for (const [key, cat] of Object.entries(scoreResult.categories)) {
848
+ const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
849
+ const icon = count === 0 ? chalk.green('✔') : chalk.red('✘');
850
+ const status = count === 0 ? chalk.green('clean') : chalk.red(`${count} issue(s)`);
851
+ const deduction = cat.deduction > 0 ? chalk.red(`-${Math.round(cat.deduction * 10) / 10} pts`) : chalk.gray('+0');
852
+ console.log(` ${icon} ${chalk.white(cat.label.padEnd(22))} ${status.padEnd(25)} ${deduction}`);
853
+ }
854
+
855
+ // Deps row — only print if not already included in scoreResult.categories
856
+ const hasDepsCategory = Object.values(scoreResult.categories).some(c => c.label?.toLowerCase().includes('depend'));
857
+ if (!hasDepsCategory) {
858
+ const depIcon = depVulns.length === 0 ? chalk.green('✔') : chalk.red('✘');
859
+ const depStatus = depVulns.length === 0 ? chalk.green('clean') : chalk.red(`${depVulns.length} CVE(s)`);
860
+ console.log(` ${depIcon} ${chalk.white('Dependencies'.padEnd(22))} ${depStatus}`);
861
+ }
862
+
863
+ console.log(chalk.gray(`\n Files scanned: ${filesScanned} | Findings: ${findings.length} | CVEs: ${depVulns.length}`));
864
+
865
+ // ── Remediation Plan ──────────────────────────────────────────────────────
866
+ if (plan.length > 0) {
867
+ console.log();
868
+ console.log(chalk.cyan(' ' + '═'.repeat(56)));
869
+ console.log(chalk.cyan.bold(' Remediation Plan'));
870
+ console.log(chalk.cyan(' ' + '═'.repeat(56)));
871
+
872
+ let currentSev = null;
873
+ let shown = 0;
874
+ const maxItems = 30;
875
+
876
+ for (const item of plan) {
877
+ if (shown >= maxItems) {
878
+ console.log(chalk.gray(`\n ... and ${plan.length - maxItems} more items in the full report`));
879
+ break;
880
+ }
881
+
882
+ if (item.severity !== currentSev) {
883
+ currentSev = item.severity;
884
+ console.log();
885
+ console.log(chalk.white.bold(` ${SEV_ICON[currentSev] || '⚪'} ${SEV_LABEL[currentSev] || currentSev.toUpperCase()}`));
886
+ console.log(chalk.gray(' ' + '─'.repeat(56)));
887
+ }
888
+
889
+ console.log(
890
+ chalk.white(` ${String(item.priority).padStart(2)}.`) +
891
+ chalk.gray(` [${item.categoryLabel}] `) +
892
+ chalk.white(item.title)
893
+ );
894
+ console.log(
895
+ chalk.gray(` ${item.file}`) +
896
+ chalk.gray(' → ') +
897
+ chalk.green((item.action || '').slice(0, 70))
898
+ );
899
+ shown++;
900
+ }
901
+ } else {
902
+ console.log();
903
+ console.log(chalk.green.bold(' All clear — safe to ship!'));
904
+ }
905
+
906
+ // ── Attack Surface ────────────────────────────────────────────────────────
907
+ if (recon) {
908
+ console.log();
909
+ console.log(chalk.gray(' Attack Surface:'));
910
+ if (recon.frameworks?.length) console.log(chalk.gray(` Frameworks: ${recon.frameworks.join(', ')}`));
911
+ if (recon.databases?.length) console.log(chalk.gray(` Databases: ${recon.databases.join(', ')}`));
912
+ if (recon.authPatterns?.length) console.log(chalk.gray(` Auth: ${recon.authPatterns.join(', ')}`));
913
+ if (recon.apiRoutes?.length) console.log(chalk.gray(` API Routes: ${recon.apiRoutes.length} discovered`));
914
+ }
915
+ }
916
+
917
+ // =============================================================================
918
+ // JSON OUTPUT
919
+ // =============================================================================
920
+
921
+ function outputJSON(scoreResult, findings, depVulns, recon, agentResults, remediationPlan, suppressions, history, filesScanned = null, absolutePath = process.cwd()) {
922
+ const output = {
923
+ score: scoreResult.score,
924
+ scanComplete: scoreResult.scanComplete,
925
+ scanErrors: scoreResult.scanErrors,
926
+ dependencyAudit: scoreResult.dependencyAudit,
927
+ grade: scoreResult.grade.letter,
928
+ gradeLabel: scoreResult.grade.label,
929
+ totalFindings: findings.length,
930
+ totalDepVulns: depVulns.length,
931
+ categories: Object.fromEntries(
932
+ Object.entries(scoreResult.categories).map(([k, v]) => [k, {
933
+ label: v.label,
934
+ findingCount: Object.values(v.counts).reduce((a, b) => a + b, 0),
935
+ deduction: v.deduction,
936
+ counts: v.counts,
937
+ }])
938
+ ),
939
+ findings: findings.map(f => {
940
+ return {
941
+ // Secret findings are built here rather than by an agent, so they never
942
+ // pass through the orchestrator's normalisation. Do it at the source —
943
+ // see cli/core/paths.js. This used to be the fourth copy of the same
944
+ // strippers, which is how an absolute path reached the JSON report with
945
+ // its drive letter removed and the username left behind.
946
+ file: displayPath(f.file, absolutePath),
947
+ line: f.line, severity: f.severity, category: f.category,
948
+ rule: f.rule, title: f.title, description: f.description, fix: f.fix,
949
+ cwe: f.cwe, owasp: f.owasp,
950
+ ...(f.eaa ? { eaa: f.eaa } : {}),
951
+ ...(f.confidence ? { confidence: f.confidence } : {}),
952
+ ...(f.standards ? { standards: f.standards } : {}),
953
+ ...(f.deepAnalysis ? { deepAnalysis: f.deepAnalysis } : {}),
954
+ ...(f.aiClassification ? {
955
+ aiClassification: f.aiClassification,
956
+ ...(f.aiReason ? { aiReason: f.aiReason } : {}),
957
+ ...(f.aiFix ? { aiFix: f.aiFix } : {}),
958
+ } : {}),
959
+ };
960
+ }),
961
+ depVulns: depVulns.map(d => ({
962
+ severity: d.severity, package: d.package || d.id, description: d.description,
963
+ })),
964
+ remediationPlan,
965
+ recon,
966
+ agents: agentResults,
967
+ // Provenance: which tool, runtime and vendored data produced these numbers.
968
+ fingerprint: buildScanFingerprint({ filesScanned }),
969
+ };
970
+ if (scoreResult.compliance) output.compliance = scoreResult.compliance;
971
+ if (scoreResult.standardsSummary) output.standardsSummary = scoreResult.standardsSummary;
972
+ if (scoreResult.agenticSummary) output.agenticSummary = scoreResult.agenticSummary;
973
+ if (suppressions) output.suppressions = suppressions;
974
+ if (history && history.length >= 2) {
975
+ const prev = history[history.length - 2];
976
+ output.comparison = {
977
+ previousScore: prev.score,
978
+ previousGrade: prev.grade,
979
+ previousDate: prev.timestamp,
980
+ diff: scoreResult.score - prev.score,
981
+ categoryComparison: Object.fromEntries(
982
+ Object.entries(scoreResult.categories).map(([k, v]) => {
983
+ const prevCat = prev.categoryScores?.[k];
984
+ return [k, {
985
+ label: v.label,
986
+ current: -v.deduction,
987
+ previous: prevCat ? -prevCat.deduction : 0,
988
+ delta: prevCat ? prevCat.deduction - v.deduction : 0,
989
+ }];
990
+ })
991
+ ),
992
+ };
993
+ }
994
+ console.log(JSON.stringify(output, null, 2));
995
+ }
996
+
997
+ // =============================================================================
998
+ // SARIF OUTPUT
999
+ // =============================================================================
1000
+
1001
+ /**
1002
+ * SARIF for GitHub Code Scanning.
1003
+ *
1004
+ * Delegates to the shared serializer in `cli/core/output/sarif.js`. This was
1005
+ * a private copy, hardcoding a driver version of `4.0.0` while the package was at a
1006
+ * different number entirely, and it carried no `security-severity` — so the default
1007
+ * `praxis scan` lost severity fidelity in Code Scanning.
1008
+ */
1009
+ function outputSARIF(findings, rootPath) {
1010
+ console.log(renderFindingsSARIF(findings, { rootPath }));
1011
+ }
1012
+
1013
+ // =============================================================================
1014
+ // FILE SCANNING (inline from scan.js to avoid circular deps)
1015
+ // =============================================================================
1016
+
1017
+ // Walk up from `start` looking for `name`. Returns the absolute path or null.
1018
+ // Bounded to 8 ancestors to avoid runaway loops on weird filesystems.
1019
+ function findUpwards(start, name) {
1020
+ let dir = path.resolve(start);
1021
+ for (let i = 0; i < 8; i++) {
1022
+ const candidate = path.join(dir, name);
1023
+ if (fs.existsSync(candidate)) return candidate;
1024
+ const parent = path.dirname(dir);
1025
+ if (parent === dir) return null;
1026
+ dir = parent;
1027
+ }
1028
+ return null;
1029
+ }
1030
+
1031
+ async function findFiles(rootPath) {
1032
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
1033
+
1034
+ // Respect .gitignore patterns
1035
+ const gitignoreGlobs = loadGitignorePatterns(rootPath);
1036
+ globIgnore.push(...gitignoreGlobs);
1037
+
1038
+ // Load .praxisignore — walk up to the project root so subdirectory scans
1039
+ // still honor the repo-level ignore file.
1040
+ const ignorePath = findUpwards(rootPath, '.praxisignore');
1041
+ if (ignorePath) {
1042
+ try {
1043
+ const patterns = fs.readFileSync(ignorePath, 'utf-8')
1044
+ .split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
1045
+ for (const p of patterns) {
1046
+ if (p.endsWith('/')) { globIgnore.push(`**/${p}**`); }
1047
+ else { globIgnore.push(`**/${p}`); globIgnore.push(p); }
1048
+ }
1049
+ } catch { /* skip */ }
1050
+ }
1051
+
1052
+ const files = await fg('**/*', {
1053
+ cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
1054
+ });
1055
+
1056
+ return files.filter(file => {
1057
+ const ext = path.extname(file).toLowerCase();
1058
+ if (SKIP_EXTENSIONS.has(ext)) return false;
1059
+ if (SKIP_FILENAMES.has(path.basename(file))) return false;
1060
+ if (path.basename(file).endsWith('.min.js') || path.basename(file).endsWith('.min.css')) return false;
1061
+ try { if (fs.statSync(file).size > MAX_FILE_SIZE) return false; } catch { return false; }
1062
+ return true;
1063
+ });
1064
+ }
1065
+
1066
+ function scanFileForSecrets(filePath) {
1067
+ const findings = [];
1068
+ try {
1069
+ const content = fs.readFileSync(filePath, 'utf-8');
1070
+ const lines = content.split('\n');
1071
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
1072
+ const line = lines[lineNum];
1073
+ if (/praxis-ignore/i.test(line)) continue;
1074
+ for (const pattern of SECRET_PATTERNS) {
1075
+ pattern.pattern.lastIndex = 0;
1076
+ let match;
1077
+ while ((match = pattern.pattern.exec(line)) !== null) {
1078
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
1079
+
1080
+ if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
1081
+ findings.push({
1082
+ line: lineNum + 1, column: match.index + 1, matched: match[0],
1083
+ patternName: pattern.name, severity: pattern.severity,
1084
+ confidence: getConfidence(pattern, match[0]),
1085
+ description: pattern.description, category: pattern.category || 'secret'
1086
+ });
1087
+ }
1088
+ }
1089
+ }
1090
+ } catch { /* skip */ }
1091
+
1092
+ const seen = new Set();
1093
+ return findings.filter(f => {
1094
+ const key = `${f.line}:${f.matched}`;
1095
+ if (seen.has(key)) return false;
1096
+ seen.add(key);
1097
+ return true;
1098
+ });
1099
+ }
1100
+
1101
+ function deduplicateFindings(findings) {
1102
+ const seen = new Set();
1103
+ return findings.filter(f => {
1104
+ const key = `${f.file}:${f.line}:${f.rule}`;
1105
+ if (seen.has(key)) return false;
1106
+ seen.add(key);
1107
+ return true;
1108
+ });
1109
+ }
1110
+
1111
+ // =============================================================================
1112
+ // SUPPRESSION COUNTING
1113
+ // =============================================================================
1114
+
1115
+ function countSuppressions(files) {
1116
+ const suppressions = {};
1117
+ let total = 0;
1118
+ for (const file of files) {
1119
+ try {
1120
+ const content = fs.readFileSync(file, 'utf-8');
1121
+ const lines = content.split('\n');
1122
+ for (const line of lines) {
1123
+ if (/praxis-ignore/i.test(line)) {
1124
+ total++;
1125
+ // Try to extract rule name from comment: praxis-ignore RULE_NAME
1126
+ const match = line.match(/praxis-ignore\s+(\w+)/i);
1127
+ const rule = match ? match[1] : '_unspecified';
1128
+ suppressions[rule] = (suppressions[rule] || 0) + 1;
1129
+ }
1130
+ }
1131
+ } catch { /* skip */ }
1132
+ }
1133
+ return total > 0 ? { total, rules: suppressions } : null;
1134
+ }
1135
+
1136
+ // =============================================================================
1137
+ // CSV OUTPUT
1138
+ // =============================================================================
1139
+
1140
+ function outputCSV(findings, depVulns, scoreResult, rootPath) {
1141
+ const escape = (s) => {
1142
+ if (!s) return '';
1143
+ const str = String(s).replace(/"/g, '""');
1144
+ return str.includes(',') || str.includes('"') || str.includes('\n') ? `"${str}"` : str;
1145
+ };
1146
+
1147
+ console.log('severity,category,rule,file,line,title,description,fix');
1148
+ for (const f of findings) {
1149
+ const relFile = displayPath(f.file, rootPath);
1150
+ console.log([
1151
+ escape(f.severity), escape(f.category), escape(f.rule),
1152
+ escape(relFile), f.line || '', escape(f.title),
1153
+ escape(f.description), escape(f.fix),
1154
+ ].join(','));
1155
+ }
1156
+ for (const d of depVulns) {
1157
+ console.log([
1158
+ escape(d.severity), 'deps', escape(d.id || d.package),
1159
+ 'package.json', '', escape(`Vulnerable: ${d.package || d.id}`),
1160
+ escape(d.description), escape('Update to patched version'),
1161
+ ].join(','));
1162
+ }
1163
+ }
1164
+
1165
+ // =============================================================================
1166
+ // MARKDOWN OUTPUT
1167
+ // =============================================================================
1168
+
1169
+ function outputMarkdown(scoreResult, findings, depVulns, remediationPlan, rootPath) {
1170
+ const lines = [];
1171
+ lines.push('# Praxis Security Report');
1172
+ lines.push('');
1173
+ lines.push(`**Score: ${scoreResult.score}/100 (${scoreResult.grade.letter})** — ${scoreResult.grade.label}`);
1174
+ lines.push('');
1175
+ lines.push(`> Generated: ${new Date().toISOString()}`);
1176
+ lines.push('');
1177
+
1178
+ // Category breakdown
1179
+ lines.push('## Category Breakdown');
1180
+ lines.push('');
1181
+ lines.push('| Category | Issues | Deduction |');
1182
+ lines.push('|----------|--------|-----------|');
1183
+ for (const [, cat] of Object.entries(scoreResult.categories)) {
1184
+ const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
1185
+ lines.push(`| ${cat.label} | ${count} | -${cat.deduction} |`);
1186
+ }
1187
+ lines.push('');
1188
+
1189
+ // Findings by severity
1190
+ for (const sev of SEV_ORDER) {
1191
+ const sevFindings = findings.filter(f => f.severity === sev);
1192
+ if (sevFindings.length === 0) continue;
1193
+
1194
+ lines.push(`## ${sev.charAt(0).toUpperCase() + sev.slice(1)} (${sevFindings.length})`);
1195
+ lines.push('');
1196
+ lines.push('| File | Rule | Description | Fix |');
1197
+ lines.push('|------|------|-------------|-----|');
1198
+ for (const f of sevFindings) {
1199
+ const relFile = displayPath(f.file, rootPath);
1200
+ lines.push(`| ${relFile}:${f.line} | ${f.rule} | ${(f.description || '').slice(0, 80)} | ${(f.fix || '').slice(0, 60)} |`);
1201
+ }
1202
+ lines.push('');
1203
+ }
1204
+
1205
+ // Dep vulns
1206
+ if (depVulns.length > 0) {
1207
+ lines.push('## Dependency Vulnerabilities');
1208
+ lines.push('');
1209
+ lines.push('| Severity | Package | Description |');
1210
+ lines.push('|----------|---------|-------------|');
1211
+ for (const d of depVulns) {
1212
+ lines.push(`| ${d.severity} | ${d.package || d.id} | ${(d.description || '').slice(0, 80)} |`);
1213
+ }
1214
+ lines.push('');
1215
+ }
1216
+
1217
+ console.log(lines.join('\n'));
1218
+ }
1219
+
1220
+ // =============================================================================
1221
+ // COMPARISON OUTPUT
1222
+ // =============================================================================
1223
+
1224
+ function printComparison(scoringEngine, rootPath, scoreResult) {
1225
+ const history = scoringEngine.loadHistory(rootPath);
1226
+ if (history.length < 2) {
1227
+ console.log(chalk.gray('\n No previous scan to compare against.'));
1228
+ return;
1229
+ }
1230
+
1231
+ const prev = history[history.length - 2];
1232
+ console.log();
1233
+ console.log(chalk.cyan.bold(' Detailed Comparison'));
1234
+ console.log(chalk.gray(' ' + '─'.repeat(56)));
1235
+ console.log(chalk.gray(` Previous scan: ${new Date(prev.timestamp).toLocaleString()}`));
1236
+ console.log();
1237
+ console.log(chalk.white(' Category'.padEnd(26)) + chalk.white('Previous'.padEnd(12)) + chalk.white('Current'.padEnd(12)) + chalk.white('Delta'));
1238
+ console.log(chalk.gray(' ' + '─'.repeat(56)));
1239
+
1240
+ for (const [key, cat] of Object.entries(scoreResult.categories)) {
1241
+ const prevCat = prev.categoryScores?.[key];
1242
+ const prevDed = prevCat ? prevCat.deduction : 0;
1243
+ const curDed = cat.deduction;
1244
+ const delta = prevDed - curDed;
1245
+
1246
+ let deltaStr;
1247
+ if (delta > 0) deltaStr = chalk.green(`+${delta} improved`);
1248
+ else if (delta < 0) deltaStr = chalk.red(`${delta} regressed`);
1249
+ else deltaStr = chalk.gray('→ unchanged');
1250
+
1251
+ console.log(
1252
+ ` ${chalk.white(cat.label.padEnd(24))}` +
1253
+ `${chalk.gray(String(-prevDed).padEnd(12))}` +
1254
+ `${chalk.gray(String(-curDed).padEnd(12))}` +
1255
+ deltaStr
1256
+ );
1257
+ }
1258
+
1259
+ const overallDiff = scoreResult.score - prev.score;
1260
+ let overallDelta;
1261
+ if (overallDiff > 0) overallDelta = chalk.green(`+${overallDiff} improved`);
1262
+ else if (overallDiff < 0) overallDelta = chalk.red(`${overallDiff} regressed`);
1263
+ else overallDelta = chalk.gray('→ unchanged');
1264
+
1265
+ console.log(chalk.gray(' ' + '─'.repeat(56)));
1266
+ console.log(
1267
+ ` ${chalk.white.bold('Overall'.padEnd(24))}` +
1268
+ `${chalk.gray(`${prev.score}/100 ${prev.grade}`.padEnd(12))}` +
1269
+ `${chalk.gray(`${scoreResult.score}/100 ${scoreResult.grade.letter}`.padEnd(12))}` +
1270
+ overallDelta
1271
+ );
1272
+ }