praxis-sec 1.2.0 → 1.2.2

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 (60) hide show
  1. package/assets/praxis-architecture.svg +304 -0
  2. package/assets/praxis-logo.svg +38 -0
  3. package/cli/agents/abom-generator.js +1 -1
  4. package/cli/agents/agent-attestation-agent.js +10 -1
  5. package/cli/agents/agent-config-scanner.js +1 -1
  6. package/cli/agents/ai-infra-inventory-agent.js +482 -482
  7. package/cli/agents/base-agent.js +1 -1
  8. package/cli/agents/endpoint-agent-abuse-agent.js +1 -1
  9. package/cli/agents/html-reporter.js +10 -10
  10. package/cli/agents/index.js +2 -2
  11. package/cli/agents/injection-tester.js +8 -1
  12. package/cli/agents/mcp-security-agent.js +600 -594
  13. package/cli/agents/memory-poisoning-agent.js +1 -1
  14. package/cli/agents/model-file-scanner.js +1 -1
  15. package/cli/agents/orchestrator.js +375 -355
  16. package/cli/agents/prompt-injection-prober.js +228 -228
  17. package/cli/bin/praxis.js +7 -3
  18. package/cli/commands/agent-fix.js +1091 -1245
  19. package/cli/commands/audit.js +1232 -1216
  20. package/cli/commands/baseline.js +3 -2
  21. package/cli/commands/benchmark.js +1 -1
  22. package/cli/commands/ci.js +50 -25
  23. package/cli/commands/deps.js +11 -5
  24. package/cli/commands/diff.js +2 -1
  25. package/cli/commands/env-audit.js +1 -1
  26. package/cli/commands/fix.js +1 -1
  27. package/cli/commands/legal.js +2 -1
  28. package/cli/commands/mcp.js +3 -2
  29. package/cli/commands/openclaw.js +3 -6
  30. package/cli/commands/red-team.js +351 -350
  31. package/cli/commands/remediate.js +1 -1
  32. package/cli/commands/rotate.js +1 -1
  33. package/cli/commands/rules.js +1 -1
  34. package/cli/commands/scan-standard.js +3 -6
  35. package/cli/commands/scan.js +554 -554
  36. package/cli/commands/score.js +1 -1
  37. package/cli/commands/undo.js +22 -77
  38. package/cli/commands/vibe-check.js +3 -2
  39. package/cli/commands/watch.js +6 -5
  40. package/cli/core/fix-plan.js +274 -0
  41. package/cli/core/fs.js +27 -0
  42. package/cli/core/git-clone.js +8 -6
  43. package/cli/core/glob.js +56 -0
  44. package/cli/core/output/html-theme.js +158 -158
  45. package/cli/core/output/json.js +56 -48
  46. package/cli/core/output/sarif.js +2 -2
  47. package/cli/core/paths.js +91 -0
  48. package/cli/core/web/jobs.js +2 -2
  49. package/cli/core/web/server.js +19 -8
  50. package/cli/data/threatpacks/latest.json +41 -41
  51. package/cli/integrations/github-action.js +136 -0
  52. package/cli/utils/cache-manager.js +2 -1
  53. package/cli/utils/plugin-loader.js +15 -95
  54. package/cli/utils/rule-import.js +227 -227
  55. package/cli/utils/rule-registry.js +425 -425
  56. package/cli/utils/scan-fingerprint.js +1 -1
  57. package/cli/utils/score-history.js +118 -118
  58. package/docs/USAGE.md +16 -9
  59. package/docs/design/WEB-UI.md +4 -5
  60. package/package.json +81 -71
@@ -16,10 +16,11 @@ import fs from 'fs';
16
16
  import path from 'path';
17
17
  import chalk from 'chalk';
18
18
  import ora from 'ora';
19
+ import { displayPath } from '../core/paths.js';
19
20
  import { buildOrchestrator } from '../agents/index.js';
20
21
  import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, MAX_FILE_SIZE } from '../utils/patterns.js';
21
22
  import { isHighEntropyMatch } from '../utils/entropy.js';
22
- import fg from 'fast-glob';
23
+ import fg from '../core/glob.js';
23
24
 
24
25
  const BASELINE_FILE = '.praxis/baseline.json';
25
26
 
