praxis-sec 1.2.0 → 1.2.2
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/assets/praxis-architecture.svg +304 -0
- package/assets/praxis-logo.svg +38 -0
- package/cli/agents/abom-generator.js +1 -1
- package/cli/agents/agent-attestation-agent.js +10 -1
- package/cli/agents/agent-config-scanner.js +1 -1
- package/cli/agents/ai-infra-inventory-agent.js +482 -482
- package/cli/agents/base-agent.js +1 -1
- package/cli/agents/endpoint-agent-abuse-agent.js +1 -1
- package/cli/agents/html-reporter.js +10 -10
- package/cli/agents/index.js +2 -2
- package/cli/agents/injection-tester.js +8 -1
- package/cli/agents/mcp-security-agent.js +600 -594
- package/cli/agents/memory-poisoning-agent.js +1 -1
- package/cli/agents/model-file-scanner.js +1 -1
- package/cli/agents/orchestrator.js +375 -355
- package/cli/agents/prompt-injection-prober.js +228 -228
- package/cli/bin/praxis.js +7 -3
- package/cli/commands/agent-fix.js +1091 -1245
- package/cli/commands/audit.js +1232 -1216
- package/cli/commands/baseline.js +3 -2
- package/cli/commands/benchmark.js +1 -1
- package/cli/commands/ci.js +50 -25
- package/cli/commands/deps.js +11 -5
- package/cli/commands/diff.js +2 -1
- package/cli/commands/env-audit.js +1 -1
- package/cli/commands/fix.js +1 -1
- package/cli/commands/legal.js +2 -1
- package/cli/commands/mcp.js +3 -2
- package/cli/commands/openclaw.js +3 -6
- package/cli/commands/red-team.js +351 -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-standard.js +3 -6
- 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 +3 -2
- package/cli/commands/watch.js +6 -5
- 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/json.js +56 -48
- package/cli/core/output/sarif.js +2 -2
- package/cli/core/paths.js +91 -0
- 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/cache-manager.js +2 -1
- 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 +81 -71
|
@@ -1,228 +1,228 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Prompt Injection Prober
|
|
3
|
-
* ========================
|
|
4
|
-
*
|
|
5
|
-
* Loads a probe corpus from cli/data/probes/prompt-injection-corpus.json and
|
|
6
|
-
* scans source code for static-detectable signals that match each probe.
|
|
7
|
-
*
|
|
8
|
-
* The corpus replaces hardcoded patterns: new probes are added by editing
|
|
9
|
-
* the JSON file. Each probe carries `tags` (e.g. ['LLM01', 'AML.T0051'])
|
|
10
|
-
* which feed into the standards registry automatically — once the agent
|
|
11
|
-
* tags a finding with the probe IDs, the per-finding `standards` field is
|
|
12
|
-
* populated by the ScoringEngine.
|
|
13
|
-
*
|
|
14
|
-
* Maps to: OWASP LLM01/05/06/07/08, MITRE ATLAS T0043/T0051/T0053/T0054/T0057/T0070.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import fs from 'fs';
|
|
18
|
-
import path from 'path';
|
|
19
|
-
import os from 'os';
|
|
20
|
-
import { fileURLToPath } from 'url';
|
|
21
|
-
import { BaseAgent, createFinding, ruleTableLineMask } from './base-agent.js';
|
|
22
|
-
|
|
23
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
-
const __dirname = path.dirname(__filename);
|
|
25
|
-
const CORPUS_PATH = path.resolve(__dirname, '..', 'data', 'probes', 'prompt-injection-corpus.json');
|
|
26
|
-
|
|
27
|
-
const SCAN_EXTS = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.py', '.rb', '.go', '.java', '.rs', '.php']);
|
|
28
|
-
|
|
29
|
-
let _cachedCorpus = null;
|
|
30
|
-
|
|
31
|
-
/** Reads a JSON file, returning null rather than throwing. */
|
|
32
|
-
function readJson(file) {
|
|
33
|
-
try {
|
|
34
|
-
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
35
|
-
} catch {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const THREATPACK_SEED = path.join(path.dirname(CORPUS_PATH), '..', 'threatpacks', 'latest.json');
|
|
41
|
-
|
|
42
|
-
function loadCorpus() {
|
|
43
|
-
if (_cachedCorpus) return _cachedCorpus;
|
|
44
|
-
try {
|
|
45
|
-
const data = readJson(CORPUS_PATH) || {};
|
|
46
|
-
const categoryById = {};
|
|
47
|
-
for (const c of data.categories || []) categoryById[c.id] = c;
|
|
48
|
-
|
|
49
|
-
const decorate = (p) => {
|
|
50
|
-
const cat = categoryById[p.category] || {};
|
|
51
|
-
let regex;
|
|
52
|
-
try {
|
|
53
|
-
regex = compileProbeRegex(p.regex);
|
|
54
|
-
} catch {
|
|
55
|
-
regex = null;
|
|
56
|
-
}
|
|
57
|
-
return {
|
|
58
|
-
...p,
|
|
59
|
-
patternSource: p.regex,
|
|
60
|
-
regex,
|
|
61
|
-
categoryTitle: cat.title || p.category,
|
|
62
|
-
tags: cat.tags || [],
|
|
63
|
-
};
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
// 1. Bundled prompt-injection corpus.
|
|
67
|
-
const probes = (data.probes || []).map(decorate);
|
|
68
|
-
|
|
69
|
-
// 2. Bundled threat-pack seed — the detection floor for this release.
|
|
70
|
-
//
|
|
71
|
-
// The seed used to be consulted only via `praxis intel update`. Loading it here
|
|
72
|
-
// means the shipped attack-vector families work on a first run with no network,
|
|
73
|
-
// and it makes the release's own signatures authoritative
|
|
74
|
-
const seed = readJson(THREATPACK_SEED);
|
|
75
|
-
const seedVersion = seed?.version || null;
|
|
76
|
-
for (const p of seed?.probes || []) probes.push(decorate(p));
|
|
77
|
-
|
|
78
|
-
// 3. Overlay the fetched intel feed, which may bring newer signatures.
|
|
79
|
-
//
|
|
80
|
-
// Version-gated per probe: a feed older than the bundled seed must not replace
|
|
81
|
-
// a probe the release already ships. A stale `threat-intel.json` holding
|
|
82
|
-
// threatpack 1.0.0 was reinstating the pre-fix TP-003 pattern
|
|
83
|
-
// (`when\s+combined\s+with`, no referent, no instruction noun) and reporting
|
|
84
|
-
// ordinary English as prompt injection. A feed newer than, or equal to, the
|
|
85
|
-
// seed still wins, so updates keep working.
|
|
86
|
-
let feedApplied = 0;
|
|
87
|
-
let feedRejected = 0;
|
|
88
|
-
const feed = readJson(path.join(os.homedir(), '.praxis', 'threat-intel.json'));
|
|
89
|
-
const pack = feed?.threatPack;
|
|
90
|
-
const feedVersion = pack?.version || null;
|
|
91
|
-
const feedIsOlder = seedVersion && feedVersion
|
|
92
|
-
&& compareVersions(feedVersion, seedVersion) < 0;
|
|
93
|
-
|
|
94
|
-
for (const p of pack?.probes || []) {
|
|
95
|
-
if (feedIsOlder) { feedRejected++; continue; }
|
|
96
|
-
const decorated = decorate(p);
|
|
97
|
-
const existing = probes.findIndex(x => x.id === p.id);
|
|
98
|
-
if (existing >= 0) probes[existing] = decorated;
|
|
99
|
-
else probes.push(decorated);
|
|
100
|
-
feedApplied++;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
_cachedCorpus = {
|
|
104
|
-
version: data.version,
|
|
105
|
-
probes,
|
|
106
|
-
seedThreatPackVersion: seedVersion,
|
|
107
|
-
feedThreatPackVersion: feedVersion,
|
|
108
|
-
feedApplied,
|
|
109
|
-
feedRejected,
|
|
110
|
-
};
|
|
111
|
-
return _cachedCorpus;
|
|
112
|
-
} catch {
|
|
113
|
-
_cachedCorpus = { version: '0', probes: [], feedApplied: 0, feedRejected: 0 };
|
|
114
|
-
return _cachedCorpus;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Compares dotted version strings numerically. Returns <0, 0 or >0.
|
|
120
|
-
* Missing/invalid segments compare as 0, so `1.0` and `1.0.0` are equal.
|
|
121
|
-
*/
|
|
122
|
-
function compareVersions(a, b) {
|
|
123
|
-
const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
|
|
124
|
-
const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
|
|
125
|
-
const len = Math.max(pa.length, pb.length);
|
|
126
|
-
for (let i = 0; i < len; i++) {
|
|
127
|
-
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
128
|
-
if (d !== 0) return d;
|
|
129
|
-
}
|
|
130
|
-
return 0;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function compileProbeRegex(pattern) {
|
|
134
|
-
let flags = 'g';
|
|
135
|
-
let body = pattern;
|
|
136
|
-
const m = body.match(/^\(\?([imsux]+)\)/);
|
|
137
|
-
if (m) {
|
|
138
|
-
for (const f of m[1]) if ('imsu'.includes(f) && !flags.includes(f)) flags += f;
|
|
139
|
-
body = body.slice(m[0].length);
|
|
140
|
-
}
|
|
141
|
-
// Scanner hardening: reject nested-quantifier constructs that risk
|
|
142
|
-
// catastrophic backtracking on adversarial input.
|
|
143
|
-
if (NESTED_QUANTIFIER.test(body)) {
|
|
144
|
-
throw new Error('ReDoS-unsafe probe regex (nested quantifiers)');
|
|
145
|
-
}
|
|
146
|
-
return new RegExp(body, flags);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*[+*]\)[+*{]/;
|
|
150
|
-
|
|
151
|
-
export class PromptInjectionProber extends BaseAgent {
|
|
152
|
-
constructor() {
|
|
153
|
-
super(
|
|
154
|
-
'PromptInjectionProber',
|
|
155
|
-
'Probe-corpus-driven scan for prompt-injection patterns (OWASP LLM01, ATLAS T0043/T0051)',
|
|
156
|
-
'llm'
|
|
157
|
-
);
|
|
158
|
-
this._corpus = loadCorpus();
|
|
159
|
-
this.probeCount = this._corpus.probes.length;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
shouldRun(recon) {
|
|
163
|
-
if (!recon) return true;
|
|
164
|
-
const langs = recon.languages instanceof Set ? [...recon.languages] : (recon.languages || []);
|
|
165
|
-
if (langs.some(l => ['javascript', 'typescript', 'python', 'ruby', 'go', 'java', 'rust', 'php'].includes(l))) {
|
|
166
|
-
return true;
|
|
167
|
-
}
|
|
168
|
-
return Boolean(recon.frameworks?.length || recon.apiRoutes?.length);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
async analyze(context) {
|
|
172
|
-
const findings = [];
|
|
173
|
-
const probes = this._corpus.probes.filter(p => p.regex);
|
|
174
|
-
if (probes.length === 0) return findings;
|
|
175
|
-
|
|
176
|
-
const files = this.getFilesToScan(context).filter(f => SCAN_EXTS.has(path.extname(f).toLowerCase()));
|
|
177
|
-
|
|
178
|
-
for (const file of files) {
|
|
179
|
-
const content = this.readFile(file);
|
|
180
|
-
if (!content) continue;
|
|
181
|
-
const lines = content.split('\n');
|
|
182
|
-
// A probe payload quoted in a rule table's own prose is the table
|
|
183
|
-
// documenting the probe, not an injection.
|
|
184
|
-
const ruleTable = ruleTableLineMask(lines);
|
|
185
|
-
|
|
186
|
-
for (const probe of probes) {
|
|
187
|
-
probe.regex.lastIndex = 0;
|
|
188
|
-
let match;
|
|
189
|
-
while ((match = probe.regex.exec(content)) !== null) {
|
|
190
|
-
const idx = match.index;
|
|
191
|
-
const before = content.slice(0, idx);
|
|
192
|
-
const lineNum = before.split('\n').length;
|
|
193
|
-
const lastNl = before.lastIndexOf('\n');
|
|
194
|
-
const column = lastNl === -1 ? idx + 1 : idx - lastNl;
|
|
195
|
-
const lineText = lines[lineNum - 1] || '';
|
|
196
|
-
if (this.isSuppressed(lineText)) continue;
|
|
197
|
-
if (ruleTable && ruleTable.has(lineNum - 1)) continue;
|
|
198
|
-
|
|
199
|
-
const finding = createFinding({
|
|
200
|
-
file,
|
|
201
|
-
line: lineNum,
|
|
202
|
-
column,
|
|
203
|
-
severity: probe.severity || 'medium',
|
|
204
|
-
category: 'llm',
|
|
205
|
-
rule: `PROBE_${probe.id}`,
|
|
206
|
-
title: probe.title,
|
|
207
|
-
description: probe.description,
|
|
208
|
-
matched: match[0].slice(0, 160),
|
|
209
|
-
confidence: 'medium',
|
|
210
|
-
cwe: 'CWE-77',
|
|
211
|
-
owasp: 'ASI01',
|
|
212
|
-
fix: probe.fix || 'Sanitize untrusted input before LLM prompt construction.',
|
|
213
|
-
});
|
|
214
|
-
finding.probe = { id: probe.id, category: probe.category, tags: probe.tags };
|
|
215
|
-
findings.push(finding);
|
|
216
|
-
|
|
217
|
-
if (!probe.regex.global) break;
|
|
218
|
-
if (match.index === probe.regex.lastIndex) probe.regex.lastIndex++;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
return findings;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
export const _internals = { loadCorpus, compileProbeRegex, CORPUS_PATH };
|
|
228
|
-
export default PromptInjectionProber;
|
|
1
|
+
/**
|
|
2
|
+
* Prompt Injection Prober
|
|
3
|
+
* ========================
|
|
4
|
+
*
|
|
5
|
+
* Loads a probe corpus from cli/data/probes/prompt-injection-corpus.json and
|
|
6
|
+
* scans source code for static-detectable signals that match each probe.
|
|
7
|
+
*
|
|
8
|
+
* The corpus replaces hardcoded patterns: new probes are added by editing
|
|
9
|
+
* the JSON file. Each probe carries `tags` (e.g. ['LLM01', 'AML.T0051'])
|
|
10
|
+
* which feed into the standards registry automatically — once the agent
|
|
11
|
+
* tags a finding with the probe IDs, the per-finding `standards` field is
|
|
12
|
+
* populated by the ScoringEngine.
|
|
13
|
+
*
|
|
14
|
+
* Maps to: OWASP LLM01/05/06/07/08, MITRE ATLAS T0043/T0051/T0053/T0054/T0057/T0070.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import os from 'os';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
import { BaseAgent, createFinding, ruleTableLineMask } from './base-agent.js';
|
|
22
|
+
|
|
23
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
+
const __dirname = path.dirname(__filename);
|
|
25
|
+
const CORPUS_PATH = path.resolve(__dirname, '..', 'data', 'probes', 'prompt-injection-corpus.json');
|
|
26
|
+
|
|
27
|
+
const SCAN_EXTS = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.py', '.rb', '.go', '.java', '.rs', '.php']);
|
|
28
|
+
|
|
29
|
+
let _cachedCorpus = null;
|
|
30
|
+
|
|
31
|
+
/** Reads a JSON file, returning null rather than throwing. */
|
|
32
|
+
function readJson(file) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const THREATPACK_SEED = path.join(path.dirname(CORPUS_PATH), '..', 'threatpacks', 'latest.json');
|
|
41
|
+
|
|
42
|
+
function loadCorpus() {
|
|
43
|
+
if (_cachedCorpus) return _cachedCorpus;
|
|
44
|
+
try {
|
|
45
|
+
const data = readJson(CORPUS_PATH) || {};
|
|
46
|
+
const categoryById = {};
|
|
47
|
+
for (const c of data.categories || []) categoryById[c.id] = c;
|
|
48
|
+
|
|
49
|
+
const decorate = (p) => {
|
|
50
|
+
const cat = categoryById[p.category] || {};
|
|
51
|
+
let regex;
|
|
52
|
+
try {
|
|
53
|
+
regex = compileProbeRegex(p.regex);
|
|
54
|
+
} catch {
|
|
55
|
+
regex = null;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
...p,
|
|
59
|
+
patternSource: p.regex,
|
|
60
|
+
regex,
|
|
61
|
+
categoryTitle: cat.title || p.category,
|
|
62
|
+
tags: cat.tags || [],
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// 1. Bundled prompt-injection corpus.
|
|
67
|
+
const probes = (data.probes || []).map(decorate);
|
|
68
|
+
|
|
69
|
+
// 2. Bundled threat-pack seed — the detection floor for this release.
|
|
70
|
+
//
|
|
71
|
+
// The seed used to be consulted only via `praxis intel update`. Loading it here
|
|
72
|
+
// means the shipped attack-vector families work on a first run with no network,
|
|
73
|
+
// and it makes the release's own signatures authoritative.
|
|
74
|
+
const seed = readJson(THREATPACK_SEED);
|
|
75
|
+
const seedVersion = seed?.version || null;
|
|
76
|
+
for (const p of seed?.probes || []) probes.push(decorate(p));
|
|
77
|
+
|
|
78
|
+
// 3. Overlay the fetched intel feed, which may bring newer signatures.
|
|
79
|
+
//
|
|
80
|
+
// Version-gated per probe: a feed older than the bundled seed must not replace
|
|
81
|
+
// a probe the release already ships. A stale `threat-intel.json` holding
|
|
82
|
+
// threatpack 1.0.0 was reinstating the pre-fix TP-003 pattern
|
|
83
|
+
// (`when\s+combined\s+with`, no referent, no instruction noun) and reporting
|
|
84
|
+
// ordinary English as prompt injection. A feed newer than, or equal to, the
|
|
85
|
+
// seed still wins, so updates keep working.
|
|
86
|
+
let feedApplied = 0;
|
|
87
|
+
let feedRejected = 0;
|
|
88
|
+
const feed = readJson(path.join(os.homedir(), '.praxis', 'threat-intel.json'));
|
|
89
|
+
const pack = feed?.threatPack;
|
|
90
|
+
const feedVersion = pack?.version || null;
|
|
91
|
+
const feedIsOlder = seedVersion && feedVersion
|
|
92
|
+
&& compareVersions(feedVersion, seedVersion) < 0;
|
|
93
|
+
|
|
94
|
+
for (const p of pack?.probes || []) {
|
|
95
|
+
if (feedIsOlder) { feedRejected++; continue; }
|
|
96
|
+
const decorated = decorate(p);
|
|
97
|
+
const existing = probes.findIndex(x => x.id === p.id);
|
|
98
|
+
if (existing >= 0) probes[existing] = decorated;
|
|
99
|
+
else probes.push(decorated);
|
|
100
|
+
feedApplied++;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_cachedCorpus = {
|
|
104
|
+
version: data.version,
|
|
105
|
+
probes,
|
|
106
|
+
seedThreatPackVersion: seedVersion,
|
|
107
|
+
feedThreatPackVersion: feedVersion,
|
|
108
|
+
feedApplied,
|
|
109
|
+
feedRejected,
|
|
110
|
+
};
|
|
111
|
+
return _cachedCorpus;
|
|
112
|
+
} catch {
|
|
113
|
+
_cachedCorpus = { version: '0', probes: [], feedApplied: 0, feedRejected: 0 };
|
|
114
|
+
return _cachedCorpus;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Compares dotted version strings numerically. Returns <0, 0 or >0.
|
|
120
|
+
* Missing/invalid segments compare as 0, so `1.0` and `1.0.0` are equal.
|
|
121
|
+
*/
|
|
122
|
+
function compareVersions(a, b) {
|
|
123
|
+
const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
|
|
124
|
+
const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
|
|
125
|
+
const len = Math.max(pa.length, pb.length);
|
|
126
|
+
for (let i = 0; i < len; i++) {
|
|
127
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
128
|
+
if (d !== 0) return d;
|
|
129
|
+
}
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function compileProbeRegex(pattern) {
|
|
134
|
+
let flags = 'g';
|
|
135
|
+
let body = pattern;
|
|
136
|
+
const m = body.match(/^\(\?([imsux]+)\)/);
|
|
137
|
+
if (m) {
|
|
138
|
+
for (const f of m[1]) if ('imsu'.includes(f) && !flags.includes(f)) flags += f;
|
|
139
|
+
body = body.slice(m[0].length);
|
|
140
|
+
}
|
|
141
|
+
// Scanner hardening: reject nested-quantifier constructs that risk
|
|
142
|
+
// catastrophic backtracking on adversarial input.
|
|
143
|
+
if (NESTED_QUANTIFIER.test(body)) {
|
|
144
|
+
throw new Error('ReDoS-unsafe probe regex (nested quantifiers)');
|
|
145
|
+
}
|
|
146
|
+
return new RegExp(body, flags);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*[+*]\)[+*{]/;
|
|
150
|
+
|
|
151
|
+
export class PromptInjectionProber extends BaseAgent {
|
|
152
|
+
constructor() {
|
|
153
|
+
super(
|
|
154
|
+
'PromptInjectionProber',
|
|
155
|
+
'Probe-corpus-driven scan for prompt-injection patterns (OWASP LLM01, ATLAS T0043/T0051)',
|
|
156
|
+
'llm'
|
|
157
|
+
);
|
|
158
|
+
this._corpus = loadCorpus();
|
|
159
|
+
this.probeCount = this._corpus.probes.length;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
shouldRun(recon) {
|
|
163
|
+
if (!recon) return true;
|
|
164
|
+
const langs = recon.languages instanceof Set ? [...recon.languages] : (recon.languages || []);
|
|
165
|
+
if (langs.some(l => ['javascript', 'typescript', 'python', 'ruby', 'go', 'java', 'rust', 'php'].includes(l))) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
return Boolean(recon.frameworks?.length || recon.apiRoutes?.length);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async analyze(context) {
|
|
172
|
+
const findings = [];
|
|
173
|
+
const probes = this._corpus.probes.filter(p => p.regex);
|
|
174
|
+
if (probes.length === 0) return findings;
|
|
175
|
+
|
|
176
|
+
const files = this.getFilesToScan(context).filter(f => SCAN_EXTS.has(path.extname(f).toLowerCase()));
|
|
177
|
+
|
|
178
|
+
for (const file of files) {
|
|
179
|
+
const content = this.readFile(file);
|
|
180
|
+
if (!content) continue;
|
|
181
|
+
const lines = content.split('\n');
|
|
182
|
+
// A probe payload quoted in a rule table's own prose is the table
|
|
183
|
+
// documenting the probe, not an injection.
|
|
184
|
+
const ruleTable = ruleTableLineMask(lines);
|
|
185
|
+
|
|
186
|
+
for (const probe of probes) {
|
|
187
|
+
probe.regex.lastIndex = 0;
|
|
188
|
+
let match;
|
|
189
|
+
while ((match = probe.regex.exec(content)) !== null) {
|
|
190
|
+
const idx = match.index;
|
|
191
|
+
const before = content.slice(0, idx);
|
|
192
|
+
const lineNum = before.split('\n').length;
|
|
193
|
+
const lastNl = before.lastIndexOf('\n');
|
|
194
|
+
const column = lastNl === -1 ? idx + 1 : idx - lastNl;
|
|
195
|
+
const lineText = lines[lineNum - 1] || '';
|
|
196
|
+
if (this.isSuppressed(lineText)) continue;
|
|
197
|
+
if (ruleTable && ruleTable.has(lineNum - 1)) continue;
|
|
198
|
+
|
|
199
|
+
const finding = createFinding({
|
|
200
|
+
file,
|
|
201
|
+
line: lineNum,
|
|
202
|
+
column,
|
|
203
|
+
severity: probe.severity || 'medium',
|
|
204
|
+
category: 'llm',
|
|
205
|
+
rule: `PROBE_${probe.id}`,
|
|
206
|
+
title: probe.title,
|
|
207
|
+
description: probe.description,
|
|
208
|
+
matched: match[0].slice(0, 160),
|
|
209
|
+
confidence: 'medium',
|
|
210
|
+
cwe: 'CWE-77',
|
|
211
|
+
owasp: 'ASI01',
|
|
212
|
+
fix: probe.fix || 'Sanitize untrusted input before LLM prompt construction.',
|
|
213
|
+
});
|
|
214
|
+
finding.probe = { id: probe.id, category: probe.category, tags: probe.tags };
|
|
215
|
+
findings.push(finding);
|
|
216
|
+
|
|
217
|
+
if (!probe.regex.global) break;
|
|
218
|
+
if (match.index === probe.regex.lastIndex) probe.regex.lastIndex++;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return findings;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const _internals = { loadCorpus, compileProbeRegex, CORPUS_PATH };
|
|
228
|
+
export default PromptInjectionProber;
|
package/cli/bin/praxis.js
CHANGED
|
@@ -123,7 +123,8 @@ const scan = program
|
|
|
123
123
|
|
|
124
124
|
scan
|
|
125
125
|
.command('full [path]', { isDefault: true })
|
|
126
|
-
.description('Full audit: secrets + 28 agents + deps + score + remediation plan')
|
|
126
|
+
.description('Full audit: secrets + 28 agents + deps + score + remediation plan')
|
|
127
|
+
.option('--trust-plugins', 'Execute trusted local .praxis/agents plugins with your permissions')
|
|
127
128
|
.option('--json', 'Output results as JSON')
|
|
128
129
|
.option('--sarif', 'Output results in SARIF format')
|
|
129
130
|
.option('--csv', 'Output results as CSV')
|
|
@@ -261,7 +262,8 @@ scan
|
|
|
261
262
|
.option('--threshold <score>', 'Minimum passing score (default: 75)', parseInt)
|
|
262
263
|
.option('--fail-on <severity>', 'Fail on findings at this severity or above')
|
|
263
264
|
.option('--always-fail-on <severity>', 'Severity floor that even an accepted baseline cannot suppress')
|
|
264
|
-
.option('--include-findings', 'Include the finding list (file/rule/severity) in JSON output for diffing')
|
|
265
|
+
.option('--include-findings', 'Include the finding list (file/rule/severity) in JSON output for diffing')
|
|
266
|
+
.option('--deep', 'Enable LLM deep analysis (requires an API key)')
|
|
265
267
|
.option('--sarif <file>', 'Write SARIF output for GitHub Code Scanning')
|
|
266
268
|
.option('--json', 'JSON output')
|
|
267
269
|
.option('--no-deps', 'Skip dependency audit')
|
|
@@ -685,7 +687,8 @@ legacy('ci [path]', 'CI/CD mode: scan, score, exit 1 on failure (alias of `scan
|
|
|
685
687
|
['--threshold <score>', 'Minimum passing score (default: 75)', parseInt],
|
|
686
688
|
['--fail-on <severity>', 'Fail on findings at this severity or above'],
|
|
687
689
|
['--always-fail-on <severity>', 'Severity floor that even an accepted baseline cannot suppress'],
|
|
688
|
-
['--include-findings', 'Include the finding list (file/rule/severity) in JSON output for diffing'],
|
|
690
|
+
['--include-findings', 'Include the finding list (file/rule/severity) in JSON output for diffing'],
|
|
691
|
+
['--deep', 'Enable LLM deep analysis (requires an API key)'],
|
|
689
692
|
['--sarif <file>', 'Write SARIF output for GitHub Code Scanning'],
|
|
690
693
|
['--json', 'JSON output'],
|
|
691
694
|
['--no-deps', 'Skip dependency audit'],
|
|
@@ -696,6 +699,7 @@ legacy('ci [path]', 'CI/CD mode: scan, score, exit 1 on failure (alias of `scan
|
|
|
696
699
|
]).action(ciCommand);
|
|
697
700
|
|
|
698
701
|
legacy('audit [path]', 'Audit agent configs (CLAUDE.md, .cursorrules, MCP, skills) — alias of `agents audit`', [
|
|
702
|
+
['--trust-plugins', 'Execute trusted local .praxis/agents plugins with your permissions'],
|
|
699
703
|
['--fix', 'Auto-harden agent configurations'],
|
|
700
704
|
['--preflight', 'Exit non-zero on critical findings (for CI)'],
|
|
701
705
|
['--red-team', 'Simulate adversarial attacks against agent configs'],
|