praxis-sec 1.2.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.
Files changed (49) hide show
  1. package/cli/agents/abom-generator.js +1 -1
  2. package/cli/agents/agent-attestation-agent.js +10 -1
  3. package/cli/agents/agent-config-scanner.js +1 -1
  4. package/cli/agents/ai-infra-inventory-agent.js +482 -482
  5. package/cli/agents/base-agent.js +1 -1
  6. package/cli/agents/endpoint-agent-abuse-agent.js +1 -1
  7. package/cli/agents/html-reporter.js +3 -3
  8. package/cli/agents/index.js +2 -2
  9. package/cli/agents/injection-tester.js +8 -1
  10. package/cli/agents/memory-poisoning-agent.js +1 -1
  11. package/cli/agents/model-file-scanner.js +1 -1
  12. package/cli/agents/orchestrator.js +11 -6
  13. package/cli/agents/prompt-injection-prober.js +228 -228
  14. package/cli/bin/praxis.js +7 -3
  15. package/cli/commands/agent-fix.js +1091 -1245
  16. package/cli/commands/audit.js +1228 -1216
  17. package/cli/commands/baseline.js +1 -1
  18. package/cli/commands/benchmark.js +1 -1
  19. package/cli/commands/ci.js +45 -21
  20. package/cli/commands/deps.js +11 -5
  21. package/cli/commands/env-audit.js +1 -1
  22. package/cli/commands/fix.js +1 -1
  23. package/cli/commands/mcp.js +1 -1
  24. package/cli/commands/red-team.js +350 -350
  25. package/cli/commands/remediate.js +1 -1
  26. package/cli/commands/rotate.js +1 -1
  27. package/cli/commands/rules.js +1 -1
  28. package/cli/commands/scan.js +554 -554
  29. package/cli/commands/score.js +1 -1
  30. package/cli/commands/undo.js +22 -77
  31. package/cli/commands/vibe-check.js +1 -1
  32. package/cli/core/fix-plan.js +274 -0
  33. package/cli/core/fs.js +27 -0
  34. package/cli/core/git-clone.js +8 -6
  35. package/cli/core/glob.js +56 -0
  36. package/cli/core/output/html-theme.js +158 -158
  37. package/cli/core/output/sarif.js +2 -2
  38. package/cli/core/web/jobs.js +2 -2
  39. package/cli/core/web/server.js +19 -8
  40. package/cli/data/threatpacks/latest.json +41 -41
  41. package/cli/integrations/github-action.js +136 -0
  42. package/cli/utils/plugin-loader.js +15 -95
  43. package/cli/utils/rule-import.js +227 -227
  44. package/cli/utils/rule-registry.js +425 -425
  45. package/cli/utils/scan-fingerprint.js +1 -1
  46. package/cli/utils/score-history.js +118 -118
  47. package/docs/USAGE.md +16 -9
  48. package/docs/design/WEB-UI.md +4 -5
  49. package/package.json +13 -4
@@ -17,7 +17,7 @@
17
17
 
18
18
  import fs from 'fs';
19
19
  import path from 'path';
20
- import fg from 'fast-glob';
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
23
  // =============================================================================
@@ -27,7 +27,7 @@
27
27
 
28
28
  import fs from 'fs';
29
29
  import path from 'path';
30
- import fg from 'fast-glob';
30
+ import fg from '../core/glob.js';
31
31
  import { BaseAgent, createFinding } from './base-agent.js';
32
32
 
33
33
  // =============================================================================
@@ -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 (P-IMP-053). A surprising result should
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 (P-IMP-055). An empty graph reads as
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 (P-IMP-054).
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
@@ -123,11 +123,11 @@ export function buildOrchestrator() {
123
123
  }
124
124
 
125
125
  /**
126
- * Async build — loads built-in agents + any plugins from .praxis/agents/.
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
- regex: /`(?:SELECT|INSERT|UPDATE|DELETE|DROP\s+TABLE|ALTER\s+TABLE|TRUNCATE|CREATE|REPLACE|MERGE)[^`]*\$\{/gi,
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',
@@ -30,7 +30,7 @@
30
30
  */
31
31
 
32
32
  import path from 'path';
33
- import fg from 'fast-glob';
33
+ import fg from '../core/glob.js';
34
34
  import { BaseAgent, createFinding } from './base-agent.js';
35
35
 
36
36
  // =============================================================================
@@ -22,7 +22,7 @@
22
22
 
23
23
  import fs from 'fs';
24
24
  import path from 'path';
25
- import fg from 'fast-glob';
25
+ import fg from '../core/glob.js';
26
26
  import { BaseAgent, createFinding } from './base-agent.js';
27
27
 
28
28
  const MODEL_GLOBS = [
@@ -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
- return Promise.race([
67
- agent.analyze(context),
68
- new Promise((_, reject) => {
69
- setTimeout(() => reject(new Error(`timed out after ${timeout / 1000}s`)), timeout);
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
  /**
@@ -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 (P-IMP-064).
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'],