@@ -28,7 +29,7 @@ const BASELINE_FILE = '.praxis/baseline.json';
28
29
  * Uses rule + relative file path + first 40 chars of matched text.
29
30
  */
30
31
  function fingerprint(finding, rootPath) {
31
- const relFile = path.relative(rootPath, finding.file || '').replace(/\\/g, '/');
32
+ const relFile = displayPath(finding.file || '', rootPath);
32
33
  const matched = (finding.matched || '').slice(0, 40);
33
34
  return `${finding.rule}:${relFile}:${matched}`;
34
35
  }
@@ -34,7 +34,7 @@ import {
34
34
  } from '../utils/patterns.js';
35
35
  import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
36
36
  import * as output from '../utils/output.js';
37
- import fg from 'fast-glob';
37
+ import fg from '../core/glob.js';
38
38
 
39
39
  // =============================================================================
40
40
  // INDUSTRY BENCHMARKS (aggregated from public research)
@@ -19,6 +19,7 @@
19
19
 
20
20
  import fs from 'fs';
21
21
  import path from 'path';
22
+ import { displayPath } from '../core/paths.js';
22
23
  import { renderFindingsSARIF } from '../core/output/sarif.js';
23
24
  import { execFileSync } from 'child_process';
24
25
  import { buildOrchestrator } from '../agents/index.js';
@@ -37,7 +38,7 @@ import {
37
38
  import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
38
39
  import { ThreatIntel } from '../utils/threat-intel.js';
39
40
  import * as intelOrchestrator from '../utils/intel/index.js';
40
- import fg from 'fast-glob';
41
+ import fg from '../core/glob.js';
41
42
 
42
43
  // =============================================================================
43
44
  // MAIN COMMAND
@@ -50,6 +51,12 @@ export async function ciCommand(targetPath = '.', options = {}) {
50
51
  const alwaysFailOn = options.alwaysFailOn || null;
51
52
  const sarifPath = options.sarif || null;
52
53
 
54
+ if (!Number.isFinite(threshold) || threshold < 0 || threshold > 100 ||
55
+ [failOn, alwaysFailOn].some(value => value && !['critical', 'high', 'medium', 'low'].includes(value))) {
56
+ console.error('[praxis] Invalid CI gate: threshold must be 0-100 and severity must be critical, high, medium, or low.');
57
+ process.exit(2);
58
+ }
59
+
53
60
  if (!fs.existsSync(absolutePath)) {
54
61
  console.error(`[praxis] Path does not exist: ${absolutePath}`);
55
62
  process.exit(1);
@@ -99,16 +106,26 @@ export async function ciCommand(targetPath = '.', options = {}) {
99
106
 
100
107
  // ── Agent Scan ───────────────────────────────────────────────────────────
101
108
  const orchestrator = buildOrchestrator();
102
- const results = await orchestrator.runAll(absolutePath, { quiet: true }); // praxis-ignore — orchestrator result, not LLM output triggering actions
109
+ const results = await orchestrator.runAll(absolutePath, { quiet: true, deep: options.deep }); // praxis-ignore — orchestrator result, not LLM output triggering actions
103
110
  const agentFindings = results.findings;
111
+ let scanComplete = results.agentResults.every(agent => agent.success);
112
+ if (!scanComplete) console.error('[praxis] Scan incomplete: one or more agents failed.');
104
113
 
105
114
  // ── Dependency Audit ─────────────────────────────────────────────────────
106
115
  let depVulns = [];
116
+ let dependencyAudit = options.deps === false ? 'skipped' : 'not-applicable';
107
117
  if (options.deps !== false) {
108
118
  try {
109
119
  const depResult = await runDepsAudit(absolutePath);
110
120
  depVulns = depResult.vulns || [];
111
- } catch { /* skip */ }
121
+ dependencyAudit = depResult.error ? 'failed' : (depResult.pm ? 'complete' : 'not-applicable');
122
+ } catch {
123
+ dependencyAudit = 'failed';
124
+ }
125
+ if (dependencyAudit === 'failed') {
126
+ scanComplete = false;
127
+ console.error('[praxis] Dependency audit incomplete. Install the package manager audit tool and check registry access, or explicitly use --no-deps.');
128
+ }
112
129
  }
113
130
 
114
131
  // ── Merge & Deduplicate ──────────────────────────────────────────────────
@@ -140,6 +157,8 @@ export async function ciCommand(targetPath = '.', options = {}) {
140
157
  scoringEngine.saveToHistory(absolutePath, scoreResult);
141
158
 
142
159
  const duration = ((Date.now() - startTime) / 1000).toFixed(1);
160
+ const gateFindings = [...allFindings, ...depVulns];
161
+ const floorFindings = [...preBaselineFindings, ...depVulns];
143
162
 
144
163
  // ── SARIF Output ─────────────────────────────────────────────────────────
145
164
  if (sarifPath) {
@@ -163,12 +182,15 @@ export async function ciCommand(targetPath = '.', options = {}) {
163
182
  }])),
164
183
  ...(options.includeFindings ? {
165
184
  findings: allFindings.map(f => ({
166
- file: path.relative(absolutePath, f.file).replace(/\\/g, '/'),
185
+ file: displayPath(f.file, absolutePath),
167
186
  line: f.line, rule: f.rule, severity: f.severity,
168
187
  })),
169
188
  } : {}),
170
189
  threshold,
171
- pass: determinePass(scoreResult, allFindings, threshold, failOn, alwaysFailOn, preBaselineFindings),
190
+ scanComplete,
191
+ dependencyAudit,
192
+ floorPass: determinePass(scoreResult, [], 0, null, alwaysFailOn, floorFindings),
193
+ pass: scanComplete && determinePass(scoreResult, gateFindings, threshold, failOn, alwaysFailOn, floorFindings),
172
194
  duration: `${duration}s`,
173
195
  }, null, 2));
