praxis-sec 1.2.2 → 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 (42) 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/checklists/launch-day.md +6 -7
  6. package/cli/agents/agent-telemetry-agent.js +2 -0
  7. package/cli/agents/api-fuzzer.js +2 -2
  8. package/cli/agents/git-history-scanner.js +14 -15
  9. package/cli/agents/html-reporter.js +2 -1
  10. package/cli/agents/memory-poisoning-agent.js +1 -5
  11. package/cli/commands/agent-fix.js +3 -1
  12. package/cli/commands/audit.js +1271 -1231
  13. package/cli/commands/autofix.js +32 -13
  14. package/cli/commands/baseline.js +2 -1
  15. package/cli/commands/benchmark.js +2 -1
  16. package/cli/commands/ci.js +7 -4
  17. package/cli/commands/env-audit.js +4 -2
  18. package/cli/commands/fix.js +2 -1
  19. package/cli/commands/mcp.js +52 -50
  20. package/cli/commands/remediate.js +2 -1
  21. package/cli/commands/rotate.js +2 -1
  22. package/cli/commands/scan-mcp.js +20 -9
  23. package/cli/commands/scan.js +15 -7
  24. package/cli/commands/score.js +2 -1
  25. package/cli/commands/vibe-check.js +2 -1
  26. package/cli/commands/watch.js +2 -1
  27. package/cli/core/glob.js +7 -5
  28. package/cli/core/paths.js +4 -4
  29. package/cli/core/web/jobs.js +2 -0
  30. package/cli/data/documented-secret-examples.json +14 -0
  31. package/cli/utils/entropy.js +19 -0
  32. package/cli/utils/hermes-tool-registry.js +11 -9
  33. package/configs/firebase/security-checklist.md +3 -3
  34. package/configs/supabase/security-checklist.md +19 -21
  35. package/docs/RELEASE-1.2.4.md +85 -0
  36. package/docs/RELEASING.md +51 -0
  37. package/docs/THIRD_PARTY_NOTICES.md +8 -0
  38. package/docs/THREAT_INTEL.md +4 -2
  39. package/docs/USAGE.md +97 -76
  40. package/package.json +82 -81
  41. package/snippets/README.md +6 -0
  42. package/snippets/auth/jwt-checklist.md +14 -13
@@ -29,6 +29,9 @@ import path from 'path';
29
29
  import { execFileSync, execSync } from 'child_process';
30
30
  import chalk from 'chalk';
31
31
  import * as output from '../utils/output.js';
32
+ import { resolveProjectFile } from '../core/fs.js';
33
+ import { isProtectedFixPath } from '../core/fix-plan.js';
34
+ import writeFileAtomic from 'write-file-atomic';
32
35
 
33
36
  // Severity rank for filtering
34
37
  const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
