praxis-sec 1.2.1 → 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.
@@ -17,6 +17,7 @@ import path from 'path';
17
17
  import { renderFindingsSARIF } from '../core/output/sarif.js';
18
18
  import chalk from 'chalk';
19
19
  import ora from 'ora';
20
+ import { displayPath } from '../core/paths.js';
20
21
  import fg from '../core/glob.js';
21
22
  import { buildOrchestrator, buildOrchestratorAsync } from '../agents/index.js';
22
23
  import { LegalRiskAgent } from '../agents/legal-risk-agent.js';
@@ -158,7 +159,9 @@ export async function auditCommand(targetPath = '.', options = {}) {
158
159
  const fileResults = scanFileForSecrets(file);
159
160
  for (const f of fileResults) {
160
161
  secretFindings.push({
161
- file,
162
+ // Normalised here so secret findings are indistinguishable from agent
163
+ // findings by the time anything renders them.
164
+ file: displayPath(file, absolutePath),
162
165
  line: f.line,
163
166
  column: f.column,
164
167
  severity: f.severity,
@@ -486,7 +489,7 @@ export async function auditCommand(targetPath = '.', options = {}) {
486
489
  } else if (options.md) {
487
490
  outputMarkdown(scoreResult, filteredFindings, depVulns, remediationPlan, absolutePath);
488
491
  } else if (options.json) {
489
- outputJSON(scoreResult, filteredFindings, depVulns, recon, agentResults, remediationPlan, suppressions, options.compare ? scoringEngine.loadHistory(absolutePath) : null, filesScanned);
492
+ outputJSON(scoreResult, filteredFindings, depVulns, recon, agentResults, remediationPlan, suppressions, options.compare ? scoringEngine.loadHistory(absolutePath) : null, filesScanned, absolutePath);
490
493
  } else if (options.sarif) {
491
494
  outputSARIF(filteredFindings, absolutePath);
492
495
  } else {
@@ -709,7 +712,7 @@ function buildRemediationPlan(findings, depVulns, rootPath) {
709
712
  const nonEnvSecrets = [];
710
713
 
711
714
  for (const f of sevSecrets) {
712
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
715
+ const relFile = displayPath(f.file, rootPath);
713
716
  if (f.file.match(/\.env(\..*)?$/)) {
714
717
  if (!envGroups.has(relFile)) envGroups.set(relFile, []);
715
718
  envGroups.get(relFile).push(f);
@@ -741,7 +744,7 @@ function buildRemediationPlan(findings, depVulns, rootPath) {
741
744
  category: 'secrets',
742
745
  categoryLabel: 'SECRETS',
743
746
  title: f.title || f.rule,
744
- file: `${path.relative(rootPath, f.file).replace(/\\/g, '/')}:${f.line}`,
747
+ file: `${displayPath(f.file, rootPath)}:${f.line}`,
745
748
  action: f.aiFix || f.fix || f.description,
746
749
  effort: 'low',
747
750
  });
@@ -755,7 +758,7 @@ function buildRemediationPlan(findings, depVulns, rootPath) {
755
758
  category: f.category,
756
759
  categoryLabel: (CATEGORY_LABELS[f.category] || f.category).toUpperCase(),
757
760
  title: f.title || f.rule,
758
- file: `${path.relative(rootPath, f.file).replace(/\\/g, '/')}:${f.line}`,
761
+ file: `${displayPath(f.file, rootPath)}:${f.line}`,
759
762
  action: f.aiFix || f.fix || f.description,
760
763
  effort: EFFORT_MAP[f.category] || 'medium',
761
764
  });
@@ -880,7 +883,7 @@ function printReport(scoreResult, findings, depVulns, recon, plan, rootPath, fil
880
883
  // JSON OUTPUT
881
884
  // =============================================================================
882
885
 
883
- function outputJSON(scoreResult, findings, depVulns, recon, agentResults, remediationPlan, suppressions, history, filesScanned = null) {
886
+ function outputJSON(scoreResult, findings, depVulns, recon, agentResults, remediationPlan, suppressions, history, filesScanned = null, absolutePath = process.cwd()) {
884
887
  const output = {
885
888
  score: scoreResult.score,
886
889
  grade: scoreResult.grade.letter,
@@ -896,13 +899,14 @@ function outputJSON(scoreResult, findings, depVulns, recon, agentResults, remedi
896
899
  }])
897
900
  ),
898
901
  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
902
  return {
905
- file: normFile, line: f.line, severity: f.severity, category: f.category,
903
+ // Secret findings are built here rather than by an agent, so they never
904
+ // pass through the orchestrator's normalisation. Do it at the source —
905
+ // see cli/core/paths.js. This used to be the fourth copy of the same
906
+ // strippers, which is how an absolute path reached the JSON report with
907
+ // its drive letter removed and the username left behind.
908
+ file: displayPath(f.file, absolutePath),
909
+ line: f.line, severity: f.severity, category: f.category,
906
910
  rule: f.rule, title: f.title, description: f.description, fix: f.fix,
907
911
  cwe: f.cwe, owasp: f.owasp,
908
912
  ...(f.eaa ? { eaa: f.eaa } : {}),
@@ -1102,7 +1106,7 @@ function outputCSV(findings, depVulns, scoreResult, rootPath) {
1102
1106
 
1103
1107
  console.log('severity,category,rule,file,line,title,description,fix');
1104
1108
  for (const f of findings) {
1105
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
1109
+ const relFile = displayPath(f.file, rootPath);
1106
1110
  console.log([
1107
1111
  escape(f.severity), escape(f.category), escape(f.rule),
1108
1112
  escape(relFile), f.line || '', escape(f.title),
@@ -1152,7 +1156,7 @@ function outputMarkdown(scoreResult, findings, depVulns, remediationPlan, rootPa
1152
1156
  lines.push('| File | Rule | Description | Fix |');
1153
1157
  lines.push('|------|------|-------------|-----|');
1154
1158
  for (const f of sevFindings) {
1155
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
1159
+ const relFile = displayPath(f.file, rootPath);
1156
1160
  lines.push(`| ${relFile}:${f.line} | ${f.rule} | ${(f.description || '').slice(0, 80)} | ${(f.fix || '').slice(0, 60)} |`);
1157
1161
  }
1158
1162
  lines.push('');
@@ -16,6 +16,7 @@ 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';
@@ -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
  }
@@ -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';
@@ -181,7 +182,7 @@ export async function ciCommand(targetPath = '.', options = {}) {
181
182
  }])),
182
183
  ...(options.includeFindings ? {
183
184
  findings: allFindings.map(f => ({
184
- file: path.relative(absolutePath, f.file).replace(/\\/g, '/'),
185
+ file: displayPath(f.file, absolutePath),
185
186
  line: f.line, rule: f.rule, severity: f.severity,
186
187
  })),
187
188
  } : {}),
@@ -203,7 +204,7 @@ export async function ciCommand(targetPath = '.', options = {}) {
203
204
  if (critical > 0) {
204
205
  console.log(`[praxis] Critical findings:`);
205
206
  for (const f of allFindings.filter(f => f.severity === 'critical').slice(0, 5)) {
206
- const rel = path.relative(absolutePath, f.file).replace(/\\/g, '/');
207
+ const rel = displayPath(f.file, absolutePath);
207
208
  console.log(` - ${f.rule} at ${rel}:${f.line}`);
208
209
  }
209
210
  }
@@ -274,7 +275,7 @@ function emitGitHubAnnotations(findings, rootPath) {
274
275
  if (process.env.GITHUB_ACTIONS !== 'true') return;
275
276
  for (const f of findings) {
276
277
  if (!f.file || !f.line) continue;
277
- const rel = path.relative(rootPath, f.file).replace(/\\/g, '/');
278
+ const rel = displayPath(f.file, rootPath);
278
279
  const level = ['critical', 'high'].includes(f.severity) ? 'error' : 'warning';
279
280
  const col = f.column || 1;
280
281
  const escapeData = value => String(value).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
@@ -350,7 +351,7 @@ function postPRComment(scoreResult, findings, depVulns, rootPath, duration) {
350
351
  body += `### Critical & High Findings\n\n`;
351
352
  body += `| Severity | File | Issue |\n|----------|------|-------|\n`;
352
353
  for (const f of findings.filter(f => f.severity === 'critical' || f.severity === 'high').slice(0, 20)) {
353
- const rel = path.relative(rootPath, f.file).replace(/\\/g, '/');
354
+ const rel = displayPath(f.file, rootPath);
354
355
  body += `| ${f.severity.toUpperCase()} | \`${rel}:${f.line}\` | ${(f.title || f.rule).slice(0, 60)} |\n`;
355
356
  }
356
357
  body += '\n';
@@ -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++;
@@ -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,6 +34,7 @@
34
34
 
35
35
  import fs from 'fs';
36
36
  import path from 'path';
37
+ import { displayPath } from '../core/paths.js';
37
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';
@@ -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');
@@ -18,6 +18,7 @@ import path from 'path';
18
18
  import { renderFindingsSARIF } from '../core/output/sarif.js';
19
19
  import chalk from 'chalk';
20
20
  import ora from 'ora';
21
+ import { displayPath } from '../core/paths.js';
21
22
  import { buildOrchestratorAsync } from '../agents/index.js';
22
23
  import { SwarmOrchestrator } from '../agents/swarm-orchestrator.js';
23
24
  import { ReconAgent } from '../agents/recon-agent.js';
@@ -267,7 +268,7 @@ function printResults(scoreResult, findings, recon, agentResults, depVulns, root
267
268
  console.log(chalk.yellow(' ' + '─'.repeat(58)));
268
269
 
269
270
  for (const f of findings.slice(0, 20)) {
270
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
271
+ const relFile = displayPath(f.file, rootPath);
271
272
  const sevColor = SEV_COLOR[f.severity] || chalk.white;
272
273
  const aiTag = f.aiClassification === 'FALSE_POSITIVE'
273
274
  ? chalk.gray(' [FP]')
@@ -16,6 +16,7 @@ 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 { buildOrchestratorAsync } from '../agents/index.js';
20
21
  import { ScoringEngine } from '../agents/scoring-engine.js';
21
22
  import {
@@ -102,11 +103,7 @@ export async function scanStandardCommand(name, targetPath = '.', options = {})
102
103
  controls: standardSummary.controls,
103
104
  findings: filtered.map(f => ({
104
105
  ...f,
105
- file: String(f.file || '')
106
- .replace(/\\/g, '/')
107
- .replace(/^[a-zA-Z]:\/+/, '')
108
- .replace(/^.*\/Praxis\/showcase-target\//, 'showcase-target/')
109
- .replace(/^.*\/Praxis\//, ''),
106
+ file: displayPath(f.file, absolutePath),
110
107
  })),
111
108
  score: scoreResult.score,
112
109
  grade: scoreResult.grade?.letter || 'A',
@@ -239,7 +236,7 @@ async function printHumanReport(standard, report) { console.log(chalk.white.bol
239
236
  const sev = (f.severity || 'medium').toUpperCase();
240
237
  const color = SEV_COLORS[f.severity] || chalk.white;
241
238
  const tag = ids.join(', ');
242
- const file = f.file ? path.relative(process.cwd(), f.file) : '';
239
+ const file = f.file ? displayPath(f.file, process.cwd()) : '';
243
240
  const loc = file ? `${file}:${f.line || 0}` : '';
244
241
  console.log(` ${color(`[${sev}]`)} ${chalk.cyan(`[${tag}]`)} ${f.title || f.rule || ''}`);
245
242
  if (loc) console.log(chalk.gray(` ${loc}`));
@@ -18,6 +18,7 @@ import fs from 'fs';
18
18
  import path from 'path';
19
19
  import chalk from 'chalk';
20
20
  import ora from 'ora';
21
+ import { displayPath } from '../core/paths.js';
21
22
  import { buildOrchestrator } from '../agents/index.js';
22
23
  import { ScoringEngine } from '../agents/scoring-engine.js';
23
24
  import { runDepsAudit } from './deps.js';
@@ -215,7 +216,7 @@ export async function vibeCheckCommand(targetPath = '.', options = {}) {
215
216
  })
216
217
  .slice(0, 3);
217
218
  for (const f of top) {
218
- const rel = path.relative(absolutePath, f.file).replace(/\\/g, '/');
219
+ const rel = displayPath(f.file, absolutePath);
219
220
  console.log(` ${SEV_EMOJI[f.severity] || '⚪'} ${f.title || f.rule} ${chalk.gray(`(${rel}:${f.line})`)}`);
220
221
  }
221
222
  console.log();
@@ -13,6 +13,7 @@
13
13
  import fs from 'fs';
14
14
  import path from 'path';
15
15
  import chalk from 'chalk';
16
+ import { displayPath } from '../core/paths.js';
16
17
  import { execFileSync } from 'child_process';
17
18
  import { SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, SECRET_PATTERNS, SECURITY_PATTERNS } from '../utils/patterns.js';
18
19
  import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
@@ -358,7 +359,7 @@ async function watchStateful(absolutePath, options = {}) {
358
359
  const scoreColor = scoreResult.score >= 75 ? chalk.cyan : scoreResult.score >= 50 ? chalk.yellow : chalk.red;
359
360
  console.log(` [${timestamp}] ${chalk.white(`${newFindings.length} new finding(s)`)}: Score ${scoreColor(`${scoreResult.score}/100`)}`);
360
361
  for (const f of newFindings.filter(f => f.severity === 'critical' || f.severity === 'high')) {
361
- const relFile = path.relative(absolutePath, f.file || '');
362
+ const relFile = displayPath(f.file || '', absolutePath);
362
363
  const sev = f.severity === 'critical' ? chalk.red.bold('!!') : chalk.yellow(' !');
363
364
  console.log(` ${sev} ${f.title} — ${relFile}:${f.line}`);
364
365
  }
@@ -375,7 +376,7 @@ async function watchStateful(absolutePath, options = {}) {
375
376
  provider: stats.provider,
376
377
  model: stats.model,
377
378
  findings: allFindings.map(f => ({
378
- file: path.relative(absolutePath, f.file || ''),
379
+ file: displayPath(f.file || '', absolutePath),
379
380
  line: f.line,
380
381
  severity: f.severity,
381
382
  rule: f.rule,
@@ -498,7 +499,7 @@ async function watchDeep(absolutePath, options = {}) {
498
499
  ? { flagged: scoreResult.agenticSummary.flagged, total: scoreResult.agenticSummary.total }
499
500
  : null,
500
501
  findings: findings.map(f => ({
501
- file: path.relative(absolutePath, f.file || ''),
502
+ file: displayPath(f.file || '', absolutePath),
502
503
  line: f.line,
503
504
  severity: f.severity,
504
505
  rule: f.rule,
@@ -519,7 +520,7 @@ async function watchDeep(absolutePath, options = {}) {
519
520
  console.log(` [${timestamp}] ${chalk.white(`${findings.length} finding(s)`)}: ${criticals ? chalk.red.bold(`${criticals} critical`) : ''}${criticals && highs ? ', ' : ''}${highs ? chalk.yellow(`${highs} high`) : ''}. Score: ${scoreColor(`${scoreResult.score}/100 ${scoreResult.grade?.letter}`)}`);
520
521
 
521
522
  for (const f of findings.filter(f => f.severity === 'critical' || f.severity === 'high')) {
522
- const relFile = path.relative(absolutePath, f.file || '');
523
+ const relFile = displayPath(f.file || '', absolutePath);
523
524
  const sev = f.severity === 'critical' ? chalk.red.bold('!!') : chalk.yellow(' !');
524
525
  const agentic = f.agenticRisk ? chalk.gray(` [${f.agenticRisk.id}]`) : '';
525
526
  console.log(` ${sev} ${f.title} — ${relFile}:${f.line}${agentic}`);
@@ -671,7 +672,7 @@ async function postPRComments(findings, rootPath) {
671
672
  ).slice(0, 10); // Max 10 comments per scan
672
673
 
673
674
  for (const f of criticalOrHigh) {
674
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
675
+ const relFile = displayPath(f.file, rootPath);
675
676
  const body = [
676
677
  `**Praxis — ${f.severity.toUpperCase()} finding**`,
677
678
  '',
@@ -1,48 +1,56 @@
1
- /**
2
- * JSON output formatter.
3
- *
4
- * Reports are emitted with `schemaVersion` so consumers can pin to a
5
- * version. Bump the version when the shape changes in a breaking way.
6
- *
7
- * Scanner hardening: secret-category findings never expose their raw
8
- * matched value — `matched` is redacted centrally so no consumer of the
9
- * JSON report can leak a credential.
10
- */
11
-
12
- const SCHEMA_VERSION = 3;
13
-
14
- function redactFinding(f) {
15
- if (!f || typeof f !== 'object') return f;
16
- const isSecret = f.category === 'secrets' || f.category === 'secret'
17
- || /secret|api[_-]?key|token|password|credential/i.test(String(f.rule || ''));
18
- const out = { ...f };
19
- if (f.matched) {
20
- out.matched = isSecret
21
- ? `${String(f.matched).slice(0, 3)}***`
22
- : String(f.matched).slice(0, 160);
23
- }
24
- if (out.file) {
25
- out.file = String(out.file)
26
- .replace(/\\/g, '/')
27
- .replace(/^[a-zA-Z]:\/+/, '')
28
- .replace(/^.*\/Praxis\/showcase-target\//, 'showcase-target/')
29
- .replace(/^.*\/Praxis\//, '');
30
- }
31
- return out;
32
- }
33
-
34
- export default function json(report, options = {}) {
35
- const { pretty = true } = options;
36
- const enriched = {
37
- schemaVersion: SCHEMA_VERSION,
38
- ...report,
39
- };
40
- if (Array.isArray(enriched.findings)) {
41
- enriched.findings = enriched.findings.map(redactFinding);
42
- }
43
- return pretty
44
- ? JSON.stringify(enriched, null, 2)
45
- : JSON.stringify(enriched);
46
- }
47
-
48
- export { redactFinding, SCHEMA_VERSION };
1
+ /**
2
+ * JSON output formatter.
3
+ *
4
+ * Reports are emitted with `schemaVersion` so consumers can pin to a
5
+ * version. Bump the version when the shape changes in a breaking way.
6
+ *
7
+ * Scanner hardening: secret-category findings never expose their raw
8
+ * matched value — `matched` is redacted centrally so no consumer of the
9
+ * JSON report can leak a credential. Finding paths are normalised against the scan
10
+ * root by the orchestrator, so no consumer can receive an absolute path either.
11
+ */
12
+
13
+ import path from 'path';
14
+
15
+ const SCHEMA_VERSION = 3;
16
+
17
+ function redactFinding(f) {
18
+ if (!f || typeof f !== 'object') return f;
19
+ const isSecret = f.category === 'secrets' || f.category === 'secret'
20
+ || /secret|api[_-]?key|token|password|credential/i.test(String(f.rule || ''));
21
+ const out = { ...f };
22
+ if (f.matched) {
23
+ out.matched = isSecret
24
+ ? `${String(f.matched).slice(0, 3)}***`
25
+ : String(f.matched).slice(0, 160);
26
+ }
27
+ if (out.file) {
28
+ // Paths arrive already normalised against the scan root by the orchestrator,
29
+ // so this only has to unify separators. The three strippers that used to live
30
+ // here were wrong for everyone but this repository:
31
+ // `^[a-zA-Z]:\/+` turned `C:\Users\alice\.cursor\mcp.json` into
32
+ // `Users/alice/.cursor/mcp.json` — leaking the username and
33
+ // naming a repo-relative file that does not exist;
34
+ // the two `/Praxis/` rules were dogfooding hacks that silently truncated any
35
+ // real path merely containing a directory called `Praxis`.
36
+ // See cli/core/paths.js for the replacement.
37
+ out.file = String(out.file).split(path.sep).join('/').replace(/\\/g, '/');
38
+ }
39
+ return out;
40
+ }
41
+
42
+ export default function json(report, options = {}) {
43
+ const { pretty = true } = options;
44
+ const enriched = {
45
+ schemaVersion: SCHEMA_VERSION,
46
+ ...report,
47
+ };
48
+ if (Array.isArray(enriched.findings)) {
49
+ enriched.findings = enriched.findings.map(redactFinding);
50
+ }
51
+ return pretty
52
+ ? JSON.stringify(enriched, null, 2)
53
+ : JSON.stringify(enriched);
54
+ }
55
+
56
+ export { redactFinding, SCHEMA_VERSION };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Honest, publishable paths for findings.
3
+ *
4
+ * A finding is not always about a file inside the scanned tree.
5
+ * `MCP_SHADOW_CONFIG` reports the developer's own `~/.cursor/mcp.json`, because a
6
+ * shadow MCP server configured outside version control is precisely the finding
7
+ * worth reporting. That file sits above the scan root, and neither obvious way of
8
+ * rendering it is acceptable:
9
+ *
10
+ * path.relative(root, file) -> '../../../Users/alice/.cursor/mcp.json'
11
+ * leaks the username and the directory depth into
12
+ * a report that gets pasted into a CI comment.
13
+ * strip the drive letter -> 'Users/alice/.cursor/mcp.json'
14
+ * does not exist, and falsely reads as
15
+ * repo-relative, so the finding is unlocatable.
16
+ *
17
+ * The only honest rendering of a path above the root is `~/…`. It identifies the
18
+ * file, leaks neither the username nor the directory layout, and — unlike either
19
+ * alternative — is identical on every machine, so a self-scan's findings do not
20
+ * change identity when a colleague runs it.
21
+ *
22
+ * Applied once by the orchestrator, before findings reach any renderer, because
23
+ * the terminal table, the HTML and JSON reports, CI annotations and SARIF all read
24
+ * the same `finding.file`. `fix` and `remediate` build their own absolute paths and
25
+ * never read this field, so normalising it cannot redirect a write.
26
+ */
27
+
28
+ import os from 'os';
29
+ import path from 'path';
30
+
31
+ const toSlash = (p) => String(p).split(path.sep).join('/').replace(/\\/g, '/');
32
+
33
+ // `path.isAbsolute` is platform-specific: on POSIX it reports false for
34
+ // `C:/work/src/a.js`, because that is a legal relative filename there. Findings
35
+ // normally come from the host's own glob, but a path read back from a report or
36
+ // a cache can carry the other platform's shape, and treating it as relative would
37
+ // print a drive letter straight into the output. Same guard sarif.js uses.
38
+ const isAbsoluteLike = (s) => path.isAbsolute(s) || /^[a-zA-Z]:[\\/]/.test(s);
39
+
40
+ /**
41
+ * Render a finding's file for display.
42
+ *
43
+ * @param {string} file Absolute or already-relative path.
44
+ * @param {string} [root] Absolute scan root, when known.
45
+ * @returns {string} Root-relative when inside `root`, `~/…` when inside the home
46
+ * directory, otherwise the bare filename — never an absolute path.
47
+ */
48
+ export function displayPath(file, root) {
49
+ if (file === undefined || file === null || file === '') return file;
50
+
51
+ const s = String(file);
52
+ // Already display-shaped. Passing a relative path back through
53
+ // `path.relative(root, ...)` would resolve it against the cwd, escape the root
54
+ // and collapse it to a bare filename — the trap that made Code Scanning alerts
55
+ // unlocatable.
56
+ if (!isAbsoluteLike(s)) return toSlash(s);
57
+
58
+ if (root) {
59
+ const rel = path.relative(root, s);
60
+ // `rel` starting with '..' means the file is outside the scan root.
61
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return toSlash(rel);
62
+ }
63
+
64
+ const home = os.homedir();
65
+ if (home && s !== home && s.startsWith(home + path.sep)) {
66
+ return '~/' + toSlash(s.slice(home.length + 1));
67
+ }
68
+
69
+ // Outside the root and outside home: identify the file without publishing the
70
+ // local directory layout.
71
+ return path.basename(s);
72
+ }
73
+
74
+ /**
75
+ * Apply {@link displayPath} to every finding that carries a path.
76
+ *
77
+ * The orchestrator calls this once, at the boundary where findings stop being
78
+ * internal state and become report output.
79
+ *
80
+ * @param {Array<object>} findings Mutated in place and returned.
81
+ * @param {string} [root] Absolute scan root.
82
+ */
83
+ export function normalizeFindingPaths(findings, root) {
84
+ if (!Array.isArray(findings)) return findings;
85
+ for (const f of findings) {
86
+ if (f && typeof f === 'object' && f.file) f.file = displayPath(f.file, root);
87
+ }
88
+ return findings;
89
+ }
90
+
91
+ export default displayPath;
@@ -17,6 +17,7 @@
17
17
 
18
18
  import fs from 'fs';
19
19
  import path from 'path';
20
+ import { displayPath } from '../core/paths.js';
20
21
  import crypto from 'crypto';
21
22
  import { toolVersion } from '../core/version.js';
22
23
 
@@ -200,7 +201,7 @@ export class CacheManager {
200
201
  // Group findings by file (relative paths)
201
202
  const lastFindings = {};
202
203
  for (const f of allFindings) {
203
- const relPath = path.relative(this.rootPath, f.file).replace(/\\/g, '/');
204
+ const relPath = displayPath(f.file, this.rootPath);
204
205
  if (!lastFindings[relPath]) lastFindings[relPath] = [];
205
206
  // Store a lightweight copy (no absolute paths)
206
207
  lastFindings[relPath].push({
package/docs/USAGE.md CHANGED
@@ -961,7 +961,7 @@ only ever emits a known class name.
961
961
  prints a provenance line in its footer:
962
962
 
963
963
  ```
964
- praxis 1.2.1 · node v24.14.1 · probes v1.1(23) · threatpack v1.1(3) · eaa v0.1.0 · files 214
964
+ praxis 1.2.2 · node v24.14.1 · probes v1.1(23) · threatpack v1.1(3) · eaa v0.1.0 · files 215
965
965
  ```
966
966
 
967
967
  It records the tool version, the runtime, and the version of every vendored data asset