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/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
|
@@ -16,9 +16,10 @@ import fs from 'fs';
|
|
|
16
16
|
import path from 'path';
|
|
17
17
|
import chalk from 'chalk';
|
|
18
18
|
import ora from 'ora';
|
|
19
|
+
import { displayPath } from '../core/paths.js';
|
|
19
20
|
import { buildOrchestrator } from '../agents/index.js';
|
|
20
21
|
import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, MAX_FILE_SIZE } from '../utils/patterns.js';
|
|
21
|
-
import { isHighEntropyMatch } from '../utils/entropy.js';
|
|
22
|
+
import { isHighEntropyMatch, isDocumentedSecretExample } from '../utils/entropy.js';
|
|
22
23
|
import fg from '../core/glob.js';
|
|
23
24
|
|
|
24
25
|
const BASELINE_FILE = '.praxis/baseline.json';
|
|
@@ -28,7 +29,7 @@ const BASELINE_FILE = '.praxis/baseline.json';
|
|
|
28
29
|
* Uses rule + relative file path + first 40 chars of matched text.
|
|
29
30
|
*/
|
|
30
31
|
function fingerprint(finding, rootPath) {
|
|
31
|
-
const relFile =
|
|
32
|
+
const relFile = displayPath(finding.file || '', rootPath);
|
|
32
33
|
const matched = (finding.matched || '').slice(0, 40);
|
|
33
34
|
return `${finding.rule}:${relFile}:${matched}`;
|
|
34
35
|
}
|
|
@@ -60,6 +61,7 @@ async function quickScan(rootPath) {
|
|
|
60
61
|
p.pattern.lastIndex = 0;
|
|
61
62
|
let m;
|
|
62
63
|
while ((m = p.pattern.exec(lines[i])) !== null) {
|
|
64
|
+
if (isDocumentedSecretExample(p.name, m[0])) continue;
|
|
63
65
|
if (p.requiresEntropyCheck && !isHighEntropyMatch(m[0])) continue;
|
|
64
66
|
findings.push({ file, line: i + 1, rule: p.name, matched: m[0], severity: p.severity });
|
|
65
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
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
|
|
20
20
|
import fs from 'fs';
|
|
21
21
|
import path from 'path';
|
|
22
|
+
import { displayPath } from '../core/paths.js';
|
|
22
23
|
import { renderFindingsSARIF } from '../core/output/sarif.js';
|
|
24
|
+
import { validateDir } from '../core/fs.js';
|
|
23
25
|
import { execFileSync } from 'child_process';
|
|
24
26
|
import { buildOrchestrator } from '../agents/index.js';
|
|
25
27
|
import { ScoringEngine } from '../agents/scoring-engine.js';
|
|
@@ -34,7 +36,7 @@ import {
|
|
|
34
36
|
MAX_FILE_SIZE,
|
|
35
37
|
loadGitignorePatterns
|
|
36
38
|
} from '../utils/patterns.js';
|
|
37
|
-
import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
|
|
39
|
+
import { isHighEntropyMatch, getConfidence, isDocumentedSecretExample } from '../utils/entropy.js';
|
|
38
40
|
import { ThreatIntel } from '../utils/threat-intel.js';
|
|
39
41
|
import * as intelOrchestrator from '../utils/intel/index.js';
|
|
40
42
|
import fg from '../core/glob.js';
|
|
@@ -44,7 +46,7 @@ import fg from '../core/glob.js';
|
|
|
44
46
|
// =============================================================================
|
|
45
47
|
|
|
46
48
|
export async function ciCommand(targetPath = '.', options = {}) {
|
|
47
|
-
const absolutePath =
|
|
49
|
+
const absolutePath = validateDir(targetPath, { exitOnMissing: false });
|
|
48
50
|
const threshold = options.threshold ?? 75;
|
|
49
51
|
const failOn = options.failOn || null;
|
|
50
52
|
const alwaysFailOn = options.alwaysFailOn || null;
|
|
@@ -56,8 +58,8 @@ export async function ciCommand(targetPath = '.', options = {}) {
|
|
|
56
58
|
process.exit(2);
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
if (!
|
|
60
|
-
console.error(
|
|
61
|
+
if (!absolutePath) {
|
|
62
|
+
console.error('[praxis] CI scans require an existing directory.');
|
|
61
63
|
process.exit(1);
|
|
62
64
|
}
|
|
63
65
|
|
|
@@ -87,6 +89,8 @@ export async function ciCommand(targetPath = '.', options = {}) {
|
|
|
87
89
|
pattern.pattern.lastIndex = 0;
|
|
88
90
|
let match;
|
|
89
91
|
while ((match = pattern.pattern.exec(line)) !== null) {
|
|
92
|
+
if (isDocumentedSecretExample(pattern.name, match[0])) continue;
|
|
93
|
+
|
|
90
94
|
if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
|
|
91
95
|
secretFindings.push({
|
|
92
96
|
file, line: lineNum + 1, column: match.index + 1,
|
|
@@ -181,7 +185,7 @@ export async function ciCommand(targetPath = '.', options = {}) {
|
|
|
181
185
|
}])),
|
|
182
186
|
...(options.includeFindings ? {
|
|
183
187
|
findings: allFindings.map(f => ({
|
|
184
|
-
file:
|
|
188
|
+
file: displayPath(f.file, absolutePath),
|
|
185
189
|
line: f.line, rule: f.rule, severity: f.severity,
|
|
186
190
|
})),
|
|
187
191
|
} : {}),
|
|
@@ -203,7 +207,7 @@ export async function ciCommand(targetPath = '.', options = {}) {
|
|
|
203
207
|
if (critical > 0) {
|
|
204
208
|
console.log(`[praxis] Critical findings:`);
|
|
205
209
|
for (const f of allFindings.filter(f => f.severity === 'critical').slice(0, 5)) {
|
|
206
|
-
const rel =
|
|
210
|
+
const rel = displayPath(f.file, absolutePath);
|
|
207
211
|
console.log(` - ${f.rule} at ${rel}:${f.line}`);
|
|
208
212
|
}
|
|
209
213
|
}
|
|
@@ -274,7 +278,7 @@ function emitGitHubAnnotations(findings, rootPath) {
|
|
|
274
278
|
if (process.env.GITHUB_ACTIONS !== 'true') return;
|
|
275
279
|
for (const f of findings) {
|
|
276
280
|
if (!f.file || !f.line) continue;
|
|
277
|
-
const rel =
|
|
281
|
+
const rel = displayPath(f.file, rootPath);
|
|
278
282
|
const level = ['critical', 'high'].includes(f.severity) ? 'error' : 'warning';
|
|
279
283
|
const col = f.column || 1;
|
|
280
284
|
const escapeData = value => String(value).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
@@ -350,7 +354,7 @@ function postPRComment(scoreResult, findings, depVulns, rootPath, duration) {
|
|
|
350
354
|
body += `### Critical & High Findings\n\n`;
|
|
351
355
|
body += `| Severity | File | Issue |\n|----------|------|-------|\n`;
|
|
352
356
|
for (const f of findings.filter(f => f.severity === 'critical' || f.severity === 'high').slice(0, 20)) {
|
|
353
|
-
const rel =
|
|
357
|
+
const rel = displayPath(f.file, rootPath);
|
|
354
358
|
body += `| ${f.severity.toUpperCase()} | \`${rel}:${f.line}\` | ${(f.title || f.rule).slice(0, 60)} |\n`;
|
|
355
359
|
}
|
|
356
360
|
body += '\n';
|
package/cli/commands/diff.js
CHANGED
|
@@ -16,6 +16,7 @@ import { execFileSync } from 'child_process';
|
|
|
16
16
|
import path from 'path';
|
|
17
17
|
import chalk from 'chalk';
|
|
18
18
|
import ora from 'ora';
|
|
19
|
+
import { displayPath } from '../core/paths.js';
|
|
19
20
|
import { SECRET_PATTERNS, SECURITY_PATTERNS, SKIP_EXTENSIONS, SKIP_FILENAMES } from '../utils/patterns.js';
|
|
20
21
|
import { buildOrchestrator } from '../agents/index.js';
|
|
21
22
|
import { ScoringEngine } from '../agents/scoring-engine.js';
|
|
@@ -165,7 +166,7 @@ export async function diffCommand(ref, options) {
|
|
|
165
166
|
const sevColor = f.severity === 'critical' ? chalk.red :
|
|
166
167
|
f.severity === 'high' ? chalk.yellow :
|
|
167
168
|
f.severity === 'medium' ? chalk.cyan : chalk.gray;
|
|
168
|
-
const relPath =
|
|
169
|
+
const relPath = displayPath(f.file, absolutePath);
|
|
169
170
|
console.log(` ${sevColor(`[${f.severity.toUpperCase()}]`)} ${chalk.white(f.title)}`);
|
|
170
171
|
console.log(chalk.gray(` ${relPath}:${f.line} → ${f.fix || f.description}`));
|
|
171
172
|
shown++;
|
|
@@ -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/legal.js
CHANGED
|
@@ -19,6 +19,7 @@ import fs from 'fs';
|
|
|
19
19
|
import path from 'path';
|
|
20
20
|
import chalk from 'chalk';
|
|
21
21
|
import ora from 'ora';
|
|
22
|
+
import { displayPath } from '../core/paths.js';
|
|
22
23
|
import { LegalRiskAgent } from '../agents/legal-risk-agent.js';
|
|
23
24
|
import * as output from '../utils/output.js';
|
|
24
25
|
|
|
@@ -136,7 +137,7 @@ export async function legalCommand(targetPath = '.', options = {}) {
|
|
|
136
137
|
const riskLabel = RISK_LABELS[riskKey] || riskKey;
|
|
137
138
|
|
|
138
139
|
console.log(` ${sevBadge} ${chalk.white.bold(f.title)}`);
|
|
139
|
-
console.log(` ${riskColor(`[${riskLabel}]`)} ${chalk.gray(
|
|
140
|
+
console.log(` ${riskColor(`[${riskLabel}]`)} ${chalk.gray(displayPath(f.file, absolutePath))}`);
|
|
140
141
|
console.log();
|
|
141
142
|
console.log(` ${chalk.gray(f.description)}`);
|
|
142
143
|
console.log();
|
package/cli/commands/mcp.js
CHANGED
|
@@ -34,9 +34,12 @@
|
|
|
34
34
|
|
|
35
35
|
import fs from 'fs';
|
|
36
36
|
import path from 'path';
|
|
37
|
+
import writeFileAtomic from 'write-file-atomic';
|
|
38
|
+
import { displayPath } from '../core/paths.js';
|
|
39
|
+
import { validateDir } from '../core/fs.js';
|
|
37
40
|
import fg from '../core/glob.js';
|
|
38
41
|
import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, TEST_FILE_PATTERNS, MAX_FILE_SIZE } from '../utils/patterns.js';
|
|
39
|
-
import { isHighEntropyMatch } from '../utils/entropy.js';
|
|
42
|
+
import { isHighEntropyMatch, isDocumentedSecretExample } from '../utils/entropy.js';
|
|
40
43
|
import { buildOrchestrator } from '../agents/index.js';
|
|
41
44
|
import { ScoringEngine } from '../agents/scoring-engine.js';
|
|
42
45
|
import { autoDetectProvider } from '../providers/llm-provider.js';
|
|
@@ -90,7 +93,7 @@ const TOOLS = [
|
|
|
90
93
|
},
|
|
91
94
|
{
|
|
92
95
|
name: 'scan_repo',
|
|
93
|
-
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.',
|
|
94
97
|
inputSchema: {
|
|
95
98
|
type: 'object',
|
|
96
99
|
properties: {
|
|
@@ -117,7 +120,7 @@ const TOOLS = [
|
|
|
117
120
|
},
|
|
118
121
|
{
|
|
119
122
|
name: 'get_findings',
|
|
120
|
-
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.',
|
|
121
124
|
inputSchema: {
|
|
122
125
|
type: 'object',
|
|
123
126
|
properties: {
|
|
@@ -252,11 +255,10 @@ async function analyzeFile({ path: filePath }) {
|
|
|
252
255
|
};
|
|
253
256
|
}
|
|
254
257
|
|
|
255
|
-
async function
|
|
256
|
-
const rootPath =
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
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 };
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
// MCP communicates over stdout as JSON-RPC. Suppress all console output during
|
|
@@ -269,16 +271,15 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
|
|
|
269
271
|
console.log = console.warn = console.error = console.info = noop;
|
|
270
272
|
|
|
271
273
|
try {
|
|
272
|
-
const orchestrator = buildOrchestrator();
|
|
273
|
-
const context = { rootPath };
|
|
274
|
-
|
|
275
274
|
// Run all agents (quiet:true suppresses ora spinners; console is already nulled)
|
|
276
|
-
const { findings, recon } = await orchestrator.runAll(rootPath, {
|
|
275
|
+
const { findings, recon, agentResults } = await orchestrator.runAll(rootPath, {
|
|
277
276
|
agents: agentFilter,
|
|
278
277
|
timeout: 30000,
|
|
279
278
|
concurrency: 6,
|
|
280
279
|
quiet: true,
|
|
281
280
|
});
|
|
281
|
+
const scanErrors = (agentResults ?? []).filter(result => result.success === false)
|
|
282
|
+
.map(result => ({ stage: 'agent', agent: result.agent, message: result.error || 'Agent failed' }));
|
|
282
283
|
|
|
283
284
|
// Optional: LLM deep analysis
|
|
284
285
|
let deepStats = null;
|
|
@@ -288,12 +289,16 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
|
|
|
288
289
|
const analyzer = new DeepAnalyzer({ provider, budgetCents: 50, verbose: false });
|
|
289
290
|
await analyzer.analyze(findings, { rootPath, recon });
|
|
290
291
|
deepStats = analyzer.getStats();
|
|
292
|
+
} else {
|
|
293
|
+
scanErrors.push({ stage: 'deep-analysis', message: 'Requested LLM analysis has no configured provider' });
|
|
291
294
|
}
|
|
292
295
|
}
|
|
293
296
|
|
|
294
297
|
// Score
|
|
295
298
|
const scorer = new ScoringEngine();
|
|
296
|
-
const
|
|
299
|
+
const scoreResult = scorer.compute(findings);
|
|
300
|
+
const score = scoreResult.score;
|
|
301
|
+
const grade = scoreResult.grade.letter;
|
|
297
302
|
|
|
298
303
|
const SEV_ORDER = ['critical', 'high', 'medium', 'low'];
|
|
299
304
|
const bySeverity = {};
|
|
@@ -304,6 +309,9 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
|
|
|
304
309
|
const report = {
|
|
305
310
|
scannedAt: new Date().toISOString(),
|
|
306
311
|
rootPath,
|
|
312
|
+
scanComplete: scanErrors.length === 0,
|
|
313
|
+
scanErrors,
|
|
314
|
+
dependencyAudit: 'skipped',
|
|
307
315
|
score,
|
|
308
316
|
grade,
|
|
309
317
|
totalFindings: findings.length,
|
|
@@ -313,7 +321,7 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
|
|
|
313
321
|
severity: f.severity,
|
|
314
322
|
category: f.category,
|
|
315
323
|
rule: f.rule,
|
|
316
|
-
file: f.file ?
|
|
324
|
+
file: f.file ? displayPath(f.file, rootPath) : null,
|
|
317
325
|
line: f.line,
|
|
318
326
|
description: f.description,
|
|
319
327
|
remediation: f.remediation,
|
|
@@ -321,7 +329,7 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
|
|
|
321
329
|
...(f.deepAnalysis ? { deepAnalysis: f.deepAnalysis } : {}),
|
|
322
330
|
})),
|
|
323
331
|
...(deepStats ? { deepAnalysis: deepStats } : {}),
|
|
324
|
-
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.`,
|
|
325
333
|
};
|
|
326
334
|
|
|
327
335
|
if (outputFile) {
|
|
@@ -332,7 +340,7 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
|
|
|
332
340
|
|
|
333
341
|
return report;
|
|
334
342
|
} catch (err) {
|
|
335
|
-
return { error: `Scan failed: ${err.message}
|
|
343
|
+
return { error: `Scan failed: ${err.message}`, scanComplete: false, scanErrors: [{ stage: 'scan', message: err.message }] };
|
|
336
344
|
} finally {
|
|
337
345
|
// Always restore console so other tool calls are not affected
|
|
338
346
|
console.log = savedLog;
|
|
@@ -342,7 +350,7 @@ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, ou
|
|
|
342
350
|
}
|
|
343
351
|
}
|
|
344
352
|
|
|
345
|
-
function
|
|
353
|
+
export function mcpGetFindings({ reportPath, severity }) {
|
|
346
354
|
const absPath = path.resolve(reportPath);
|
|
347
355
|
|
|
348
356
|
if (!fs.existsSync(absPath)) {
|
|
@@ -367,6 +375,9 @@ function getFindings({ reportPath, severity }) {
|
|
|
367
375
|
scannedAt: report.scannedAt,
|
|
368
376
|
score: report.score,
|
|
369
377
|
grade: report.grade,
|
|
378
|
+
scanComplete: report.scanComplete,
|
|
379
|
+
scanErrors: report.scanErrors,
|
|
380
|
+
dependencyAudit: report.dependencyAudit,
|
|
370
381
|
totalFindings: filtered.length,
|
|
371
382
|
bySeverity: report.bySeverity,
|
|
372
383
|
findings: filtered,
|
|
@@ -375,7 +386,7 @@ function getFindings({ reportPath, severity }) {
|
|
|
375
386
|
};
|
|
376
387
|
}
|
|
377
388
|
|
|
378
|
-
function
|
|
389
|
+
export function mcpSuppressFinding({ file, line, reason }) {
|
|
379
390
|
const absPath = path.resolve(file);
|
|
380
391
|
|
|
381
392
|
if (!fs.existsSync(absPath)) {
|
|
@@ -392,7 +403,7 @@ function suppressFinding({ file, line, reason }) {
|
|
|
392
403
|
const lines = content.split('\n');
|
|
393
404
|
const lineIdx = line - 1; // Convert to 0-indexed
|
|
394
405
|
|
|
395
|
-
if (lineIdx < 0 || lineIdx >= lines.length) {
|
|
406
|
+
if (!Number.isInteger(line) || lineIdx < 0 || lineIdx >= lines.length) {
|
|
396
407
|
return { error: `Line ${line} is out of range (file has ${lines.length} lines)` };
|
|
397
408
|
}
|
|
398
409
|
|
|
@@ -410,7 +421,23 @@ function suppressFinding({ file, line, reason }) {
|
|
|
410
421
|
return { error: `Cannot suppress critical severity finding: ${criticalFinding.description}` };
|
|
411
422
|
}
|
|
412
423
|
|
|
413
|
-
|
|
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.
|
|
414
441
|
const suppressionsDir = path.join(process.cwd(), '.praxis');
|
|
415
442
|
if (!fs.existsSync(suppressionsDir)) {
|
|
416
443
|
try {
|
|
@@ -427,7 +454,7 @@ function suppressFinding({ file, line, reason }) {
|
|
|
427
454
|
suppressions.push({
|
|
428
455
|
file: path.relative(process.cwd(), absPath),
|
|
429
456
|
line,
|
|
430
|
-
reason,
|
|
457
|
+
reason: safeReason,
|
|
431
458
|
timestamp: new Date().toISOString(),
|
|
432
459
|
status: 'pending_review'
|
|
433
460
|
});
|
|
@@ -435,38 +462,13 @@ function suppressFinding({ file, line, reason }) {
|
|
|
435
462
|
fs.writeFileSync(suppressionsFile, JSON.stringify(suppressions, null, 2), 'utf-8');
|
|
436
463
|
} catch {}
|
|
437
464
|
|
|
438
|
-
// Detect indentation and comment style
|
|
439
|
-
const indent = targetLine.match(/^(\s*)/)?.[1] ?? '';
|
|
440
|
-
const isJs = /\.(js|ts|jsx|tsx|mjs|cjs|java|c|cpp|cs|go|rs|swift|kt)$/.test(file);
|
|
441
|
-
const isPy = /\.py$/.test(file);
|
|
442
|
-
const isRb = /\.rb$/.test(file);
|
|
443
|
-
const isHtml = /\.(html?|vue|svelte)$/.test(file);
|
|
444
|
-
|
|
445
|
-
let ignoreComment;
|
|
446
|
-
if (isHtml) {
|
|
447
|
-
ignoreComment = `${indent}<!-- praxis-ignore — ${reason} -->`;
|
|
448
|
-
} else if (isPy || isRb) {
|
|
449
|
-
ignoreComment = `${indent}# praxis-ignore — ${reason}`;
|
|
450
|
-
} else {
|
|
451
|
-
ignoreComment = `${indent}// praxis-ignore — ${reason}`;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// Insert ignore comment on the line BEFORE the finding
|
|
455
|
-
lines.splice(lineIdx, 0, ignoreComment);
|
|
456
|
-
|
|
457
|
-
try {
|
|
458
|
-
fs.writeFileSync(absPath, lines.join('\n'), 'utf-8');
|
|
459
|
-
} catch (err) {
|
|
460
|
-
return { error: `Cannot write file: ${err.message}` };
|
|
461
|
-
}
|
|
462
|
-
|
|
463
465
|
return {
|
|
464
466
|
suppressed: true,
|
|
465
467
|
file: absPath,
|
|
466
468
|
originalLine: line,
|
|
467
|
-
insertedLine: line,
|
|
469
|
+
insertedLine: line,
|
|
468
470
|
comment: ignoreComment,
|
|
469
|
-
message: `Added praxis-ignore comment
|
|
471
|
+
message: `Added trailing praxis-ignore comment on line ${line} in ${path.basename(file)}.`,
|
|
470
472
|
};
|
|
471
473
|
}
|
|
472
474
|
|
|
@@ -505,6 +507,7 @@ function scanFile(filePath) {
|
|
|
505
507
|
pattern.pattern.lastIndex = 0;
|
|
506
508
|
let match;
|
|
507
509
|
while ((match = pattern.pattern.exec(line)) !== null) {
|
|
510
|
+
if (isDocumentedSecretExample(pattern.name, match[0])) continue;
|
|
508
511
|
if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
|
|
509
512
|
findings.push({
|
|
510
513
|
line: lineNum + 1,
|
|
@@ -628,13 +631,13 @@ async function handleRequest(request) {
|
|
|
628
631
|
result = await analyzeFile(args);
|
|
629
632
|
break;
|
|
630
633
|
case 'scan_repo':
|
|
631
|
-
result = await
|
|
634
|
+
result = await mcpScanRepo(args);
|
|
632
635
|
break;
|
|
633
636
|
case 'get_findings':
|
|
634
|
-
result =
|
|
637
|
+
result = mcpGetFindings(args);
|
|
635
638
|
break;
|
|
636
639
|
case 'suppress_finding':
|
|
637
|
-
result =
|
|
640
|
+
result = mcpSuppressFinding(args);
|
|
638
641
|
break;
|
|
639
642
|
case 'explain_and_fix':
|
|
640
643
|
result = await explainAndFix(args);
|
package/cli/commands/openclaw.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import fs from 'fs';
|
|
16
16
|
import path from 'path';
|
|
17
17
|
import chalk from 'chalk';
|
|
18
|
+
import { displayPath } from '../core/paths.js';
|
|
18
19
|
import * as output from '../utils/output.js';
|
|
19
20
|
import { AgentConfigScanner } from '../agents/agent-config-scanner.js';
|
|
20
21
|
import { MCPSecurityAgent } from '../agents/mcp-security-agent.js';
|
|
@@ -138,11 +139,7 @@ async function runJsonMode(absolutePath, options) {
|
|
|
138
139
|
const findings = [...configFindings, ...mcpFindings];
|
|
139
140
|
const normFindings = findings.map(f => ({
|
|
140
141
|
...f,
|
|
141
|
-
file:
|
|
142
|
-
.replace(/\\/g, '/')
|
|
143
|
-
.replace(/^[a-zA-Z]:\/+/, '')
|
|
144
|
-
.replace(/^.*\/Praxis\/showcase-target\//, 'showcase-target/')
|
|
145
|
-
.replace(/^.*\/Praxis\//, ''),
|
|
142
|
+
file: displayPath(f.file, absolutePath),
|
|
146
143
|
}));
|
|
147
144
|
const result = {
|
|
148
145
|
findings: normFindings,
|
|
@@ -174,7 +171,7 @@ function printFindings(findings, rootPath) {
|
|
|
174
171
|
findings.sort((a, b) => (sevOrder[a.severity] ?? 4) - (sevOrder[b.severity] ?? 4));
|
|
175
172
|
|
|
176
173
|
for (const f of findings) {
|
|
177
|
-
const relFile =
|
|
174
|
+
const relFile = displayPath(f.file, rootPath);
|
|
178
175
|
const sevLabel = f.severity === 'critical' ? chalk.red.bold('CRITICAL')
|
|
179
176
|
: f.severity === 'high' ? chalk.yellow('HIGH')
|
|
180
177
|
: chalk.blue('MEDIUM');
|
package/cli/commands/red-team.js
CHANGED
|
@@ -18,6 +18,7 @@ import path from 'path';
|
|
|
18
18
|
import { renderFindingsSARIF } from '../core/output/sarif.js';
|
|
19
19
|
import chalk from 'chalk';
|
|
20
20
|
import ora from 'ora';
|
|
21
|
+
import { displayPath } from '../core/paths.js';
|
|
21
22
|
import { buildOrchestratorAsync } from '../agents/index.js';
|
|
22
23
|
import { SwarmOrchestrator } from '../agents/swarm-orchestrator.js';
|
|
23
24
|
import { ReconAgent } from '../agents/recon-agent.js';
|
|
@@ -267,7 +268,7 @@ function printResults(scoreResult, findings, recon, agentResults, depVulns, root
|
|
|
267
268
|
console.log(chalk.yellow(' ' + '─'.repeat(58)));
|
|
268
269
|
|
|
269
270
|
for (const f of findings.slice(0, 20)) {
|
|
270
|
-
const relFile =
|
|
271
|
+
const relFile = displayPath(f.file, rootPath);
|
|
271
272
|
const sevColor = SEV_COLOR[f.severity] || chalk.white;
|
|
272
273
|
const aiTag = f.aiClassification === 'FALSE_POSITIVE'
|
|
273
274
|
? chalk.gray(' [FP]')
|
|
@@ -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,
|