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.
- 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 +3 -3
- package/cli/agents/index.js +2 -2
- package/cli/agents/injection-tester.js +8 -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 -228
- 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 +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,7 +17,7 @@
|
|
|
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
23
|
// =============================================================================
|
|
@@ -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
|
// =============================================================================
|
|
@@ -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',
|
|
@@ -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
|
/**
|
|
@@ -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'],
|