174
196
  } else {
@@ -182,7 +204,7 @@ export async function ciCommand(targetPath = '.', options = {}) {
182
204
  if (critical > 0) {
183
205
  console.log(`[praxis] Critical findings:`);
184
206
  for (const f of allFindings.filter(f => f.severity === 'critical').slice(0, 5)) {
185
- const rel = path.relative(absolutePath, f.file).replace(/\\/g, '/');
207
+ const rel = displayPath(f.file, absolutePath);
186
208
  console.log(` - ${f.rule} at ${rel}:${f.line}`);
187
209
  }
188
210
  }
@@ -192,7 +214,7 @@ export async function ciCommand(targetPath = '.', options = {}) {
192
214
  }
193
215
  }
194
216
 
195
- // ── GitHub PR Inline Annotations (P-IMP-044) ───────────────────────────
217
+ // ── GitHub PR Inline Annotations ───────────────────────────
196
218
  emitGitHubAnnotations(allFindings, absolutePath);
197
219
 
198
220
  // ── GitHub PR Comment ──────────────────────────────────────────────────
@@ -200,12 +222,12 @@ export async function ciCommand(targetPath = '.', options = {}) {
200
222
  try {
201
223
  postPRComment(scoreResult, allFindings, depVulns, absolutePath, duration);
202
224
  } catch (err) {
203
- console.log(`[praxis] Warning: Could not post PR comment: ${err.message}`);
225
+ console.error(`[praxis] Warning: Could not post PR comment: ${err.message}`);
204
226
  }
205
227
  }
206
228
 
207
229
  // ── Exit Code ────────────────────────────────────────────────────────────
208
- const pass = determinePass(scoreResult, allFindings, threshold, failOn, alwaysFailOn, preBaselineFindings);
230
+ const pass = scanComplete && determinePass(scoreResult, gateFindings, threshold, failOn, alwaysFailOn, floorFindings);
209
231
  if (!pass) {
210
232
  if (!options.json) {
211
233
  if (failOn) {
@@ -229,14 +251,7 @@ export async function ciCommand(targetPath = '.', options = {}) {
229
251
  // HELPERS
230
252
  // =============================================================================
231
253
 
232
- function determinePass(scoreResult, findings, threshold, failOn, alwaysFailOn, preBaselineFindings = null) {
233
- if (failOn) {
234
- const sevOrder = ['critical', 'high', 'medium', 'low'];
235
- const failIndex = sevOrder.indexOf(failOn);
236
- if (failIndex === -1) return scoreResult.score >= threshold;
237
- const blockingSevs = sevOrder.slice(0, failIndex + 1);
238
- return !findings.some(f => blockingSevs.includes(f.severity));
239
- }
254
+ export function determinePass(scoreResult, findings, threshold, failOn, alwaysFailOn, preBaselineFindings = null) {
240
255
  if (alwaysFailOn) {
241
256
  const sevOrder = ['critical', 'high', 'medium', 'low'];
242
257
  const floorIndex = sevOrder.indexOf(alwaysFailOn);
@@ -246,6 +261,13 @@ function determinePass(scoreResult, findings, threshold, failOn, alwaysFailOn, p
246
261
  const floorSet = preBaselineFindings || findings;
247
262
  if (floorSet.some(f => floorSevs.includes(f.severity))) return false;
248
263
  }
264
+ if (failOn) {
265
+ const sevOrder = ['critical', 'high', 'medium', 'low'];
266
+ const failIndex = sevOrder.indexOf(failOn);
267
+ if (failIndex === -1) return scoreResult.score >= threshold;
268
+ const blockingSevs = sevOrder.slice(0, failIndex + 1);
269
+ return !findings.some(f => blockingSevs.includes(f.severity));
270
+ }
249
271
  return scoreResult.score >= threshold;
250
272
  }
251
273
 
@@ -253,19 +275,22 @@ function emitGitHubAnnotations(findings, rootPath) {
253
275
  if (process.env.GITHUB_ACTIONS !== 'true') return;
254
276
  for (const f of findings) {
255
277
  if (!f.file || !f.line) continue;
256
- const rel = path.relative(rootPath, f.file).replace(/\\/g, '/');
278
+ const rel = displayPath(f.file, rootPath);
257
279
  const level = ['critical', 'high'].includes(f.severity) ? 'error' : 'warning';
258
280
  const col = f.column || 1;
259
- const title = (f.title || f.rule || 'Security Finding').replace(/\r?\n/g, ' ');
260
- const msg = (f.description || f.matched || '').replace(/\r?\n/g, ' ').slice(0, 300);
261
- console.log(`::${level} file=${rel},line=${f.line},col=${col},title=${title}::${msg}`);
281
+ const escapeData = value => String(value).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
282
+ const escapeProperty = value => escapeData(value).replace(/:/g, '%3A').replace(/,/g, '%2C');
283
+ const title = escapeProperty(f.title || f.rule || 'Security Finding');
284
+ // Matched text may be a live credential. Annotations only need the description.
285
+ const msg = escapeData(String(f.description || 'Security finding detected').slice(0, 300));
286
+ console.error(`::${level} file=${escapeProperty(rel)},line=${f.line},col=${col},title=${title}::${msg}`);
262
287
  }
263
288
  }
264
289
 
265
290
  /**
266
291
  * SARIF for GitHub Code Scanning.
267
292
  *
268
- * Delegates to the shared serializer in `cli/core/output/sarif.js` (P-IMP-062). This
293
+ * Delegates to the shared serializer in `cli/core/output/sarif.js`. This
269
294
  * used to be a private copy, which is why the GitHub Action's SARIF carried no
270
295
  * `security-severity` at all — the fix landed in the registry and never reached here.
271
296
  *
@@ -301,7 +326,7 @@ function postPRComment(scoreResult, findings, depVulns, rootPath, duration) {
301
326
  const parsed = JSON.parse(prJson);
302
327
  prNumber = String(parsed.number);
303
328
  } catch {
304
- console.log('[praxis] No PR detected — skipping PR comment');
329
+ console.error('[praxis] No PR detected — skipping PR comment');
305
330
  return;
306
331
  }
307
332
  }
@@ -326,7 +351,7 @@ function postPRComment(scoreResult, findings, depVulns, rootPath, duration) {
326
351
  body += `### Critical & High Findings\n\n`;
327
352
  body += `| Severity | File | Issue |\n|----------|------|-------|\n`;
328
353
  for (const f of findings.filter(f => f.severity === 'critical' || f.severity === 'high').slice(0, 20)) {
329
- const rel = path.relative(rootPath, f.file).replace(/\\/g, '/');
354
+ const rel = displayPath(f.file, rootPath);
330
355
  body += `| ${f.severity.toUpperCase()} | \`${rel}:${f.line}\` | ${(f.title || f.rule).slice(0, 60)} |\n`;
331
356
  }
332
357
  body += '\n';
@@ -344,7 +369,7 @@ function postPRComment(scoreResult, findings, depVulns, rootPath, duration) {
344
369
  stdio: ['pipe', 'pipe', 'pipe'], // praxis-ignore
345
370
  });
346
371
 
347
- console.log(`[praxis] PR comment posted on #${prNumber}`);
372
+ console.error(`[praxis] PR comment posted on #${prNumber}`);
348
373
  }
349
374
 
350
375
  function checkIntelFreshness(maxAge) {
@@ -229,7 +229,10 @@ function parseNpmAudit(jsonStr) {
229
229
  try {
230
230
  data = JSON.parse(jsonStr);
231
231
  } catch {
232
- return [];
232
+ throw new Error('Dependency audit did not produce valid JSON');
233
+ }
234
+ if (!data || data.error || (!data.vulnerabilities && !data.advisories)) {
235
+ throw new Error('Dependency audit did not produce an audit report');
233
236
  }
234
237
 
235
238
  const vulns = [];
@@ -331,11 +334,14 @@ function parsePipAudit(jsonStr) {
331
334
  try {
332
335
  data = JSON.parse(jsonStr);
333
336
  } catch {
334
- return [];
337
+ throw new Error('Dependency audit did not produce valid JSON');
335
338
  }
336
339
 
340
+ const dependencies = Array.isArray(data) ? data : data?.dependencies;
341
+ if (!Array.isArray(dependencies)) throw new Error('Dependency audit did not produce an audit report');
342
+
337
343
  const vulns = [];
338
- for (const pkg of (Array.isArray(data) ? data : [])) {
344
+ for (const pkg of dependencies) {
339
345
  for (const vuln of (pkg.vulns || [])) {
340
346
  const fixVersion = vuln.fix_versions?.[0];
341
347
  vulns.push({
@@ -500,7 +506,7 @@ function printDepFindings(vulns, pm) {
500
506
 
501
507
  /**
502
508
  * Run the dependency audit for a given path and return normalized vulnerabilities.
503
- * Returns { pm, vulns } or { pm: null, vulns: [] } if no manifest found.
509
+ * Returns { pm, vulns, error? }. An unavailable audit is never a clean audit.
504
510
  * Does not print anything — used programmatically by other commands.
505
511
  */
506
512
  export async function runDepsAudit(rootPath) {
@@ -511,6 +517,6 @@ export async function runDepsAudit(rootPath) {
511
517
  const vulns = runAudit(pm, rootPath);
512
518
  return { pm, vulns };
513
519
  } catch {
514
- return { pm, vulns: [] };
520
+ return { pm, vulns: [], error: `${pm.name} dependency audit could not be completed` };
515
521
  }
516
522
  }
@@ -16,6 +16,7 @@ import { execFileSync } from 'child_process';
16
16
  import path from 'path';
17
17
  import chalk from 'chalk';
18
18
  import ora from 'ora';
19
+ import { displayPath } from '../core/paths.js';
19
20
  import { SECRET_PATTERNS, SECURITY_PATTERNS, SKIP_EXTENSIONS, SKIP_FILENAMES } from '../utils/patterns.js';
20
21
  import { buildOrchestrator } from '../agents/index.js';
21
22
  import { ScoringEngine } from '../agents/scoring-engine.js';
@@ -165,7 +166,7 @@ export async function diffCommand(ref, options) {
165
166
  const sevColor = f.severity === 'critical' ? chalk.red :
166
167
  f.severity === 'high' ? chalk.yellow :
167
168
  f.severity === 'medium' ? chalk.cyan : chalk.gray;
168
- const relPath = path.relative(absolutePath, f.file);
169
+ const relPath = displayPath(f.file, absolutePath);
169
170
  console.log(` ${sevColor(`[${f.severity.toUpperCase()}]`)} ${chalk.white(f.title)}`);
170
171
  console.log(chalk.gray(` ${relPath}:${f.line} → ${f.fix || f.description}`));
171
172
  shown++;
@@ -22,7 +22,7 @@
22
22
 
23
23
  import fs from 'fs';
24
24
  import path from 'path';
25
- import fg from 'fast-glob';
25
+ import fg from '../core/glob.js';
26
26
  import chalk from 'chalk';
27
27
  import ora from 'ora';
28
28
  import { execFileSync } from 'child_process';
@@ -23,7 +23,7 @@ import {
23
23
  MAX_FILE_SIZE
24
24
  } from '../utils/patterns.js';
25
25
  import { isHighEntropyMatch } from '../utils/entropy.js';
26
- import fg from 'fast-glob';
26
+ import fg from '../core/glob.js';
27
27
  import * as output from '../utils/output.js';
28
28
 
29
29
  // =============================================================================
@@ -19,6 +19,7 @@ import fs from 'fs';
19
19
  import path from 'path';
20
20
  import chalk from 'chalk';
21
21
  import ora from 'ora';
22
+ import { displayPath } from '../core/paths.js';
22
23
  import { LegalRiskAgent } from '../agents/legal-risk-agent.js';
23
24
  import * as output from '../utils/output.js';
24
25
 
@@ -136,7 +137,7 @@ export async function legalCommand(targetPath = '.', options = {}) {
136
137
  const riskLabel = RISK_LABELS[riskKey] || riskKey;
137
138
 
138
139
  console.log(` ${sevBadge} ${chalk.white.bold(f.title)}`);
139
- console.log(` ${riskColor(`[${riskLabel}]`)} ${chalk.gray(path.relative(absolutePath, f.file) || f.file)}`);
140
+ console.log(` ${riskColor(`[${riskLabel}]`)} ${chalk.gray(displayPath(f.file, absolutePath))}`);
140
141
  console.log();
141
142
  console.log(` ${chalk.gray(f.description)}`);
142
143
  console.log();
@@ -34,7 +34,8 @@
34
34
 
35
35
  import fs from 'fs';
36
36
  import path from 'path';
37
- import fg from 'fast-glob';
37
+ import { displayPath } from '../core/paths.js';
38
+ import fg from '../core/glob.js';
38
39
  import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, TEST_FILE_PATTERNS, MAX_FILE_SIZE } from '../utils/patterns.js';
39
40
  import { isHighEntropyMatch } from '../utils/entropy.js';
40
41
  import { buildOrchestrator } from '../agents/index.js';
@@ -313,7 +314,7 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
313
314
  severity: f.severity,
314
315
  category: f.category,
315
316
  rule: f.rule,
316
- file: f.file ? path.relative(rootPath, f.file) : null,
317
+ file: f.file ? displayPath(f.file, rootPath) : null,
317
318
  line: f.line,
318
319
  description: f.description,
319
320
  remediation: f.remediation,
@@ -15,6 +15,7 @@
15
15
  import fs from 'fs';
16
16
  import path from 'path';
17
17
  import chalk from 'chalk';
18
+ import { displayPath } from '../core/paths.js';
18
19
  import * as output from '../utils/output.js';
19
20
  import { AgentConfigScanner } from '../agents/agent-config-scanner.js';
20
21
  import { MCPSecurityAgent } from '../agents/mcp-security-agent.js';
@@ -138,11 +139,7 @@ async function runJsonMode(absolutePath, options) {
138
139
  const findings = [...configFindings, ...mcpFindings];
139
140
  const normFindings = findings.map(f => ({
140
141
  ...f,
141
- file: String(f.file || '')
142
- .replace(/\\/g, '/')
143
- .replace(/^[a-zA-Z]:\/+/, '')
144
- .replace(/^.*\/Praxis\/showcase-target\//, 'showcase-target/')
145
- .replace(/^.*\/Praxis\//, ''),
142
+ file: displayPath(f.file, absolutePath),
146
143
  }));
147
144
  const result = {
148
145
  findings: normFindings,
@@ -174,7 +171,7 @@ function printFindings(findings, rootPath) {
174
171
  findings.sort((a, b) => (sevOrder[a.severity] ?? 4) - (sevOrder[b.severity] ?? 4));
175
172
 
176
173
  for (const f of findings) {
177
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
174
+ const relFile = displayPath(f.file, rootPath);
178
175
  const sevLabel = f.severity === 'critical' ? chalk.red.bold('CRITICAL')
179
176
  : f.severity === 'high' ? chalk.yellow('HIGH')
180
177
  : chalk.blue('MEDIUM');