@@ -86,7 +89,11 @@ export async function autofixCommand(options = {}) {
86
89
  if (!f.deepAnalysis?.fix) return false;
87
90
  if ((SEV_RANK[f.severity] ?? 0) < minRank) return false;
88
91
  if (!f.file) return false;
89
- const absFile = path.resolve(rootPath, f.file);
92
+ let absFile;
93
+ try {
94
+ absFile = resolveProjectFile(rootPath, f.file);
95
+ if (isProtectedFixPath(path.relative(fs.realpathSync(rootPath), absFile)) || /^~[\\/]/.test(f.file)) return false;
96
+ } catch { return false; }
90
97
  if (NEVER_EDIT.some(p => p.test(absFile.replace(/\\/g, '/')))) return false;
91
98
  if (!fs.existsSync(absFile)) return false;
92
99
  return true;
@@ -160,11 +167,14 @@ export async function autofixCommand(options = {}) {
160
167
  const failed = [];
161
168
 
162
169
  for (const f of fixable) {
163
- const absFile = path.resolve(rootPath, f.file);
164
170
  const fix = f.deepAnalysis.fix;
165
171
 
166
172
  try {
167
- applyInlineAnnotation(absFile, f.line, fix);
173
+ const absFile = resolveProjectFile(rootPath, f.file);
174
+ if (!applyInlineAnnotation(absFile, f.line, fix)) {
175
+ console.log(chalk.gray(` Already annotated: ${f.file}:${f.line ?? ''}`));
176
+ continue;
177
+ }
168
178
  applied.push(f);
169
179
  console.log(chalk.green(` ✔ Annotated: ${f.file}:${f.line ?? ''}`));
170
180
  } catch (err) {
@@ -269,16 +279,20 @@ export async function autofixCommand(options = {}) {
269
279
  * Returns the count of files successfully annotated.
270
280
  * Exported for use by the --agentic audit loop.
271
281
  */
272
- export function applyInlineAnnotations(findings) {
273
- const NEVER_EDIT = new Set(['.env', '.env.local', '.env.production', 'secrets.json', '.npmrc', '.netrc']);
274
- const fixable = findings.filter(f =>
275
- f.fix && f.file && fs.existsSync(f.file) && !NEVER_EDIT.has(path.basename(f.file))
276
- );
282
+ export function applyInlineAnnotations(findings, rootPath = process.cwd()) {
283
+ const neverEdit = new Set(['secrets.json', '.npmrc', '.netrc']);
277
284
  let count = 0;
278
- for (const f of fixable.slice(0, 10)) {
285
+ // Insert from the bottom of each file so earlier insertions do not shift
286
+ // the original line numbers of later findings.
287
+ const ordered = [...findings].sort((a, b) => String(a.file).localeCompare(String(b.file)) || (b.line || 1) - (a.line || 1));
288
+ for (const f of ordered) {
289
+ if (count >= 10) break;
279
290
  try {
280
- applyInlineAnnotation(f.file, f.line, f.fix);
281
- count++;
291
+ if (!f.fix || !f.file || /^(?:~[\\/])/.test(f.file)) continue;
292
+ const absFile = resolveProjectFile(rootPath, f.file);
293
+ const relative = path.relative(fs.realpathSync(rootPath), absFile);
294
+ if (isProtectedFixPath(relative) || neverEdit.has(path.basename(absFile)) || !fs.statSync(absFile).isFile()) continue;
295
+ if (applyInlineAnnotation(absFile, f.line, f.fix)) count++;
282
296
  } catch { /* skip unwritable */ }
283
297
  }
284
298
  return count;
@@ -294,11 +308,15 @@ export function applyInlineAnnotation(filePath, lineNum, fix) {
294
308
  }
295
309
 
296
310
  // Already annotated?
297
- if (idx > 0 && /praxis-fix/i.test(lines[idx - 1])) return;
311
+ if (/^\s*(?:\/\/|#)\s*praxis-fix\b/i.test(lines[idx])) return false;
312
+ for (let previous = idx - 1; previous >= 0 && /^\s*(?:\/\/|#)/.test(lines[previous]); previous--) {
313
+ if (/praxis-fix/i.test(lines[previous])) return false;
314
+ }
298
315
 
299
316
  const indent = lines[idx].match(/^(\s*)/)?.[1] ?? '';
300
317
  const isJs = /\.(js|ts|jsx|tsx|mjs|cjs|java|c|cpp|cs|go|rs|swift|kt)$/.test(filePath);
301
318
  const isPy = /\.py$/.test(filePath);
319
+ if (!isJs && !isPy) throw new Error('Inline annotations require a supported source-code comment syntax');
302
320
 
303
321
  // Wrap fix in a structured annotation comment
304
322
  const fixLines = fix.split('\n').map(l => l.trim()).filter(Boolean);
@@ -317,7 +335,8 @@ export function applyInlineAnnotation(filePath, lineNum, fix) {
317
335
  }
318
336
 
319
337
  lines.splice(idx, 0, annotation);
320
- fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
338
+ writeFileAtomic.sync(filePath, lines.join('\n'), { encoding: 'utf8' });
339
+ return true;
321
340
  }
322
341
 
323
342
  function buildPRBody(applied, failed, reportPath) {
@@ -19,7 +19,7 @@ import ora from 'ora';
19
19
  import { displayPath } from '../core/paths.js';
20
20
  import { buildOrchestrator } from '../agents/index.js';
21
21
  import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, MAX_FILE_SIZE } from '../utils/patterns.js';
22
- import { isHighEntropyMatch } from '../utils/entropy.js';
22
+ import { isHighEntropyMatch, isDocumentedSecretExample } from '../utils/entropy.js';
23
23
  import fg from '../core/glob.js';
24
24
 
25
25
  const BASELINE_FILE = '.praxis/baseline.json';
@@ -61,6 +61,7 @@ async function quickScan(rootPath) {
61
61
  p.pattern.lastIndex = 0;
62
62
  let m;
63
63
  while ((m = p.pattern.exec(lines[i])) !== null) {
64
+ if (isDocumentedSecretExample(p.name, m[0])) continue;
64
65
  if (p.requiresEntropyCheck && !isHighEntropyMatch(m[0])) continue;
65
66
  findings.push({ file, line: i + 1, rule: p.name, matched: m[0], severity: p.severity });
66
67
  }
@@ -32,7 +32,7 @@ import {
32
32
  MAX_FILE_SIZE,
33
33
  loadGitignorePatterns
34
34
  } from '../utils/patterns.js';
35
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
35
+ import { isHighEntropyMatch, getConfidence, isDocumentedSecretExample } from '../utils/entropy.js';
36
36
  import * as output from '../utils/output.js';
37
37
  import fg from '../core/glob.js';
38
38
 
@@ -150,6 +150,7 @@ export async function benchmarkCommand(targetPath = '.', options = {}) {
150
150
  pattern.pattern.lastIndex = 0;
151
151
  let match;
152
152
  while ((match = pattern.pattern.exec(line)) !== null) {
153
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
153
154
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
154
155
  secretFindings.push({
155
156
  file, line: lineNum + 1, column: match.index + 1,
@@ -21,6 +21,7 @@ import fs from 'fs';
21
21
  import path from 'path';
22
22
  import { displayPath } from '../core/paths.js';
23
23
  import { renderFindingsSARIF } from '../core/output/sarif.js';
24
+ import { validateDir } from '../core/fs.js';
24
25
  import { execFileSync } from 'child_process';
25
26
  import { buildOrchestrator } from '../agents/index.js';
26
27
  import { ScoringEngine } from '../agents/scoring-engine.js';
@@ -35,7 +36,7 @@ import {
35
36
  MAX_FILE_SIZE,
36
37
  loadGitignorePatterns
37
38
  } from '../utils/patterns.js';
38
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
39
+ import { isHighEntropyMatch, getConfidence, isDocumentedSecretExample } from '../utils/entropy.js';
39
40
  import { ThreatIntel } from '../utils/threat-intel.js';
40
41
  import * as intelOrchestrator from '../utils/intel/index.js';
41
42
  import fg from '../core/glob.js';
@@ -45,7 +46,7 @@ import fg from '../core/glob.js';
45
46
  // =============================================================================
46
47
 
47
48
  export async function ciCommand(targetPath = '.', options = {}) {
48
- const absolutePath = path.resolve(targetPath);
49
+ const absolutePath = validateDir(targetPath, { exitOnMissing: false });
49
50
  const threshold = options.threshold ?? 75;
50
51
  const failOn = options.failOn || null;
51
52
  const alwaysFailOn = options.alwaysFailOn || null;
@@ -57,8 +58,8 @@ export async function ciCommand(targetPath = '.', options = {}) {
57
58
  process.exit(2);
58
59
  }
59
60
 
60
- if (!fs.existsSync(absolutePath)) {
61
- console.error(`[praxis] Path does not exist: ${absolutePath}`);
61
+ if (!absolutePath) {
62
+ console.error('[praxis] CI scans require an existing directory.');
62
63
  process.exit(1);
63
64
  }
64
65
 
@@ -88,6 +89,8 @@ export async function ciCommand(targetPath = '.', options = {}) {
88
89
  pattern.pattern.lastIndex = 0;
89
90
  let match;
90
91
  while ((match = pattern.pattern.exec(line)) !== null) {
92
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
93
+
91
94
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
92
95
  secretFindings.push({
93
96
  file, line: lineNum + 1, column: match.index + 1,
@@ -27,6 +27,7 @@ import chalk from 'chalk';
27
27
  import ora from 'ora';
28
28
  import { execFileSync } from 'child_process';
29
29
  import { SECRET_PATTERNS, SKIP_DIRS } from '../utils/patterns.js';
30
+ import { isDocumentedSecretExample } from '../utils/entropy.js';
30
31
 
31
32
  // Minimum value length to cross-reference (skip short values like "true", "3000")
32
33
  const MIN_VALUE_LENGTH = 8;
@@ -138,8 +139,9 @@ export async function envAuditCommand(targetPath = '.', options) {
138
139
  const content = fs.readFileSync(pFile, 'utf-8');
139
140
  for (const pattern of SECRET_PATTERNS) {
140
141
  pattern.pattern.lastIndex = 0;
141
- const match = pattern.pattern.exec(content);
142
- if (match) {
142
+ let match;
143
+ while ((match = pattern.pattern.exec(content)) !== null) {
144
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
143
145
  const relPath = path.relative(absolutePath, pFile).replace(/\\/g, '/');
144
146
  findings.push({
145
147
  type: 'projects-manifest',
@@ -22,7 +22,7 @@ import {
22
22
  TEST_FILE_PATTERNS,
23
23
  MAX_FILE_SIZE
24
24
  } from '../utils/patterns.js';
25
- import { isHighEntropyMatch } from '../utils/entropy.js';
25
+ import { isHighEntropyMatch, isDocumentedSecretExample } from '../utils/entropy.js';
26
26
  import fg from '../core/glob.js';
27
27
  import * as output from '../utils/output.js';
28
28
 
@@ -111,6 +111,7 @@ async function scanFile(filePath) {
111
111
  pattern.pattern.lastIndex = 0;
112
112
  let match;
113
113
  while ((match = pattern.pattern.exec(line)) !== null) {
114
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
114
115
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
115
116
  findings.push({
116
117
  line: lineNum + 1,
@@ -34,10 +34,12 @@
34
34
 
35
35
  import fs from 'fs';
36
36
  import path from 'path';
37
+ import writeFileAtomic from 'write-file-atomic';
37
38
  import { displayPath } from '../core/paths.js';
39
+ import { validateDir } from '../core/fs.js';
38
40
  import fg from '../core/glob.js';
39
41
  import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, TEST_FILE_PATTERNS, MAX_FILE_SIZE } from '../utils/patterns.js';
40
- import { isHighEntropyMatch } from '../utils/entropy.js';
42
+ import { isHighEntropyMatch, isDocumentedSecretExample } from '../utils/entropy.js';
41
43
  import { buildOrchestrator } from '../agents/index.js';
42
44
  import { ScoringEngine } from '../agents/scoring-engine.js';
43
45
  import { autoDetectProvider } from '../providers/llm-provider.js';
@@ -91,7 +93,7 @@ const TOOLS = [
91
93
  },
92
94
  {
93
95
  name: 'scan_repo',
94
- description: 'Run a full multi-agent security scan on a repository or directory. Runs all 20+ praxis security agents (injection, auth bypass, secrets, supply chain, LLM security, etc.) and returns a structured findings report with severity ratings and remediation advice. Use this when the user asks to audit, scan, or check the security of their project.',
96
+ description: 'Run a full multi-agent security scan on a repository or directory. Runs the 28 built-in Praxis security agents (injection, auth bypass, secrets, supply chain, LLM security, etc.) and returns a structured findings report with severity ratings and remediation advice. Use this when the user asks to audit, scan, or check the security of their project.',
95
97
  inputSchema: {
96
98
  type: 'object',
97
99
  properties: {
@@ -118,7 +120,7 @@ const TOOLS = [
118
120
  },
119
121
  {
120
122
  name: 'get_findings',
121
- description: 'Read and return findings from a praxis JSON report file previously saved by scan_repo or the praxis CLI (npx praxis-sec audit --json). Useful for reviewing or referencing a prior scan without re-running it.',
123
+ description: 'Read and return findings from a praxis JSON report file previously saved by scan_repo or the praxis CLI (praxis scan full --json). Useful for reviewing or referencing a prior scan without re-running it.',
122
124
  inputSchema: {
123
125
  type: 'object',
124
126
  properties: {
@@ -253,11 +255,10 @@ async function analyzeFile({ path: filePath }) {
253
255
  };
254
256
  }
255
257
 
256
- async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, outputFile }) {
257
- const rootPath = path.resolve(targetPath);
258
-
259
- if (!fs.existsSync(rootPath)) {
260
- return { error: `Path does not exist: ${rootPath}` };
258
+ export async function mcpScanRepo({ path: targetPath, agents: agentFilter, llm = false, outputFile }, orchestrator = buildOrchestrator()) {
259
+ const rootPath = validateDir(targetPath, { exitOnMissing: false });
260
+ if (!rootPath) {
261
+ return { error: 'Repository scans require an existing directory', scanComplete: false };
261
262
  }
262
263
 
263
264
  // MCP communicates over stdout as JSON-RPC. Suppress all console output during
@@ -270,16 +271,15 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
270
271
  console.log = console.warn = console.error = console.info = noop;
271
272
 
272
273
  try {
273
- const orchestrator = buildOrchestrator();
274
- const context = { rootPath };
275
-
276
274
  // Run all agents (quiet:true suppresses ora spinners; console is already nulled)
277
- const { findings, recon } = await orchestrator.runAll(rootPath, {
275
+ const { findings, recon, agentResults } = await orchestrator.runAll(rootPath, {
278
276
  agents: agentFilter,
279
277
  timeout: 30000,
280
278
  concurrency: 6,
281
279
  quiet: true,
282
280
  });
281
+ const scanErrors = (agentResults ?? []).filter(result => result.success === false)
282
+ .map(result => ({ stage: 'agent', agent: result.agent, message: result.error || 'Agent failed' }));
283
283
 
284
284
  // Optional: LLM deep analysis
285
285
  let deepStats = null;
@@ -289,12 +289,16 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
289
289
  const analyzer = new DeepAnalyzer({ provider, budgetCents: 50, verbose: false });
290
290
  await analyzer.analyze(findings, { rootPath, recon });
291
291
  deepStats = analyzer.getStats();
292
+ } else {
293
+ scanErrors.push({ stage: 'deep-analysis', message: 'Requested LLM analysis has no configured provider' });
292
294
  }
293
295
  }
294
296
 
295
297
  // Score
296
298
  const scorer = new ScoringEngine();
297
- const { score, grade } = scorer.score(findings);
299
+ const scoreResult = scorer.compute(findings);
300
+ const score = scoreResult.score;
301
+ const grade = scoreResult.grade.letter;
298
302
 
299
303
  const SEV_ORDER = ['critical', 'high', 'medium', 'low'];
300
304
  const bySeverity = {};
@@ -305,6 +309,9 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
305
309
  const report = {
306
310
  scannedAt: new Date().toISOString(),
307
311
  rootPath,
312
+ scanComplete: scanErrors.length === 0,
313
+ scanErrors,
314
+ dependencyAudit: 'skipped',
308
315
  score,
309
316
  grade,
310
317
  totalFindings: findings.length,
@@ -322,7 +329,7 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
322
329
  ...(f.deepAnalysis ? { deepAnalysis: f.deepAnalysis } : {}),
323
330
  })),
324
331
  ...(deepStats ? { deepAnalysis: deepStats } : {}),
325
- summary: `Score: ${score}/100 (${grade}) — ${findings.length} finding(s): ${bySeverity.critical} critical, ${bySeverity.high} high, ${bySeverity.medium} medium, ${bySeverity.low} low.`,
332
+ summary: `${scanErrors.length ? 'Incomplete scan. ' : ''}Score: ${score}/100 (${grade}) — ${findings.length} finding(s): ${bySeverity.critical} critical, ${bySeverity.high} high, ${bySeverity.medium} medium, ${bySeverity.low} low.`,
326
333
  };
327
334
 
328
335
  if (outputFile) {
@@ -333,7 +340,7 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
333
340
 
334
341
  return report;
335
342
  } catch (err) {
336
- return { error: `Scan failed: ${err.message}` };
343
+ return { error: `Scan failed: ${err.message}`, scanComplete: false, scanErrors: [{ stage: 'scan', message: err.message }] };
337
344
  } finally {
338
345
  // Always restore console so other tool calls are not affected
339
346
  console.log = savedLog;
@@ -343,7 +350,7 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
343
350
  }
344
351
  }
345
352
 
346
- function getFindings({ reportPath, severity }) {
353
+ export function mcpGetFindings({ reportPath, severity }) {
347
354
  const absPath = path.resolve(reportPath);
348
355
 
349
356
  if (!fs.existsSync(absPath)) {
@@ -368,6 +375,9 @@ function getFindings({ reportPath, severity }) {
368
375
  scannedAt: report.scannedAt,
369
376
  score: report.score,
370
377
  grade: report.grade,
378
+ scanComplete: report.scanComplete,
379
+ scanErrors: report.scanErrors,
380
+ dependencyAudit: report.dependencyAudit,
371
381
  totalFindings: filtered.length,
372
382
  bySeverity: report.bySeverity,
373
383
  findings: filtered,
@@ -376,7 +386,7 @@ function getFindings({ reportPath, severity }) {
376
386
  };
377
387
  }
378
388
 
379
- function suppressFinding({ file, line, reason }) {
389
+ export function mcpSuppressFinding({ file, line, reason }) {
380
390
  const absPath = path.resolve(file);
381
391
 
382
392
  if (!fs.existsSync(absPath)) {
@@ -393,7 +403,7 @@ function suppressFinding({ file, line, reason }) {
393
403
  const lines = content.split('\n');
394
404
  const lineIdx = line - 1; // Convert to 0-indexed
395
405
 
396
- if (lineIdx < 0 || lineIdx >= lines.length) {
406
+ if (!Number.isInteger(line) || lineIdx < 0 || lineIdx >= lines.length) {
397
407
  return { error: `Line ${line} is out of range (file has ${lines.length} lines)` };
398
408
  }
399
409
 
@@ -411,7 +421,23 @@ function suppressFinding({ file, line, reason }) {
411
421
  return { error: `Cannot suppress critical severity finding: ${criticalFinding.description}` };
412
422
  }
413
423
 
414
- // Log suppression request to .praxis/suppressions.json for manual audit
424
+ const isSlashComment = /\.(js|ts|jsx|tsx|mjs|cjs|java|c|cpp|cs|go|rs|swift|kt)$/i.test(file);
425
+ const isHashComment = /\.(py|rb|sh|bash|yaml|yml|toml)$/i.test(file);
426
+ if (!isSlashComment && !isHashComment) {
427
+ return { error: 'Suppression requires a supported source-code comment syntax' };
428
+ }
429
+ const safeReason = String(reason ?? '').replace(/[\r\n\u2028\u2029]/g, ' ').trim();
430
+ if (!safeReason) return { error: 'A suppression reason is required' };
431
+ const ignoreComment = `${isHashComment ? '#' : '//'} praxis-ignore — ${safeReason}`;
432
+ const cr = targetLine.endsWith('\r') ? '\r' : '';
433
+ lines[lineIdx] = `${targetLine.replace(/\r$/, '')} ${ignoreComment}${cr}`;
434
+ try {
435
+ writeFileAtomic.sync(absPath, lines.join('\n'), { encoding: 'utf8' });
436
+ } catch (err) {
437
+ return { error: `Cannot write file: ${err.message}` };
438
+ }
439
+
440
+ // Log only successful suppression writes for manual audit.
415
441
  const suppressionsDir = path.join(process.cwd(), '.praxis');
416
442
  if (!fs.existsSync(suppressionsDir)) {
417
443
  try {
@@ -428,7 +454,7 @@ function suppressFinding({ file, line, reason }) {
428
454
  suppressions.push({
429
455
  file: path.relative(process.cwd(), absPath),
430
456
  line,
431
- reason,
457
+ reason: safeReason,
432
458
  timestamp: new Date().toISOString(),
433
459
  status: 'pending_review'
434
460
  });
@@ -436,38 +462,13 @@ function suppressFinding({ file, line, reason }) {
436
462
  fs.writeFileSync(suppressionsFile, JSON.stringify(suppressions, null, 2), 'utf-8');
437
463
  } catch {}
438
464
 
439
- // Detect indentation and comment style
440
- const indent = targetLine.match(/^(\s*)/)?.[1] ?? '';
441
- const isJs = /\.(js|ts|jsx|tsx|mjs|cjs|java|c|cpp|cs|go|rs|swift|kt)$/.test(file);
442
- const isPy = /\.py$/.test(file);
443
- const isRb = /\.rb$/.test(file);
444
- const isHtml = /\.(html?|vue|svelte)$/.test(file);
445
-
446
- let ignoreComment;
447
- if (isHtml) {
448
- ignoreComment = `${indent}<!-- praxis-ignore — ${reason} -->`;
449
- } else if (isPy || isRb) {
450
- ignoreComment = `${indent}# praxis-ignore — ${reason}`;
451
- } else {
452
- ignoreComment = `${indent}// praxis-ignore — ${reason}`;
453
- }
454
-
455
- // Insert ignore comment on the line BEFORE the finding
456
- lines.splice(lineIdx, 0, ignoreComment);
457
-
458
- try {
459
- fs.writeFileSync(absPath, lines.join('\n'), 'utf-8');
460
- } catch (err) {
461
- return { error: `Cannot write file: ${err.message}` };
462
- }
463
-
464
465
  return {
465
466
  suppressed: true,
466
467
  file: absPath,
467
468
  originalLine: line,
468
- insertedLine: line, // The ignore comment is now on this line, original moved to line+1
469
+ insertedLine: line,
469
470
  comment: ignoreComment,
470
- message: `Added praxis-ignore comment before line ${line} in ${path.basename(file)}.`,
471
+ message: `Added trailing praxis-ignore comment on line ${line} in ${path.basename(file)}.`,
471
472
  };
472
473
  }
473
474
 
@@ -506,6 +507,7 @@ function scanFile(filePath) {
506
507
  pattern.pattern.lastIndex = 0;
507
508
  let match;
508
509
  while ((match = pattern.pattern.exec(line)) !== null) {
510
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
509
511
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
510
512
  findings.push({
511
513
  line: lineNum + 1,
@@ -629,13 +631,13 @@ async function handleRequest(request) {
629
631
  result = await analyzeFile(args);
630
632
  break;
631
633
  case 'scan_repo':
632
- result = await scanRepo(args);
634
+ result = await mcpScanRepo(args);
633
635
  break;
634
636
  case 'get_findings':
635
- result = getFindings(args);
637
+ result = mcpGetFindings(args);
636
638
  break;
637
639
  case 'suppress_finding':
638
- result = suppressFinding(args);
640
+ result = mcpSuppressFinding(args);
639
641
  break;
640
642
  case 'explain_and_fix':
641
643
  result = await explainAndFix(args);
@@ -47,7 +47,7 @@ import {
47
47
  TEST_FILE_PATTERNS,
48
48
  MAX_FILE_SIZE
49
49
  } from '../utils/patterns.js';
50
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
50
+ import { isHighEntropyMatch, getConfidence, isDocumentedSecretExample } from '../utils/entropy.js';
51
51
  import * as output from '../utils/output.js';
52
52
 
53
53
  // =============================================================================
@@ -468,6 +468,7 @@ async function scanFile(filePath) {
468
468
  pattern.pattern.lastIndex = 0;
469
469
  let match;
470
470
  while ((match = pattern.pattern.exec(line)) !== null) {
471
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
471
472
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
472
473
  findings.push({
473
474
  line: lineNum + 1,
@@ -34,7 +34,7 @@ import {
34
34
  TEST_FILE_PATTERNS,
35
35
  MAX_FILE_SIZE
36
36
  } from '../utils/patterns.js';
37
- import { isHighEntropyMatch } from '../utils/entropy.js';
37
+ import { isHighEntropyMatch, isDocumentedSecretExample } from '../utils/entropy.js';
38
38
  import * as output from '../utils/output.js';
39
39
 
40
40
  // =============================================================================
@@ -439,6 +439,7 @@ async function scanFile(filePath) {
439
439
  pattern.pattern.lastIndex = 0;
440
440
  let match;
441
441
  while ((match = pattern.pattern.exec(line)) !== null) {
442
+ if (isDocumentedSecretExample(pattern.name, match[0])) continue;
442
443
  if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
443
444
  findings.push({
444
445
  line: lineNum + 1,
@@ -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);
@@ -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
  }