praxis-sec 1.1.0 → 1.2.1
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/cli/agents/abom-generator.js +1 -1
- package/cli/agents/agent-attestation-agent.js +16 -2
- package/cli/agents/agent-config-scanner.js +1 -1
- package/cli/agents/agent-telemetry-agent.js +8 -1
- package/cli/agents/ai-infra-inventory-agent.js +482 -453
- package/cli/agents/base-agent.js +61 -1
- package/cli/agents/endpoint-agent-abuse-agent.js +2 -2
- package/cli/agents/git-history-scanner.js +1 -1
- package/cli/agents/html-reporter.js +3 -3
- package/cli/agents/index.js +2 -2
- package/cli/agents/injection-tester.js +8 -1
- package/cli/agents/mcp-security-agent.js +7 -1
- package/cli/agents/memory-poisoning-agent.js +1 -1
- package/cli/agents/model-file-scanner.js +1 -1
- package/cli/agents/orchestrator.js +11 -6
- package/cli/agents/prompt-injection-prober.js +228 -224
- package/cli/bin/praxis.js +7 -3
- package/cli/commands/agent-fix.js +1091 -1245
- package/cli/commands/audit.js +1228 -1216
- package/cli/commands/baseline.js +1 -1
- package/cli/commands/benchmark.js +1 -1
- package/cli/commands/ci.js +45 -21
- package/cli/commands/deps.js +11 -5
- package/cli/commands/env-audit.js +1 -1
- package/cli/commands/fix.js +1 -1
- package/cli/commands/mcp.js +2 -2
- package/cli/commands/openclaw.js +1 -1
- package/cli/commands/red-team.js +350 -350
- package/cli/commands/remediate.js +1 -1
- package/cli/commands/rotate.js +1 -1
- package/cli/commands/rules.js +1 -1
- package/cli/commands/scan.js +554 -554
- package/cli/commands/score.js +1 -1
- package/cli/commands/undo.js +22 -77
- package/cli/commands/vibe-check.js +1 -1
- package/cli/core/fix-plan.js +274 -0
- package/cli/core/fs.js +27 -0
- package/cli/core/git-clone.js +8 -6
- package/cli/core/glob.js +56 -0
- package/cli/core/output/html-theme.js +158 -158
- package/cli/core/output/sarif.js +2 -2
- package/cli/core/web/jobs.js +2 -2
- package/cli/core/web/server.js +19 -8
- package/cli/data/threatpacks/latest.json +41 -41
- package/cli/integrations/github-action.js +136 -0
- package/cli/utils/plugin-loader.js +15 -95
- package/cli/utils/rule-import.js +227 -227
- package/cli/utils/rule-registry.js +425 -425
- package/cli/utils/scan-fingerprint.js +1 -1
- package/cli/utils/score-history.js +118 -118
- package/docs/USAGE.md +16 -9
- package/docs/design/WEB-UI.md +4 -5
- package/package.json +13 -4
package/cli/agents/base-agent.js
CHANGED
|
@@ -17,9 +17,63 @@
|
|
|
17
17
|
|
|
18
18
|
import fs from 'fs';
|
|
19
19
|
import path from 'path';
|
|
20
|
-
import fg from '
|
|
20
|
+
import fg from '../core/glob.js';
|
|
21
21
|
import { SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, MAX_FILE_SIZE, MAX_SCAN_FILES, loadGitignorePatterns } from '../utils/patterns.js';
|
|
22
22
|
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// RULE-TABLE SUPPRESSION
|
|
25
|
+
// =============================================================================
|
|
26
|
+
//
|
|
27
|
+
// A detection rule table is data, not code, and it necessarily contains the
|
|
28
|
+
// signatures it hunts for: a rule's `description:` spells out the insecure call
|
|
29
|
+
// it is looking for, so that prose matches the rule itself. Scanning our own
|
|
30
|
+
// tables therefore reported every rule describing itself — 138 of 271 findings
|
|
31
|
+
// in a self-scan, over half the report.
|
|
32
|
+
//
|
|
33
|
+
// Keep this comment free of concrete API names: quoting one makes this file a
|
|
34
|
+
// match for the very rule being discussed.
|
|
35
|
+
//
|
|
36
|
+
// Suppression is deliberately two-stage so it cannot mask a real finding:
|
|
37
|
+
// 1. the FILE must be a rule table (two or more matcher entries against rule
|
|
38
|
+
// ids — something application code never declares), and
|
|
39
|
+
// 2. the LINE must be a rule field holding prose or a pattern.
|
|
40
|
+
// A file that merely uses the words "description" or "fix" fails stage 1 and is
|
|
41
|
+
// scanned normally.
|
|
42
|
+
|
|
43
|
+
const RULE_MATCHER_FIELD = /^\s*(?:regex|pattern|detectionRegex)\s*:/;
|
|
44
|
+
const RULE_ID_FIELD = /^\s*(?:rule|id|name)\s*:\s*['"`]/;
|
|
45
|
+
const RULE_PROSE_FIELD =
|
|
46
|
+
/^\s*(?:title|description|fix|note|recommendation|regex|pattern|detectionRegex|severity|cwe|owasp|confidence|id|rule)\s*:/;
|
|
47
|
+
|
|
48
|
+
const MIN_RULE_TABLE_ENTRIES = 2;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the set of line indexes that hold rule-table prose rather than code.
|
|
52
|
+
*
|
|
53
|
+
* Returns `null` when the file is not a rule table, so callers get "scan this
|
|
54
|
+
* file normally" for free. Reuse the returned Set for the whole file: building
|
|
55
|
+
* it is one cheap pass, and callers that scan line by line would otherwise
|
|
56
|
+
* repeat it per line.
|
|
57
|
+
*
|
|
58
|
+
* @param {string[]} lines
|
|
59
|
+
* @returns {Set<number>|null} zero-based indexes of rule-definition lines
|
|
60
|
+
*/
|
|
61
|
+
export function ruleTableLineMask(lines) {
|
|
62
|
+
let matchers = 0;
|
|
63
|
+
let ids = 0;
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
if (RULE_MATCHER_FIELD.test(line)) matchers++;
|
|
66
|
+
else if (RULE_ID_FIELD.test(line)) ids++;
|
|
67
|
+
}
|
|
68
|
+
if (matchers < MIN_RULE_TABLE_ENTRIES || ids < MIN_RULE_TABLE_ENTRIES) return null;
|
|
69
|
+
|
|
70
|
+
const mask = new Set();
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
if (RULE_PROSE_FIELD.test(lines[i])) mask.add(i);
|
|
73
|
+
}
|
|
74
|
+
return mask;
|
|
75
|
+
}
|
|
76
|
+
|
|
23
77
|
// =============================================================================
|
|
24
78
|
// FINDING FACTORY
|
|
25
79
|
// =============================================================================
|
|
@@ -225,9 +279,15 @@ export class BaseAgent {
|
|
|
225
279
|
const lines = content.split('\n');
|
|
226
280
|
const findings = [];
|
|
227
281
|
|
|
282
|
+
// Rule tables state what a vulnerability looks like, so their prose and
|
|
283
|
+
// patterns match the rules looking for them. Skipping those lines is what
|
|
284
|
+
// stops a self-scan from reporting every rule describing itself.
|
|
285
|
+
const ruleTable = ruleTableLineMask(lines);
|
|
286
|
+
|
|
228
287
|
for (let i = 0; i < lines.length; i++) {
|
|
229
288
|
const line = lines[i];
|
|
230
289
|
if (this.isSuppressed(line)) continue;
|
|
290
|
+
if (ruleTable && ruleTable.has(i)) continue;
|
|
231
291
|
|
|
232
292
|
for (const p of patterns) {
|
|
233
293
|
p.regex.lastIndex = 0;
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import fs from 'fs';
|
|
29
29
|
import path from 'path';
|
|
30
|
-
import fg from '
|
|
30
|
+
import fg from '../core/glob.js';
|
|
31
31
|
import { BaseAgent, createFinding } from './base-agent.js';
|
|
32
32
|
|
|
33
33
|
// =============================================================================
|
|
@@ -247,7 +247,7 @@ export class EndpointAgentAbuseAgent extends BaseAgent {
|
|
|
247
247
|
category: this.category,
|
|
248
248
|
rule: 'EAA_AGENT_CLI_IN_LIFECYCLE',
|
|
249
249
|
title: `Lifecycle Script "${name}" Invokes an AI Agent CLI`,
|
|
250
|
-
description: `An npm lifecycle hook launches a coding-agent CLI. Package installs run without user review, making this a vector for driving a trusted agent from an untrusted parent (EAA-001, observed in the Nx s1ngularity and Trivy OpenVSX incidents).`,
|
|
250
|
+
description: `An npm lifecycle hook launches a coding-agent CLI. Package installs run without user review, making this a vector for driving a trusted agent from an untrusted parent (EAA-001, observed in the Nx s1ngularity and Trivy OpenVSX incidents).`, // praxis-ignore AGENT_RECURSIVE_INVOCATION — description of a finding this scanner emits, not an agent definition
|
|
251
251
|
matched: cmd.slice(0, 200),
|
|
252
252
|
confidence: 'medium',
|
|
253
253
|
cwe: 'CWE-506',
|
|
@@ -99,7 +99,7 @@ export class GitHistoryScanner extends BaseAgent {
|
|
|
99
99
|
severity: stillExists ? p.severity : this.elevateSeverity(p.severity),
|
|
100
100
|
category: 'history',
|
|
101
101
|
rule: 'GIT_HISTORY_SECRET',
|
|
102
|
-
title: `Historical Secret: ${p.name}`,
|
|
102
|
+
title: `Historical Secret: ${p.name}`, // praxis-ignore AGENT_LOG_SECRET_KV — title template of a finding this scanner emits, not an agent definition
|
|
103
103
|
description: stillExists
|
|
104
104
|
? `Secret found in current code AND in git history (commit ${currentCommit}).`
|
|
105
105
|
: `Secret was removed from code but still exists in git history (commit ${currentCommit}, ${currentDate}). Anyone with repo access can retrieve it.`,
|
|
@@ -55,7 +55,7 @@ export class HTMLReporter {
|
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* The provenance line printed in report footers: exactly which tool, runtime and
|
|
58
|
-
* vendored data assets produced this document
|
|
58
|
+
* vendored data assets produced this document. A surprising result should
|
|
59
59
|
* be attributable, not mysterious.
|
|
60
60
|
*/
|
|
61
61
|
getFingerprintLine(filesScanned = null) {
|
|
@@ -880,7 +880,7 @@ function toggleDetail(id) {
|
|
|
880
880
|
/**
|
|
881
881
|
* Score trend over time, from `.praxis/history.json`.
|
|
882
882
|
*
|
|
883
|
-
* This is the part that must not overstate
|
|
883
|
+
* This is the part that must not overstate. An empty graph reads as
|
|
884
884
|
* "flat, no change", which is a different claim from "we have no data", so:
|
|
885
885
|
* - no prior scans → say the project is at its baseline
|
|
886
886
|
* - fewer than 3 measurements → say a trend needs more, and show what exists
|
|
@@ -985,7 +985,7 @@ function toggleDetail(id) {
|
|
|
985
985
|
}
|
|
986
986
|
|
|
987
987
|
/**
|
|
988
|
-
* Remediation Ledger — the applied half of the find→fix→verify loop
|
|
988
|
+
* Remediation Ledger — the applied half of the find→fix→verify loop.
|
|
989
989
|
*
|
|
990
990
|
* Reads `.praxis/fixes.jsonl` (currently applied changes) and `.praxis/failures.jsonl`
|
|
991
991
|
* (plans proposed and rejected). Rejections are the more telling half: a rejected fix
|
package/cli/agents/index.js
CHANGED
|
@@ -123,11 +123,11 @@ export function buildOrchestrator() {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
|
-
* Async build — loads built-
|
|
126
|
+
* Async build — loads built-ins and, with explicit trust, .praxis/agents/ plugins.
|
|
127
127
|
* Preferred over buildOrchestrator() when rootPath is available.
|
|
128
128
|
*
|
|
129
129
|
* @param {string} rootPath — project root (for plugin discovery)
|
|
130
|
-
* @param {object} options — { verbose, quiet }
|
|
130
|
+
* @param {object} options — { verbose, quiet, trustPlugins }
|
|
131
131
|
*/
|
|
132
132
|
export async function buildOrchestratorAsync(rootPath, options = {}) {
|
|
133
133
|
const orchestrator = new OrchestratorClass();
|
|
@@ -23,7 +23,14 @@ export const PATTERNS = [
|
|
|
23
23
|
{
|
|
24
24
|
rule: 'SQL_INJECTION_TEMPLATE_LITERAL',
|
|
25
25
|
title: 'SQL Injection via Template Literal',
|
|
26
|
-
|
|
26
|
+
// Each keyword is anchored to the token SQL requires after it. The previous
|
|
27
|
+
// `(?:SELECT|INSERT|...|CREATE|REPLACE|MERGE)[^`]*\$\{` matched the bare
|
|
28
|
+
// keywords, so English words that merely start with one — created, updated,
|
|
29
|
+
// deleted, inserted, replaced — fired a critical on any template literal like
|
|
30
|
+
// `` `created file changed since fix: ${file.path}` ``. Anchoring also lets a
|
|
31
|
+
// word that IS a keyword stand in for SQL only with its real continuation
|
|
32
|
+
// (`MERGE INTO`, `SELECT ... FROM`), not `merge conflict`.
|
|
33
|
+
regex: /`(?:SELECT\s+[\w*`,\s]{1,80}?\b(?:FROM|INTO|SET|VALUES|WHERE)\b|INSERT\s+INTO|UPDATE\s+[\w*`,\s]{1,80}?\bSET\b|DELETE\s+FROM|(?:DROP|ALTER)\s+TABLE|TRUNCATE\s+(?:TABLE\b|(?=[A-Za-z_]*\$\{))|CREATE\s+(?:TABLE|INDEX|UNIQUE|OR\s+REPLACE)|REPLACE\s+INTO|MERGE\s+INTO)[^`]*\$\{/gi,
|
|
27
34
|
severity: 'critical',
|
|
28
35
|
cwe: 'CWE-89',
|
|
29
36
|
owasp: 'A03:2021',
|
|
@@ -65,7 +65,13 @@ export const PATTERNS = [
|
|
|
65
65
|
{
|
|
66
66
|
rule: 'MCP_STDIO_NO_SANDBOX',
|
|
67
67
|
title: 'MCP: stdio Transport Without Sandbox',
|
|
68
|
-
|
|
68
|
+
// Anchored to actual MCP context: the transport class itself, or a transport/type field
|
|
69
|
+
// whose value is stdio. An earlier version matched the bare substring instead, so every
|
|
70
|
+
// `stdio: 'pipe'` in a child_process call and every comment merely mentioning stdio was
|
|
71
|
+
// reported — 49 of Praxis's own 50 hits for this rule were that mistake, and it was the
|
|
72
|
+
// single largest source of noise in a self-scan. Keep this comment free of the class name
|
|
73
|
+
// or it will match itself.
|
|
74
|
+
regex: /StdioServerTransport|(?:transport|type)["']?\s*[:=]\s*["']?stdio\b/g, // praxis-ignore MCP_STDIO_NO_SANDBOX - this line necessarily names the pattern
|
|
69
75
|
severity: 'medium',
|
|
70
76
|
cwe: 'CWE-269',
|
|
71
77
|
owasp: 'A04:2021',
|
|
@@ -63,12 +63,17 @@ export class Orchestrator {
|
|
|
63
63
|
* Run a single agent with a timeout.
|
|
64
64
|
*/
|
|
65
65
|
async runAgent(agent, context, timeout) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
let timer;
|
|
67
|
+
try {
|
|
68
|
+
return await Promise.race([
|
|
69
|
+
Promise.resolve().then(() => agent.analyze(context)),
|
|
70
|
+
new Promise((_, reject) => {
|
|
71
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${timeout / 1000}s`)), timeout);
|
|
72
|
+
}),
|
|
73
|
+
]);
|
|
74
|
+
} finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
/**
|