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.
- package/README.md +84 -115
- package/ai-defense/cost-protection.md +6 -0
- package/ai-defense/llm-security-checklist.md +6 -0
- package/ai-defense/system-prompt-armor.md +7 -1
- package/checklists/launch-day.md +6 -7
- package/cli/agents/agent-telemetry-agent.js +2 -0
- package/cli/agents/api-fuzzer.js +2 -2
- package/cli/agents/git-history-scanner.js +14 -15
- package/cli/agents/html-reporter.js +2 -1
- package/cli/agents/memory-poisoning-agent.js +1 -5
- package/cli/commands/agent-fix.js +3 -1
- package/cli/commands/audit.js +1271 -1231
- package/cli/commands/autofix.js +32 -13
- package/cli/commands/baseline.js +2 -1
- package/cli/commands/benchmark.js +2 -1
- package/cli/commands/ci.js +7 -4
- package/cli/commands/env-audit.js +4 -2
- package/cli/commands/fix.js +2 -1
- package/cli/commands/mcp.js +52 -50
- package/cli/commands/remediate.js +2 -1
- package/cli/commands/rotate.js +2 -1
- package/cli/commands/scan-mcp.js +20 -9
- package/cli/commands/scan.js +15 -7
- package/cli/commands/score.js +2 -1
- package/cli/commands/vibe-check.js +2 -1
- package/cli/commands/watch.js +2 -1
- package/cli/core/glob.js +7 -5
- package/cli/core/paths.js +4 -4
- package/cli/core/web/jobs.js +2 -0
- package/cli/data/documented-secret-examples.json +14 -0
- package/cli/utils/entropy.js +19 -0
- package/cli/utils/hermes-tool-registry.js +11 -9
- package/configs/firebase/security-checklist.md +3 -3
- package/configs/supabase/security-checklist.md +19 -21
- package/docs/RELEASE-1.2.4.md +85 -0
- package/docs/RELEASING.md +51 -0
- package/docs/THIRD_PARTY_NOTICES.md +8 -0
- package/docs/THREAT_INTEL.md +4 -2
- package/docs/USAGE.md +97 -76
- package/package.json +82 -81
- package/snippets/README.md +6 -0
- package/snippets/auth/jwt-checklist.md +14 -13
package/cli/commands/autofix.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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 (
|
|
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
|
-
|
|
338
|
+
writeFileAtomic.sync(filePath, lines.join('\n'), { encoding: 'utf8' });
|
|
339
|
+
return true;
|
|
321
340
|
}
|
|
322
341
|
|
|
323
342
|
function buildPRBody(applied, failed, reportPath) {
|
package/cli/commands/baseline.js
CHANGED
|
@@ -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,
|
package/cli/commands/ci.js
CHANGED
|
@@ -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 =
|
|
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 (!
|
|
61
|
-
console.error(
|
|
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
|
-
|
|
142
|
-
|
|
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',
|
package/cli/commands/fix.js
CHANGED
|
@@ -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,
|
package/cli/commands/mcp.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
257
|
-
const rootPath =
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
469
|
+
insertedLine: line,
|
|
469
470
|
comment: ignoreComment,
|
|
470
|
-
message: `Added praxis-ignore comment
|
|
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
|
|
634
|
+
result = await mcpScanRepo(args);
|
|
633
635
|
break;
|
|
634
636
|
case 'get_findings':
|
|
635
|
-
result =
|
|
637
|
+
result = mcpGetFindings(args);
|
|
636
638
|
break;
|
|
637
639
|
case 'suppress_finding':
|
|
638
|
-
result =
|
|
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,
|
package/cli/commands/rotate.js
CHANGED
|
@@ -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,
|
package/cli/commands/scan-mcp.js
CHANGED
|
@@ -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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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(
|
|
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);
|
package/cli/commands/scan.js
CHANGED
|
@@ -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 =
|
|
118
|
+
const absolutePath = validateDir(effectivePath, { exitOnMissing: false });
|
|
117
119
|
|
|
118
120
|
// Validate path exists
|
|
119
|
-
if (!
|
|
121
|
+
if (!absolutePath) {
|
|
120
122
|
cleanup();
|
|
121
|
-
|
|
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
|
-
|
|
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',
|
package/cli/commands/score.js
CHANGED
|
@@ -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
|
}
|