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
@@ -188,31 +188,37 @@ const DANGEROUS_TOOL_NAMES = [
188
188
  // =============================================================================
189
189
 
190
190
  export async function scanMcpCommand(target, options = {}) {
191
+ const machineOutput = options.json || options.quiet;
191
192
  if (!target) {
193
+ if (options.quiet) throw new Error('An MCP manifest path or URL is required');
192
194
  output.error('Usage: praxis scan-mcp <url|path>');
193
195
  output.info(' Analyze an MCP server\'s tool manifest for security issues before connecting.');
194
196
  process.exit(1);
195
197
  }
196
198
 
197
- console.log();
198
- output.header('Praxis — MCP Server Security Analysis');
199
- console.log();
199
+ if (!machineOutput) {
200
+ console.log();
201
+ output.header('Praxis — MCP Server Security Analysis');
202
+ console.log();
203
+ }
200
204
 
201
205
  let manifest, serverName, source;
202
206
 
203
207
  if (target.startsWith('http://') || target.startsWith('https://')) {
204
- console.log(chalk.gray(` Fetching MCP manifest from: ${target}`));
208
+ if (!machineOutput) console.log(chalk.gray(` Fetching MCP manifest from: ${target}`));
205
209
  try {
206
210
  manifest = await fetchMcpManifest(target);
207
211
  serverName = new URL(target).hostname;
208
212
  source = target;
209
213
  } catch (err) {
214
+ if (options.quiet) throw err;
210
215
  output.error(`Failed to fetch MCP manifest: ${err.message}`);
211
216
  process.exit(1);
212
217
  }
213
218
  } else {
214
219
  const filePath = path.resolve(target);
215
220
  if (!fs.existsSync(filePath)) {
221
+ if (options.quiet) throw new Error(`File not found: ${filePath}`);
216
222
  output.error(`File not found: ${filePath}`);
217
223
  process.exit(1);
218
224
  }
@@ -221,26 +227,31 @@ export async function scanMcpCommand(target, options = {}) {
221
227
  serverName = path.basename(filePath);
222
228
  source = filePath;
223
229
  } catch (err) {
230
+ if (options.quiet) throw err;
224
231
  output.error(`Failed to parse manifest: ${err.message}`);
225
232
  process.exit(1);
226
233
  }
227
234
  }
228
235
 
229
236
  const tools = extractTools(manifest);
230
- console.log(chalk.gray(` Server: ${serverName}`));
231
- console.log(chalk.gray(` Tools found: ${tools.length}`));
232
- console.log();
237
+ if (!machineOutput) {
238
+ console.log(chalk.gray(` Server: ${serverName}`));
239
+ console.log(chalk.gray(` Tools found: ${tools.length}`));
240
+ console.log();
241
+ }
233
242
 
234
243
  if (tools.length === 0) {
244
+ if (options.quiet) throw new Error('No tools found in MCP manifest');
235
245
  output.warning('No tools found in manifest. Is this a valid MCP tools response?');
236
246
  return;
237
247
  }
238
248
 
239
249
  const findings = analyzeManifest(manifest, tools, serverName, source);
250
+ const report = { server: serverName, source, toolCount: tools.length, findings, summary: getSummary(findings) };
240
251
 
241
252
  if (options.json) {
242
- console.log(JSON.stringify({ server: serverName, source, toolCount: tools.length, findings, summary: getSummary(findings) }, null, 2));
243
- return;
253
+ if (!options.quiet) console.log(JSON.stringify(report, null, 2));
254
+ return report;
244
255
  }
245
256
 
246
257
  printFindings(findings, serverName, tools.length);
@@ -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}`));
@@ -35,10 +35,12 @@ import {
35
35
  MAX_FILE_SIZE,
36
36
  loadGitignorePatterns
37
37
  } from '../utils/patterns.js';
38
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
38
+ import { isHighEntropyMatch, getConfidence, isDocumentedSecretExample } from '../utils/entropy.js';
39
39
  import * as output from '../utils/output.js';
40
40
  import { CacheManager } from '../utils/cache-manager.js';
41
41
  import { isGitUrl, cloneGitRepo } from '../core/git-clone.js';
42
+ import { displayPath } from '../core/paths.js';
43
+ import { validateDir } from '../core/fs.js';
42
44
 
43
45
  // =============================================================================
44
46
  // CUSTOM PATTERNS (.praxis.json)
@@ -113,12 +115,12 @@ export async function scanCommand(targetPath = '.', options = {}) {
113
115
  }
114
116
  };
115
117
 
116
- const absolutePath = path.resolve(effectivePath);
118
+ const absolutePath = validateDir(effectivePath, { exitOnMissing: false });
117
119
 
118
120
  // Validate path exists
119
- if (!fs.existsSync(absolutePath)) {
121
+ if (!absolutePath) {
120
122
  cleanup();
121
- output.error(`Path does not exist: ${absolutePath}`);
123
+ console.error('Secret scans require an existing directory.');
122
124
  process.exit(1);
123
125
  }
124
126
 
@@ -231,7 +233,7 @@ export async function scanCommand(targetPath = '.', options = {}) {
231
233
  if (options.sarif) {
232
234
  console.log(renderSARIF(allResults, absolutePath));
233
235
  } else if (options.json) {
234
- outputJSON(allResults, files.length);
236
+ outputJSON(allResults, files.length, absolutePath);
235
237
  } else {
236
238
  outputPretty(allResults, files.length, absolutePath);
237
239
  }
@@ -376,6 +378,8 @@ async function scanFile(filePath, patterns = SECRET_PATTERNS) {
376
378
  let match;
377
379
  while ((match = pattern.pattern.exec(line)) !== null) {
378
380
  // For generic patterns, apply entropy check to filter placeholders
381
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
382
+
379
383
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) {
380
384
  continue;
381
385
  }
@@ -498,7 +502,7 @@ function outputPretty(results, filesScanned, rootPath) {
498
502
  output.summary(stats);
499
503
  }
500
504
 
501
- function outputJSON(results, filesScanned) {
505
+ function outputJSON(results, filesScanned, rootPath) {
502
506
  const jsonOutput = {
503
507
  success: results.length === 0,
504
508
  filesScanned,
@@ -510,7 +514,11 @@ function outputJSON(results, filesScanned) {
510
514
  for (const f of findings) {
511
515
  jsonOutput.totalFindings++;
512
516
  jsonOutput.findings.push({
513
- file,
517
+ // `results` is keyed by the raw absolute glob path, so this command never
518
+ // passes through the orchestrator's normalisation. Without it the JSON
519
+ // report published the absolute path, username and all — worse than the
520
+ // drive-letter stripping this replaced.
521
+ file: displayPath(file, rootPath),
514
522
  line: f.line,
515
523
  column: f.column,
516
524
  category: f.category || 'secret',
@@ -40,7 +40,7 @@ import {
40
40
  TEST_FILE_PATTERNS,
41
41
  MAX_FILE_SIZE
42
42
  } from '../utils/patterns.js';
43
- import { isHighEntropyMatch } from '../utils/entropy.js';
43
+ import { isHighEntropyMatch, isDocumentedSecretExample } from '../utils/entropy.js';
44
44
  import { runDepsAudit } from './deps.js';
45
45
  import * as output from '../utils/output.js';
46
46
 
@@ -422,6 +422,7 @@ function scanFile(filePath) {
422
422
 
423
423
  let match;
424
424
  while ((match = pattern.pattern.exec(line)) !== null) {
425
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
425
426
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) {
426
427
  continue;
427
428
  }
@@ -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';
@@ -29,7 +30,7 @@ import {
29
30
  MAX_FILE_SIZE,
30
31
  loadGitignorePatterns
31
32
  } from '../utils/patterns.js';
32
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
33
+ import { isHighEntropyMatch, getConfidence, isDocumentedSecretExample } from '../utils/entropy.js';
33
34
  import fg from '../core/glob.js';
34
35
 
35
36
  // =============================================================================
@@ -134,6 +135,7 @@ export async function vibeCheckCommand(targetPath = '.', options = {}) {
134
135
  pattern.pattern.lastIndex = 0;
135
136
  let match;
136
137
  while ((match = pattern.pattern.exec(line)) !== null) {
138
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
137
139
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
138
140
  secretFindings.push({
139
141
  file, line: lineNum + 1, column: match.index + 1,
@@ -215,7 +217,7 @@ export async function vibeCheckCommand(targetPath = '.', options = {}) {
215
217
  })
216
218
  .slice(0, 3);
217
219
  for (const f of top) {
218
- const rel = path.relative(absolutePath, f.file).replace(/\\/g, '/');
220
+ const rel = displayPath(f.file, absolutePath);
219
221
  console.log(` ${SEV_EMOJI[f.severity] || '⚪'} ${f.title || f.rule} ${chalk.gray(`(${rel}:${f.line})`)}`);
220
222
  }
221
223
  console.log();
@@ -13,9 +13,10 @@
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
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
19
+ import { isHighEntropyMatch, getConfidence, isDocumentedSecretExample } from '../utils/entropy.js';
19
20
  import * as output from '../utils/output.js';
20
21
  import { ScoringEngine } from '../agents/scoring-engine.js';
21
22
 
@@ -174,6 +175,7 @@ function scanFile(filePath, patterns) {
174
175
  pattern.pattern.lastIndex = 0;
175
176
  let match;
176
177
  while ((match = pattern.pattern.exec(line)) !== null) {
178
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
177
179
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
178
180
  findings.push({
179
181
  line: i + 1,
@@ -358,7 +360,7 @@ async function watchStateful(absolutePath, options = {}) {
358
360
  const scoreColor = scoreResult.score >= 75 ? chalk.cyan : scoreResult.score >= 50 ? chalk.yellow : chalk.red;
359
361
  console.log(` [${timestamp}] ${chalk.white(`${newFindings.length} new finding(s)`)}: Score ${scoreColor(`${scoreResult.score}/100`)}`);
360
362
  for (const f of newFindings.filter(f => f.severity === 'critical' || f.severity === 'high')) {
361
- const relFile = path.relative(absolutePath, f.file || '');
363
+ const relFile = displayPath(f.file || '', absolutePath);
362
364
  const sev = f.severity === 'critical' ? chalk.red.bold('!!') : chalk.yellow(' !');
363
365
  console.log(` ${sev} ${f.title} — ${relFile}:${f.line}`);
364
366
  }
@@ -375,7 +377,7 @@ async function watchStateful(absolutePath, options = {}) {
375
377
  provider: stats.provider,
376
378
  model: stats.model,
377
379
  findings: allFindings.map(f => ({
378
- file: path.relative(absolutePath, f.file || ''),
380
+ file: displayPath(f.file || '', absolutePath),
379
381
  line: f.line,
380
382
  severity: f.severity,
381
383
  rule: f.rule,
@@ -498,7 +500,7 @@ async function watchDeep(absolutePath, options = {}) {
498
500
  ? { flagged: scoreResult.agenticSummary.flagged, total: scoreResult.agenticSummary.total }
499
501
  : null,
500
502
  findings: findings.map(f => ({
501
- file: path.relative(absolutePath, f.file || ''),
503
+ file: displayPath(f.file || '', absolutePath),
502
504
  line: f.line,
503
505
  severity: f.severity,
504
506
  rule: f.rule,
@@ -519,7 +521,7 @@ async function watchDeep(absolutePath, options = {}) {
519
521
  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
522
 
521
523
  for (const f of findings.filter(f => f.severity === 'critical' || f.severity === 'high')) {
522
- const relFile = path.relative(absolutePath, f.file || '');
524
+ const relFile = displayPath(f.file || '', absolutePath);
523
525
  const sev = f.severity === 'critical' ? chalk.red.bold('!!') : chalk.yellow(' !');
524
526
  const agentic = f.agenticRisk ? chalk.gray(` [${f.agenticRisk.id}]`) : '';
525
527
  console.log(` ${sev} ${f.title} — ${relFile}:${f.line}${agentic}`);
@@ -671,7 +673,7 @@ async function postPRComments(findings, rootPath) {
671
673
  ).slice(0, 10); // Max 10 comments per scan
672
674
 
673
675
  for (const f of criticalOrHigh) {
674
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
676
+ const relFile = displayPath(f.file, rootPath);
675
677
  const body = [
676
678
  `**Praxis — ${f.severity.toUpperCase()} finding**`,
677
679
  '',
package/cli/core/glob.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Scanner file discovery boundary. Target-controlled patterns are bounded before
3
- * fast-glob/braces parse them (GHSA-vfj7-8cjw-p6xm has no upstream patch yet).
3
+ * the glob parser sees them, including pathological ignore patterns.
4
4
  * Directory symlinks must not turn a project scan into a scan of the host.
5
5
  */
6
- import fastGlob from 'fast-glob';
6
+ import { glob as discover, globSync } from 'tinyglobby';
7
+ import { validateDir } from './fs.js';
7
8
 
8
9
  const MAX_PATTERNS = 4096;
9
10
  const MAX_LENGTH = 8192;
@@ -42,15 +43,16 @@ export function validateGlobPatterns(patterns) {
42
43
  }
43
44
 
44
45
  function safeOptions(patterns, options) {
46
+ if (options.cwd && !validateDir(options.cwd, { exitOnMissing: false })) throw new Error('Scan root must be an existing directory');
45
47
  validateGlobPatterns(patterns);
46
48
  if (options.ignore) validateGlobPatterns(options.ignore);
47
- return { ...options, followSymbolicLinks: false };
49
+ return { ...options, expandDirectories: false, followSymbolicLinks: false };
48
50
  }
49
51
 
50
52
  function glob(patterns, options = {}) {
51
- return fastGlob(patterns, safeOptions(patterns, options));
53
+ return discover(patterns, safeOptions(patterns, options));
52
54
  }
53
55
 
54
- glob.sync = (patterns, options = {}) => fastGlob.sync(patterns, safeOptions(patterns, options));
56
+ glob.sync = (patterns, options = {}) => globSync(patterns, safeOptions(patterns, options));
55
57
 
56
58
  export default glob;
@@ -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) || /^\\\\/.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 !== '..' && !rel.startsWith(`..${path.sep}`) && !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.posix.basename(toSlash(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;
@@ -172,6 +172,8 @@ export async function runScanWithOrchestrator(rootPath, onProgress) {
172
172
  },
173
173
  });
174
174
 
175
+ if (agentResults.some(agent => !agent.success)) throw new Error('Scan incomplete: one or more agents failed');
176
+
175
177
  let score = 100;
176
178
  let grade = 'A';
177
179
  let categories = {};
@@ -0,0 +1,14 @@
1
+ {
2
+ "version": 1,
3
+ "lastReviewed": "2026-10-07",
4
+ "description": "Exact provider-documented non-working credential identifiers. No substring, test-directory, or history-wide exemptions.",
5
+ "examples": [
6
+ {
7
+ "pattern": "AWS Access Key ID",
8
+ "aliases": ["AGENT_LOG_EXPOSED_AWS_KEY"],
9
+ "value": "AKIAIOSFODNN7EXAMPLE",
10
+ "source": "https://docs.aws.amazon.com/AmazonS3/latest/developerguide/RESTAuthentication.html",
11
+ "reason": "AWS explicitly labels this identifier a non-working example credential."
12
+ }
13
+ ]
14
+ }
@@ -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({
@@ -19,6 +19,25 @@
19
19
  * Patterns with known prefixes (sk-ant-, ghp_, AKIA...) are already precise enough.
20
20
  */
21
21
 
22
+ import fs from 'fs';
23
+
24
+ const documentedExamples = new Map();
25
+ try {
26
+ const catalog = JSON.parse(fs.readFileSync(new URL('../data/documented-secret-examples.json', import.meta.url), 'utf8'));
27
+ for (const entry of catalog.examples || []) {
28
+ if (typeof entry.pattern !== 'string' || typeof entry.value !== 'string') continue;
29
+ for (const name of [entry.pattern, ...(Array.isArray(entry.aliases) ? entry.aliases : [])]) {
30
+ if (typeof name !== 'string') continue;
31
+ if (!documentedExamples.has(name)) documentedExamples.set(name, new Set());
32
+ documentedExamples.get(name).add(entry.value);
33
+ }
34
+ }
35
+ } catch { /* Missing example data must never suppress unknown credentials. */ }
36
+
37
+ export function isDocumentedSecretExample(patternName, matched) {
38
+ return documentedExamples.get(patternName)?.has(extractSecretValue(matched)) === true;
39
+ }
40
+
22
41
  // =============================================================================
23
42
  // ENTROPY CALCULATION
24
43
  // =============================================================================
@@ -62,7 +62,9 @@ export const HERMES_TOOLS = [
62
62
  },
63
63
  handler: async ({ path: scanPath, severity = 'medium', deep = false }) => {
64
64
  const { auditCommand } = await import('../commands/audit.js');
65
- return auditCommand(scanPath, { severity, deep, json: true, quiet: true });
65
+ const report = await auditCommand(scanPath, { deep, _agenticInner: true, deps: false, noAi: true });
66
+ const ranks = { critical: 4, high: 3, medium: 2, low: 1 };
67
+ return { ...report, findings: report.findings.filter(f => (ranks[f.severity] ?? 0) >= (ranks[severity] ?? 0)) };
66
68
  },
67
69
  },
68
70
 
@@ -86,7 +88,7 @@ export const HERMES_TOOLS = [
86
88
  },
87
89
  handler: async ({ target }) => {
88
90
  const { scanMcpCommand } = await import('../commands/scan-mcp.js');
89
- return scanMcpCommand(target, { json: true });
91
+ return scanMcpCommand(target, { json: true, quiet: true });
90
92
  },
91
93
  },
92
94
 
@@ -115,7 +117,7 @@ export const HERMES_TOOLS = [
115
117
  },
116
118
  handler: async ({ path: projectPath, severity = 'medium' }) => {
117
119
  const { mcpGetFindings } = await import('../commands/mcp.js');
118
- return mcpGetFindings({ projectPath, severity });
120
+ return mcpGetFindings({ reportPath: path.join(projectPath, '.praxis', 'last-report.json'), severity });
119
121
  },
120
122
  },
121
123
 
@@ -123,7 +125,7 @@ export const HERMES_TOOLS = [
123
125
  name: 'praxis_suppress_finding',
124
126
  description:
125
127
  'Suppress a known-safe finding by inserting an inline praxis-ignore comment ' +
126
- 'in the source file before the flagged line. Use only when the finding is a ' +
128
+ 'on the flagged source line. Use only when the finding is a ' +
127
129
  'confirmed false positive and you can document why it is safe.',
128
130
  parameters: {
129
131
  type: 'object',
@@ -184,11 +186,11 @@ export const HERMES_TOOLS = [
184
186
  // =============================================================================
185
187
 
186
188
  const KNOWN_HASHES = {
187
- praxis_audit: '4d282d29e44fcc01',
188
- praxis_scan_mcp: 'f967aea9626ca840',
189
- praxis_get_findings: 'c09c9447efd574b3',
190
- praxis_suppress_finding: '3b7339419fe52ac7',
191
- praxis_memory_list: 'c71c996716d1805b',
189
+ praxis_audit: '4bdb0dafe1efea27',
190
+ praxis_scan_mcp: '99c278579ccc7e38',
191
+ praxis_get_findings: '8c3b10c970f047ab',
192
+ praxis_suppress_finding: '3cedaa6e7ae974ae',
193
+ praxis_memory_list: '9d405b87e81c7263',
192
194
  };
193
195
 
194
196
  function toolHash(tool) {