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.
- 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/assets/praxis-architecture.svg +304 -0
- package/assets/praxis-logo.svg +38 -0
- 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 +9 -8
- package/cli/agents/mcp-security-agent.js +600 -594
- package/cli/agents/memory-poisoning-agent.js +1 -5
- package/cli/agents/orchestrator.js +375 -360
- package/cli/commands/agent-fix.js +3 -1
- package/cli/commands/audit.js +1272 -1228
- package/cli/commands/autofix.js +32 -13
- package/cli/commands/baseline.js +4 -2
- package/cli/commands/benchmark.js +2 -1
- package/cli/commands/ci.js +12 -8
- package/cli/commands/diff.js +2 -1
- package/cli/commands/env-audit.js +4 -2
- package/cli/commands/fix.js +2 -1
- package/cli/commands/legal.js +2 -1
- package/cli/commands/mcp.js +54 -51
- package/cli/commands/openclaw.js +3 -6
- package/cli/commands/red-team.js +2 -1
- 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-standard.js +3 -6
- package/cli/commands/scan.js +15 -7
- package/cli/commands/score.js +2 -1
- package/cli/commands/vibe-check.js +4 -2
- package/cli/commands/watch.js +8 -6
- package/cli/core/glob.js +7 -5
- package/cli/core/output/json.js +56 -48
- package/cli/core/paths.js +91 -0
- package/cli/core/web/jobs.js +2 -0
- package/cli/data/documented-secret-examples.json +14 -0
- package/cli/utils/cache-manager.js +2 -1
- 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 +6 -4
- package/snippets/README.md +6 -0
- package/snippets/auth/jwt-checklist.md +14 -13
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);
|
|
@@ -16,6 +16,7 @@ import fs from 'fs';
|
|
|
16
16
|
import path from 'path';
|
|
17
17
|
import chalk from 'chalk';
|
|
18
18
|
import ora from 'ora';
|
|
19
|
+
import { displayPath } from '../core/paths.js';
|
|
19
20
|
import { buildOrchestratorAsync } from '../agents/index.js';
|
|
20
21
|
import { ScoringEngine } from '../agents/scoring-engine.js';
|
|
21
22
|
import {
|
|
@@ -102,11 +103,7 @@ export async function scanStandardCommand(name, targetPath = '.', options = {})
|
|
|
102
103
|
controls: standardSummary.controls,
|
|
103
104
|
findings: filtered.map(f => ({
|
|
104
105
|
...f,
|
|
105
|
-
file:
|
|
106
|
-
.replace(/\\/g, '/')
|
|
107
|
-
.replace(/^[a-zA-Z]:\/+/, '')
|
|
108
|
-
.replace(/^.*\/Praxis\/showcase-target\//, 'showcase-target/')
|
|
109
|
-
.replace(/^.*\/Praxis\//, ''),
|
|
106
|
+
file: displayPath(f.file, absolutePath),
|
|
110
107
|
})),
|
|
111
108
|
score: scoreResult.score,
|
|
112
109
|
grade: scoreResult.grade?.letter || 'A',
|
|
@@ -239,7 +236,7 @@ async function printHumanReport(standard, report) { console.log(chalk.white.bol
|
|
|
239
236
|
const sev = (f.severity || 'medium').toUpperCase();
|
|
240
237
|
const color = SEV_COLORS[f.severity] || chalk.white;
|
|
241
238
|
const tag = ids.join(', ');
|
|
242
|
-
const file = f.file ?
|
|
239
|
+
const file = f.file ? displayPath(f.file, process.cwd()) : '';
|
|
243
240
|
const loc = file ? `${file}:${f.line || 0}` : '';
|
|
244
241
|
console.log(` ${color(`[${sev}]`)} ${chalk.cyan(`[${tag}]`)} ${f.title || f.rule || ''}`);
|
|
245
242
|
if (loc) console.log(chalk.gray(` ${loc}`));
|
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
|
}
|
|
@@ -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 =
|
|
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();
|
package/cli/commands/watch.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
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
|
-
*
|
|
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
|
|
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
|
|
53
|
+
return discover(patterns, safeOptions(patterns, options));
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
glob.sync = (patterns, options = {}) =>
|
|
56
|
+
glob.sync = (patterns, options = {}) => globSync(patterns, safeOptions(patterns, options));
|
|
55
57
|
|
|
56
58
|
export default glob;
|
package/cli/core/output/json.js
CHANGED
|
@@ -1,48 +1,56 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* JSON output formatter.
|
|
3
|
-
*
|
|
4
|
-
* Reports are emitted with `schemaVersion` so consumers can pin to a
|
|
5
|
-
* version. Bump the version when the shape changes in a breaking way.
|
|
6
|
-
*
|
|
7
|
-
* Scanner hardening: secret-category findings never expose their raw
|
|
8
|
-
* matched value — `matched` is redacted centrally so no consumer of the
|
|
9
|
-
* JSON report can leak a credential.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
1
|
+
/**
|
|
2
|
+
* JSON output formatter.
|
|
3
|
+
*
|
|
4
|
+
* Reports are emitted with `schemaVersion` so consumers can pin to a
|
|
5
|
+
* version. Bump the version when the shape changes in a breaking way.
|
|
6
|
+
*
|
|
7
|
+
* Scanner hardening: secret-category findings never expose their raw
|
|
8
|
+
* matched value — `matched` is redacted centrally so no consumer of the
|
|
9
|
+
* JSON report can leak a credential. Finding paths are normalised against the scan
|
|
10
|
+
* root by the orchestrator, so no consumer can receive an absolute path either.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import path from 'path';
|
|
14
|
+
|
|
15
|
+
const SCHEMA_VERSION = 3;
|
|
16
|
+
|
|
17
|
+
function redactFinding(f) {
|
|
18
|
+
if (!f || typeof f !== 'object') return f;
|
|
19
|
+
const isSecret = f.category === 'secrets' || f.category === 'secret'
|
|
20
|
+
|| /secret|api[_-]?key|token|password|credential/i.test(String(f.rule || ''));
|
|
21
|
+
const out = { ...f };
|
|
22
|
+
if (f.matched) {
|
|
23
|
+
out.matched = isSecret
|
|
24
|
+
? `${String(f.matched).slice(0, 3)}***`
|
|
25
|
+
: String(f.matched).slice(0, 160);
|
|
26
|
+
}
|
|
27
|
+
if (out.file) {
|
|
28
|
+
// Paths arrive already normalised against the scan root by the orchestrator,
|
|
29
|
+
// so this only has to unify separators. The three strippers that used to live
|
|
30
|
+
// here were wrong for everyone but this repository:
|
|
31
|
+
// `^[a-zA-Z]:\/+` turned `C:\Users\alice\.cursor\mcp.json` into
|
|
32
|
+
// `Users/alice/.cursor/mcp.json` — leaking the username and
|
|
33
|
+
// naming a repo-relative file that does not exist;
|
|
34
|
+
// the two `/Praxis/` rules were dogfooding hacks that silently truncated any
|
|
35
|
+
// real path merely containing a directory called `Praxis`.
|
|
36
|
+
// See cli/core/paths.js for the replacement.
|
|
37
|
+
out.file = String(out.file).split(path.sep).join('/').replace(/\\/g, '/');
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function json(report, options = {}) {
|
|
43
|
+
const { pretty = true } = options;
|
|
44
|
+
const enriched = {
|
|
45
|
+
schemaVersion: SCHEMA_VERSION,
|
|
46
|
+
...report,
|
|
47
|
+
};
|
|
48
|
+
if (Array.isArray(enriched.findings)) {
|
|
49
|
+
enriched.findings = enriched.findings.map(redactFinding);
|
|
50
|
+
}
|
|
51
|
+
return pretty
|
|
52
|
+
? JSON.stringify(enriched, null, 2)
|
|
53
|
+
: JSON.stringify(enriched);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { redactFinding, SCHEMA_VERSION };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Honest, publishable paths for findings.
|
|
3
|
+
*
|
|
4
|
+
* A finding is not always about a file inside the scanned tree.
|
|
5
|
+
* `MCP_SHADOW_CONFIG` reports the developer's own `~/.cursor/mcp.json`, because a
|
|
6
|
+
* shadow MCP server configured outside version control is precisely the finding
|
|
7
|
+
* worth reporting. That file sits above the scan root, and neither obvious way of
|
|
8
|
+
* rendering it is acceptable:
|
|
9
|
+
*
|
|
10
|
+
* path.relative(root, file) -> '../../../Users/alice/.cursor/mcp.json'
|
|
11
|
+
* leaks the username and the directory depth into
|
|
12
|
+
* a report that gets pasted into a CI comment.
|
|
13
|
+
* strip the drive letter -> 'Users/alice/.cursor/mcp.json'
|
|
14
|
+
* does not exist, and falsely reads as
|
|
15
|
+
* repo-relative, so the finding is unlocatable.
|
|
16
|
+
*
|
|
17
|
+
* The only honest rendering of a path above the root is `~/…`. It identifies the
|
|
18
|
+
* file, leaks neither the username nor the directory layout, and — unlike either
|
|
19
|
+
* alternative — is identical on every machine, so a self-scan's findings do not
|
|
20
|
+
* change identity when a colleague runs it.
|
|
21
|
+
*
|
|
22
|
+
* Applied once by the orchestrator, before findings reach any renderer, because
|
|
23
|
+
* the terminal table, the HTML and JSON reports, CI annotations and SARIF all read
|
|
24
|
+
* the same `finding.file`. `fix` and `remediate` build their own absolute paths and
|
|
25
|
+
* never read this field, so normalising it cannot redirect a write.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import os from 'os';
|
|
29
|
+
import path from 'path';
|
|
30
|
+
|
|
31
|
+
const toSlash = (p) => String(p).split(path.sep).join('/').replace(/\\/g, '/');
|
|
32
|
+
|
|
33
|
+
// `path.isAbsolute` is platform-specific: on POSIX it reports false for
|
|
34
|
+
// `C:/work/src/a.js`, because that is a legal relative filename there. Findings
|
|
35
|
+
// normally come from the host's own glob, but a path read back from a report or
|
|
36
|
+
// a cache can carry the other platform's shape, and treating it as relative would
|
|
37
|
+
// print a drive letter straight into the output. Same guard sarif.js uses.
|
|
38
|
+
const isAbsoluteLike = (s) => path.isAbsolute(s) || /^[a-zA-Z]:[\\/]/.test(s) || /^\\\\/.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;
|
package/cli/core/web/jobs.js
CHANGED
|
@@ -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 =
|
|
204
|
+
const relPath = displayPath(f.file, this.rootPath);
|
|
204
205
|
if (!lastFindings[relPath]) lastFindings[relPath] = [];
|
|
205
206
|
// Store a lightweight copy (no absolute paths)
|
|
206
207
|
lastFindings[relPath].push({
|
package/cli/utils/entropy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
'
|
|
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: '
|
|
188
|
-
praxis_scan_mcp: '
|
|
189
|
-
praxis_get_findings: '
|
|
190
|
-
praxis_suppress_finding: '
|
|
191
|
-
praxis_memory_list: '
|
|
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) {
|