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.
- package/assets/praxis-architecture.svg +304 -0
- package/assets/praxis-logo.svg +38 -0
- package/cli/agents/html-reporter.js +7 -7
- package/cli/agents/mcp-security-agent.js +600 -594
- package/cli/agents/orchestrator.js +375 -360
- package/cli/commands/audit.js +18 -14
- package/cli/commands/baseline.js +2 -1
- package/cli/commands/ci.js +5 -4
- package/cli/commands/diff.js +2 -1
- package/cli/commands/legal.js +2 -1
- package/cli/commands/mcp.js +2 -1
- package/cli/commands/openclaw.js +3 -6
- package/cli/commands/red-team.js +2 -1
- package/cli/commands/scan-standard.js +3 -6
- package/cli/commands/vibe-check.js +2 -1
- package/cli/commands/watch.js +6 -5
- package/cli/core/output/json.js +56 -48
- package/cli/core/paths.js +91 -0
- package/cli/utils/cache-manager.js +2 -1
- package/docs/USAGE.md +1 -1
- package/package.json +81 -80
package/cli/commands/audit.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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: `${
|
|
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: `${
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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('');
|
package/cli/commands/baseline.js
CHANGED
|
@@ -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 =
|
|
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
|
}
|
package/cli/commands/ci.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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';
|
package/cli/commands/diff.js
CHANGED
|
@@ -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 =
|
|
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++;
|
package/cli/commands/legal.js
CHANGED
|
@@ -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(
|
|
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();
|
package/cli/commands/mcp.js
CHANGED
|
@@ -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 ?
|
|
317
|
+
file: f.file ? displayPath(f.file, rootPath) : null,
|
|
317
318
|
line: f.line,
|
|
318
319
|
description: f.description,
|
|
319
320
|
remediation: f.remediation,
|
package/cli/commands/openclaw.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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');
|
package/cli/commands/red-team.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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 ?
|
|
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 =
|
|
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();
|
package/cli/commands/watch.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
675
|
+
const relFile = displayPath(f.file, rootPath);
|
|
675
676
|
const body = [
|
|
676
677
|
`**Praxis — ${f.severity.toUpperCase()} finding**`,
|
|
677
678
|
'',
|
package/cli/core/output/json.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 =
|
|
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.
|
|
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
|