@clear-capabilities/agentic-security-scanner 0.79.0 → 0.80.0
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/dist/178.index.js +1 -1
- package/dist/333.index.js +283 -0
- package/dist/384.index.js +1 -1
- package/dist/637.index.js +1 -1
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +90 -1
- package/dist/agentic-security.mjs +83 -83
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -4
- package/src/.agentic-security/findings.json +104638 -0
- package/src/.agentic-security/last-scan.json +104638 -0
- package/src/.agentic-security/last-scan.json.sig +1 -0
- package/src/.agentic-security/scan-history.json +12562 -0
- package/src/.agentic-security/streak.json +21 -0
- package/src/dataflow/.agentic-security/findings.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
- package/src/dataflow/.agentic-security/scan-history.json +250 -0
- package/src/dataflow/.agentic-security/streak.json +21 -0
- package/src/dataflow/cross-service-taint.js +201 -0
- package/src/dataflow/formal-verify.js +204 -0
- package/src/dataflow/ifds-precise.js +222 -0
- package/src/dataflow/k2-summary-cache.js +153 -0
- package/src/dataflow/lib-taint-summaries.js +198 -0
- package/src/dataflow/privacy-taint.js +205 -0
- package/src/dataflow/smt-feasibility.js +189 -0
- package/src/engine.js +784 -127
- package/src/ir/.agentic-security/findings.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json.sig +1 -0
- package/src/ir/.agentic-security/scan-history.json +193 -0
- package/src/ir/.agentic-security/streak.json +20 -0
- package/src/ir/cpp-preprocessor.js +142 -0
- package/src/ir/csharp-ir.js +604 -0
- package/src/ir/universal-ir.js +403 -0
- package/src/mcp/.agentic-security/findings.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
- package/src/mcp/.agentic-security/scan-history.json +143 -0
- package/src/mcp/.agentic-security/streak.json +20 -0
- package/src/mcp/tools.js +90 -1
- package/src/posture/.agentic-security/findings.json +64004 -0
- package/src/posture/.agentic-security/last-scan.json +64004 -0
- package/src/posture/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/.agentic-security/scan-history.json +7162 -0
- package/src/posture/.agentic-security/streak.json +21 -0
- package/src/posture/api-contract.js +193 -0
- package/src/posture/attack-taxonomy.js +227 -0
- package/src/posture/compliance-policy.js +218 -0
- package/src/posture/composite-risk.js +122 -0
- package/src/posture/csharp-analysis.js +330 -0
- package/src/posture/exploit-bundle.js +210 -0
- package/src/posture/federated-learning.js +172 -0
- package/src/posture/license-attributions.js +94 -0
- package/src/posture/license-graph.js +238 -0
- package/src/posture/pqc-migration-plan.js +158 -0
- package/src/posture/reachability-filter.js +33 -2
- package/src/posture/realtime-cve-monitor.js +214 -0
- package/src/posture/runtime-correlation.js +174 -0
- package/src/posture/sbom-diff.js +171 -0
- package/src/posture/sca-policy.js +235 -0
- package/src/posture/sca-upgrade.js +259 -0
- package/src/posture/threat-model-auto.js +268 -0
- package/src/posture/triage-learning.js +170 -0
- package/src/posture/triage.js +26 -1
- package/src/sast/.agentic-security/findings.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json.sig +1 -0
- package/src/sast/.agentic-security/scan-history.json +941 -0
- package/src/sast/.agentic-security/streak.json +22 -0
- package/src/sast/_secret-entropy.js +145 -0
- package/src/sast/cloud-iam.js +312 -0
- package/src/sast/cpp.js +138 -4
- package/src/sast/crypto-protocol.js +388 -0
- package/src/sast/csharp-tokenizer.js +392 -0
- package/src/sast/csharp.js +924 -138
- package/src/sast/dapp-frontend.js +200 -0
- package/src/sast/k8s-admission.js +271 -0
- package/src/sast/llm-app.js +272 -0
- package/src/sast/ml-supply-chain.js +259 -0
- package/src/sast/mobile.js +224 -0
- package/src/sast/post-quantum-crypto.js +348 -0
- package/src/sast/web3-advanced.js +375 -0
- package/src/sca/.agentic-security/findings.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json.sig +1 -0
- package/src/sca/.agentic-security/scan-history.json +113 -0
- package/src/sca/.agentic-security/streak.json +21 -0
- package/src/sca/CLAUDE.md +161 -0
- package/src/sca/binary-metadata.js +37 -15
- package/src/sca/sigstore-verify.js +215 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// AI / LLM application security — Recommendation #1 of the world-class+2 plan.
|
|
2
|
+
//
|
|
3
|
+
// Coverage for the fastest-growing attack surface in 2026 — applications
|
|
4
|
+
// that wire user input into an LLM call. The existing /llm skill +
|
|
5
|
+
// scanner/src/sast/llm.js + llm-owasp.js are start; this module adds the
|
|
6
|
+
// production-grade detectors:
|
|
7
|
+
//
|
|
8
|
+
// - llm-prompt-injection System prompt + user prompt concatenated
|
|
9
|
+
// without isolation/delimiter
|
|
10
|
+
// - llm-tool-exec Agent tool definitions that expose
|
|
11
|
+
// exec/shell/fetch/subprocess to the LLM
|
|
12
|
+
// - llm-rag-injection Vector-store retrieve → llm.generate
|
|
13
|
+
// without sanitization of retrieved content
|
|
14
|
+
// - llm-model-load-untrusted loading a model file from a user-controlled
|
|
15
|
+
// path (model-poisoning surface)
|
|
16
|
+
// - llm-credential-in-prompt API key / secret embedded in the prompt
|
|
17
|
+
// text (exposed to model + logs)
|
|
18
|
+
// - llm-output-untrusted-sink LLM output directly written to eval/exec/
|
|
19
|
+
// file/HTML without validation
|
|
20
|
+
// - llm-training-data-pii PII fields in training/fine-tuning paths
|
|
21
|
+
//
|
|
22
|
+
// Detection runs over the universal IR + content regex. Findings carry
|
|
23
|
+
// family 'llm-app-security' with finer subfamily strings.
|
|
24
|
+
|
|
25
|
+
import { blankComments } from './_comment-strip.js';
|
|
26
|
+
|
|
27
|
+
const _LLM_CLIENT_PATTERNS = [
|
|
28
|
+
/\bopenai\b/i,
|
|
29
|
+
/\bAnthropic\b/i,
|
|
30
|
+
/\bbedrock\b/i,
|
|
31
|
+
/\bvertex(?:ai)?\b/i,
|
|
32
|
+
/\bAzureOpenAI\b/i,
|
|
33
|
+
/\bllamaIndex\b/i,
|
|
34
|
+
/\blangchain\b/i,
|
|
35
|
+
/\bllama_cpp\b/i,
|
|
36
|
+
/\bollama\b/i,
|
|
37
|
+
/\bmistral(?:ai)?\b/i,
|
|
38
|
+
/\bgroq\b/i,
|
|
39
|
+
/\bcohere\b/i,
|
|
40
|
+
/\bhuggingface\b/i,
|
|
41
|
+
/\btransformers\b/i,
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Heuristic: file looks LLM-relevant when ANY common LLM client / framework
|
|
45
|
+
// appears. We avoid noisy detection on files that have nothing to do with
|
|
46
|
+
// LLMs.
|
|
47
|
+
function _isLlmRelevant(text) {
|
|
48
|
+
return _LLM_CLIENT_PATTERNS.some(re => re.test(text));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
52
|
+
function _snip(raw, line) { return (raw.split('\n')[line - 1] || '').trim().slice(0, 200); }
|
|
53
|
+
|
|
54
|
+
const _findingShape = (raw, line, ruleId, vuln, sub, severity, cwe, remediation) => ({
|
|
55
|
+
id: `${ruleId}:${line}`,
|
|
56
|
+
line, vuln, severity, cwe,
|
|
57
|
+
stride: 'Tampering',
|
|
58
|
+
snippet: _snip(raw, line),
|
|
59
|
+
remediation,
|
|
60
|
+
confidence: 0.8,
|
|
61
|
+
parser: 'LLM-APP',
|
|
62
|
+
family: 'llm-app-security',
|
|
63
|
+
subfamily: sub,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── Individual detectors ───────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function detectPromptInjection(file, raw, code, out, seen) {
|
|
69
|
+
// Pattern: `messages: [{role: 'system', content: ...}, {role: 'user',
|
|
70
|
+
// content: <tainted-or-concatenated>}]` where the user content is built
|
|
71
|
+
// by concatenating a system-shaped prefix with user input — i.e., the
|
|
72
|
+
// developer is mixing trust boundaries inside a single prompt.
|
|
73
|
+
//
|
|
74
|
+
// Detection heuristics in v1:
|
|
75
|
+
// 1. A literal "system" role message immediately followed by a user
|
|
76
|
+
// role whose content concatenates a string with a free variable
|
|
77
|
+
// 2. Direct `system_prompt + user_input` concatenation at the call site
|
|
78
|
+
const re1 = /\brole\s*[:=]\s*["']user["'][^}]{0,400}content\s*[:=]\s*[^,)]*\+\s*\w/g;
|
|
79
|
+
let m;
|
|
80
|
+
while ((m = re1.exec(code))) {
|
|
81
|
+
const line = _lineOf(raw, m.index);
|
|
82
|
+
const id = `llm-prompt-injection-user-concat:${file}:${line}`;
|
|
83
|
+
if (seen.has(id)) continue;
|
|
84
|
+
seen.add(id);
|
|
85
|
+
out.push({
|
|
86
|
+
..._findingShape(raw, line, 'llm-prompt-injection-user-concat',
|
|
87
|
+
'Prompt Injection — user prompt built from string concatenation',
|
|
88
|
+
'prompt-injection', 'high', 'CWE-1427',
|
|
89
|
+
'Separate trust boundaries: keep system prompt as a literal constant; pass user input as a separate message with role "user" and never concatenate. Use the messages array form (e.g. messages.create) and avoid string interpolation. When user content must be embedded inside structured prompts, wrap it in delimiter tokens AND sanitize.'),
|
|
90
|
+
file,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// Pattern: system_prompt + " " + user_input shape
|
|
94
|
+
const re2 = /\b(?:system[_-]?prompt|systemPrompt|SYSTEM_PROMPT)\s*[\+,]\s*(?:["'][\s\S]{0,40}["']\s*[\+,]\s*)?(?:user[_-]?(?:input|prompt|query|message)|userInput|userPrompt|message|input|query)\b/gi;
|
|
95
|
+
while ((m = re2.exec(code))) {
|
|
96
|
+
const line = _lineOf(raw, m.index);
|
|
97
|
+
const id = `llm-prompt-injection-mix:${file}:${line}`;
|
|
98
|
+
if (seen.has(id)) continue;
|
|
99
|
+
seen.add(id);
|
|
100
|
+
out.push({
|
|
101
|
+
..._findingShape(raw, line, 'llm-prompt-injection-mix',
|
|
102
|
+
'Prompt Injection — system prompt concatenated with user input',
|
|
103
|
+
'prompt-injection', 'high', 'CWE-1427',
|
|
104
|
+
'Pass system prompt and user message as separate role-tagged messages: `chat.create({messages: [{role:"system", content:S}, {role:"user", content:U}]})`. Concatenation merges the two trust levels into one string and lets the user override the system prompt.'),
|
|
105
|
+
file,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function detectToolExec(file, raw, code, out, seen) {
|
|
111
|
+
// Pattern: agent / tool definitions that expose dangerous APIs to the LLM:
|
|
112
|
+
// - exec / shell / subprocess / spawn
|
|
113
|
+
// - eval / Function ctor
|
|
114
|
+
// - http / fetch / requests with arbitrary URL
|
|
115
|
+
// - file_read / file_write with arbitrary path
|
|
116
|
+
// The detection looks for tool-array entries whose `name` or `function`
|
|
117
|
+
// field references one of these patterns.
|
|
118
|
+
const re = /\b(?:tools|function_calls|functions)\s*[:=]\s*\[[^\]]{0,2000}(?:exec|shell|spawn|subprocess|eval|Function|child_process|os\.system|run_command|http_request|fetch_url|http_call|file_read|file_write|read_file|write_file)/gi;
|
|
119
|
+
let m;
|
|
120
|
+
while ((m = re.exec(code))) {
|
|
121
|
+
const line = _lineOf(raw, m.index);
|
|
122
|
+
const id = `llm-tool-exec:${file}:${line}`;
|
|
123
|
+
if (seen.has(id)) continue;
|
|
124
|
+
seen.add(id);
|
|
125
|
+
out.push({
|
|
126
|
+
..._findingShape(raw, line, 'llm-tool-exec',
|
|
127
|
+
'Insecure LLM Tool — agent exposes shell/exec/eval/network surface to the model',
|
|
128
|
+
'tool-exec', 'critical', 'CWE-78',
|
|
129
|
+
'Tools given to an LLM execute under the LLM\'s judgment, which is adversary-controllable via prompt injection. Replace bare `exec` / `shell` tools with: (1) a typed allow-list of operations (e.g. `kubectl_get_pods`, not `kubectl`); (2) explicit confirmation for any side-effectful call; (3) network egress allow-list; (4) per-tool capability scoping.'),
|
|
130
|
+
file,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function detectRagInjection(file, raw, code, out, seen) {
|
|
136
|
+
// Pattern: vectorstore.query(...) / vectorstore.similarity_search(...) /
|
|
137
|
+
// retriever.retrieve(...) → embedded into next prompt. We detect when a
|
|
138
|
+
// retrieval result is wired directly into messages or used as content
|
|
139
|
+
// without an intermediate sanitization / instruction-isolation step.
|
|
140
|
+
const re = /\b(?:vectorstore|vector_store|vectorStore|retriever|index)\s*\.\s*(?:query|similarity_search|search|retrieve|invoke)\s*\(/gi;
|
|
141
|
+
let m;
|
|
142
|
+
while ((m = re.exec(code))) {
|
|
143
|
+
const line = _lineOf(raw, m.index);
|
|
144
|
+
// Look in the next ~10 lines for a chat/completion call.
|
|
145
|
+
const windowEnd = code.indexOf('\n', m.index);
|
|
146
|
+
const tail = code.slice(m.index, Math.min(code.length, m.index + 800));
|
|
147
|
+
const consumed = /\b(?:chat\.completions?\.create|completion\.create|messages\.create|invoke|llm\.generate|invoke_model|InvokeModel|generate_content|prompt\s*=|content\s*=)/.test(tail);
|
|
148
|
+
if (!consumed) continue;
|
|
149
|
+
const id = `llm-rag-injection:${file}:${line}`;
|
|
150
|
+
if (seen.has(id)) continue;
|
|
151
|
+
seen.add(id);
|
|
152
|
+
out.push({
|
|
153
|
+
..._findingShape(raw, line, 'llm-rag-injection',
|
|
154
|
+
'RAG Injection — vector-store result flows into LLM prompt without sanitization',
|
|
155
|
+
'rag-injection', 'high', 'CWE-1427',
|
|
156
|
+
'Retrieved content from a vector store is untrusted (any document indexed in your knowledge base is now an attacker-vector). Wrap retrieved chunks in clear delimiter tokens, instruct the system prompt to treat them as data, and apply a known-instruction-keyword filter before prompting. Also: confirm document-ingest pipeline validates that no document contains directives.'),
|
|
157
|
+
file,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function detectModelLoadUntrusted(file, raw, code, out, seen) {
|
|
163
|
+
// model = torch.load(user_supplied_path) / pickle.load / safetensors.load
|
|
164
|
+
// with arbitrary path.
|
|
165
|
+
const re = /\b(?:torch\.load|pickle\.load|joblib\.load|safetensors\.torch\.load_file|tf\.keras\.models\.load_model|transformers\.AutoModel(?:\w+)?\.from_pretrained)\s*\(\s*(?!["'])\w/g;
|
|
166
|
+
let m;
|
|
167
|
+
while ((m = re.exec(code))) {
|
|
168
|
+
const line = _lineOf(raw, m.index);
|
|
169
|
+
const id = `llm-model-load-untrusted:${file}:${line}`;
|
|
170
|
+
if (seen.has(id)) continue;
|
|
171
|
+
seen.add(id);
|
|
172
|
+
out.push({
|
|
173
|
+
..._findingShape(raw, line, 'llm-model-load-untrusted',
|
|
174
|
+
'Untrusted Model Load — model file loaded from a non-literal path',
|
|
175
|
+
'model-load', 'high', 'CWE-502',
|
|
176
|
+
'Model files (PyTorch .pt, pickle, transformers checkpoints) execute arbitrary code on load — pickle especially can carry RCE payloads. Pin the model file path to a constant, verify the file hash against a known-good value before loading, and prefer .safetensors over .pt where possible (safetensors cannot carry code).'),
|
|
177
|
+
file,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function detectCredentialInPrompt(file, raw, code, out, seen) {
|
|
183
|
+
// System prompt or user message containing a string literal that looks
|
|
184
|
+
// like an API key or secret. Catches the common error of embedding a
|
|
185
|
+
// credential into a system prompt for "context."
|
|
186
|
+
const re = /\b(?:system_prompt|systemPrompt|user_prompt|userPrompt|prompt|content|message)\s*[:=]\s*(?:f?["'`])[\s\S]{0,400}?(?:sk-[A-Za-z0-9]{20,}|AKIA[A-Z0-9]{16}|ghp_[A-Za-z0-9]{36}|xox[abprs]-[A-Za-z0-9-]{10,})/g;
|
|
187
|
+
let m;
|
|
188
|
+
while ((m = re.exec(code))) {
|
|
189
|
+
const line = _lineOf(raw, m.index);
|
|
190
|
+
const id = `llm-credential-in-prompt:${file}:${line}`;
|
|
191
|
+
if (seen.has(id)) continue;
|
|
192
|
+
seen.add(id);
|
|
193
|
+
out.push({
|
|
194
|
+
..._findingShape(raw, line, 'llm-credential-in-prompt',
|
|
195
|
+
'Credential in LLM Prompt — API key / secret embedded in prompt text',
|
|
196
|
+
'credential-in-prompt', 'critical', 'CWE-798',
|
|
197
|
+
'Credentials embedded in prompts are sent to the model endpoint AND logged in any LLM-debugging trace. Worse, the model may echo them in its response. Remove the literal; pass the credential via the API client\'s headers / SDK auth; never include it in the prompt.'),
|
|
198
|
+
file,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function detectOutputUntrustedSink(file, raw, code, out, seen) {
|
|
204
|
+
// Pattern: `result = completion.choices[0].message.content; eval(result)`
|
|
205
|
+
// or `... innerHTML = result` or `... = await chat.invoke(...); fs.writeFile(path, result)`.
|
|
206
|
+
// Detection: an LLM call result is captured in a variable, then that
|
|
207
|
+
// variable appears in a sink (eval / exec / write / innerHTML / etc.)
|
|
208
|
+
// within the next ~20 lines.
|
|
209
|
+
const llmCallRe = /(\w+)\s*=\s*(?:await\s+)?(?:openai|anthropic|client|llm|chat|completion|model)[\.\w]*\.\s*(?:complete|completions?\.create|messages\.create|invoke|generate|generate_content|chat|call)\s*\(/gi;
|
|
210
|
+
let m;
|
|
211
|
+
while ((m = llmCallRe.exec(code))) {
|
|
212
|
+
const varName = m[1];
|
|
213
|
+
const startLine = _lineOf(raw, m.index);
|
|
214
|
+
const tail = code.slice(m.index, Math.min(code.length, m.index + 1500));
|
|
215
|
+
const sinkRe = new RegExp(`\\b(?:eval|exec|new\\s+Function|innerHTML\\s*=|outerHTML\\s*=|document\\.write|fs\\.writeFile|writeFile|child_process|os\\.system|subprocess\\.run|Function\\s*\\()[^\\n]{0,200}\\b${varName.replace(/[.+^${}()|\\]/g, '\\$&')}\\b`);
|
|
216
|
+
const sinkMatch = sinkRe.exec(tail);
|
|
217
|
+
if (!sinkMatch) continue;
|
|
218
|
+
const sinkLine = startLine + tail.substring(0, sinkMatch.index).split('\n').length - 1;
|
|
219
|
+
const id = `llm-output-untrusted-sink:${file}:${sinkLine}`;
|
|
220
|
+
if (seen.has(id)) continue;
|
|
221
|
+
seen.add(id);
|
|
222
|
+
out.push({
|
|
223
|
+
..._findingShape(raw, sinkLine, 'llm-output-untrusted-sink',
|
|
224
|
+
`Untrusted LLM Output Sink — value from LLM (var \`${varName}\`) flows into eval/exec/innerHTML/file-write`,
|
|
225
|
+
'output-untrusted-sink', 'critical', 'CWE-94',
|
|
226
|
+
'LLM output is adversary-influenced (via prompt injection). Treat it like network input: never eval/exec/innerHTML it directly. If you need to render it: HTML-encode for DOM, validate against a JSON schema for tool use, and quarantine in a sandbox iframe for arbitrary content.'),
|
|
227
|
+
file,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function detectTrainingDataPii(file, raw, code, out, seen) {
|
|
233
|
+
// Patterns where a fine-tuning / training pipeline reads from a path
|
|
234
|
+
// that suggests PII presence (paths containing user / personal /
|
|
235
|
+
// customers / users / pii).
|
|
236
|
+
const re = /\b(?:openai\.FineTuning|fine_tune|trainer\.train|model\.fit|datasets\.load_dataset)\s*\([^)]*["'][^"']*(?:users?|customer|personal|pii|patient|patient_data|medical|finance|salary)\b/gi;
|
|
237
|
+
let m;
|
|
238
|
+
while ((m = re.exec(code))) {
|
|
239
|
+
const line = _lineOf(raw, m.index);
|
|
240
|
+
const id = `llm-training-data-pii:${file}:${line}`;
|
|
241
|
+
if (seen.has(id)) continue;
|
|
242
|
+
seen.add(id);
|
|
243
|
+
out.push({
|
|
244
|
+
..._findingShape(raw, line, 'llm-training-data-pii',
|
|
245
|
+
'PII in Training Data — fine-tune/training source path suggests personal data',
|
|
246
|
+
'training-data-pii', 'high', 'CWE-359',
|
|
247
|
+
'Fine-tuning embeds the training data into model weights — the data is recoverable via membership-inference attacks. PII in training data triggers GDPR Art. 22 (automated decision-making) AND HIPAA. Sanitize before training: redact PII via dedicated tooling (Presidio, Cape Privacy), or use differential-privacy training (Opacus / TensorFlow Privacy).'),
|
|
248
|
+
file,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Entry point ────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
export function scanLlmApp(fp, raw) {
|
|
256
|
+
if (!raw || raw.length > 500_000) return [];
|
|
257
|
+
if (!_isLlmRelevant(raw)) return [];
|
|
258
|
+
const code = blankComments(raw);
|
|
259
|
+
const out = [];
|
|
260
|
+
const seen = new Set();
|
|
261
|
+
try { detectPromptInjection(fp, raw, code, out, seen); } catch {}
|
|
262
|
+
try { detectToolExec(fp, raw, code, out, seen); } catch {}
|
|
263
|
+
try { detectRagInjection(fp, raw, code, out, seen); } catch {}
|
|
264
|
+
try { detectModelLoadUntrusted(fp, raw, code, out, seen); } catch {}
|
|
265
|
+
try { detectCredentialInPrompt(fp, raw, code, out, seen); } catch {}
|
|
266
|
+
try { detectOutputUntrustedSink(fp, raw, code, out, seen); } catch {}
|
|
267
|
+
try { detectTrainingDataPii(fp, raw, code, out, seen); } catch {}
|
|
268
|
+
for (const f of out) f.file = fp;
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export const _internals = { _LLM_CLIENT_PATTERNS, _isLlmRelevant };
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// ML supply chain analyzer — Item #7 of the world-class+3 plan.
|
|
2
|
+
//
|
|
3
|
+
// Fills the gaps between model-load.js (pickle/torch/yaml core) and
|
|
4
|
+
// llm-app.js / llm-owasp.js (runtime prompt-injection / tool-exec). This
|
|
5
|
+
// module catches:
|
|
6
|
+
//
|
|
7
|
+
// 1. MLFLOW_UNTRUSTED_URI mlflow.pyfunc.load_model from URI
|
|
8
|
+
// not under a verified registry
|
|
9
|
+
// 2. ONNX_NO_PROVIDER_ALLOWLIST onnxruntime.InferenceSession without
|
|
10
|
+
// explicit providers=[...]
|
|
11
|
+
// 3. HF_DATASETS_TRUST_REMOTE_CODE load_dataset(trust_remote_code=True)
|
|
12
|
+
// 4. STREAMING_DATASET_URL webdataset / streaming.StreamingDataset
|
|
13
|
+
// loading from HTTP without checksum
|
|
14
|
+
// 5. PROMPT_FROM_ENV_OR_URL System prompt sourced from env var /
|
|
15
|
+
// URL fetch / file read without integrity
|
|
16
|
+
// 6. AGENT_TOOL_EXPOSES_EXEC LangChain / OpenAI function-calling
|
|
17
|
+
// tool definitions exposing exec/shell/eval/fs.write
|
|
18
|
+
// 7. UNSAFE_MODEL_FILE_FORMAT Loading .pt/.pth/.bin where .safetensors
|
|
19
|
+
// would do (informational nudge)
|
|
20
|
+
// 8. MODEL_HASH_NOT_VERIFIED Downloading model file via requests /
|
|
21
|
+
// urllib without a verifying checksum
|
|
22
|
+
// 9. GRADIO_AUTH_DISABLED gradio.launch(share=True) without auth
|
|
23
|
+
// 10. CUSTOM_HF_HUB_URL HF cache_dir / endpoint override
|
|
24
|
+
// pointing at non-canonical mirror
|
|
25
|
+
//
|
|
26
|
+
// Detection: regex on .py / .ipynb. Lower confidence than model-load.js
|
|
27
|
+
// because these patterns are less unique — context matters.
|
|
28
|
+
//
|
|
29
|
+
// Opt-out: AGENTIC_SECURITY_NO_ML_SUPPLY=1
|
|
30
|
+
|
|
31
|
+
import { blankComments } from './_comment-strip.js';
|
|
32
|
+
|
|
33
|
+
const _SCAN_EXT_RE = /\.(?:py|ipynb)$/i;
|
|
34
|
+
const _NONPROD_PATH_RE = /(?:^|\/)(?:tests?|__tests__|spec|fixtures?|examples?|docs?|stories|codefixes|node_modules)\//i;
|
|
35
|
+
|
|
36
|
+
const _RELEVANCE = /\b(?:mlflow|onnxruntime|onnx_runtime|datasets\b|webdataset|streaming\.StreamingDataset|gradio|huggingface_hub|HUGGINGFACE|HF_HUB|langchain|openai|anthropic|tools?\s*=)/i;
|
|
37
|
+
|
|
38
|
+
function _line(raw, idx) { return raw.slice(0, idx).split('\n').length; }
|
|
39
|
+
function _snip(raw, line) { return (raw.split('\n')[line - 1] || '').trim().slice(0, 200); }
|
|
40
|
+
|
|
41
|
+
function _shape(file, line, ruleId, vuln, fam, sev, cwe, remediation, description) {
|
|
42
|
+
return {
|
|
43
|
+
id: `${ruleId}:${file}:${line}`,
|
|
44
|
+
file, line, vuln, severity: sev, cwe,
|
|
45
|
+
family: fam, parser: 'ML-SUPPLY',
|
|
46
|
+
confidence: 0.75,
|
|
47
|
+
stride: 'Tampering',
|
|
48
|
+
description: description || vuln,
|
|
49
|
+
remediation,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Detectors ──────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function detectMlflowUntrustedUri(file, raw, code, out, seen) {
|
|
56
|
+
// mlflow.pyfunc.load_model / mlflow.sklearn.load_model from URI not pinning
|
|
57
|
+
// to a registry alias / version.
|
|
58
|
+
const re = /\bmlflow\.(?:pyfunc|sklearn|pytorch|keras|tensorflow|onnx|spark|xgboost)\.load_model\s*\(\s*['"]([^'"]+)['"]/g;
|
|
59
|
+
let m;
|
|
60
|
+
while ((m = re.exec(code))) {
|
|
61
|
+
const uri = m[1];
|
|
62
|
+
// Pinned forms: models:/<name>/<version-number>, models:/<name>@<alias>,
|
|
63
|
+
// runs:/<run-id>/<artifact>, or trailing /\d+ on a non-models URI.
|
|
64
|
+
const isPinned =
|
|
65
|
+
/\bmodels:\/[^/]+\/\d+\b/.test(uri) ||
|
|
66
|
+
/\bmodels:\/[^/]+@\w+/.test(uri) ||
|
|
67
|
+
/\bruns:\/[^/]+\/[\w./-]+/.test(uri) ||
|
|
68
|
+
/\/v?\d+(?:\.\d+)*(?:[/?#]|$)/.test(uri);
|
|
69
|
+
if (isPinned) continue;
|
|
70
|
+
const ln = _line(raw, m.index);
|
|
71
|
+
const id = `mlflow-untrusted-uri:${file}:${ln}`;
|
|
72
|
+
if (seen.has(id)) continue;
|
|
73
|
+
seen.add(id);
|
|
74
|
+
out.push(_shape(file, ln, 'mlflow-untrusted-uri',
|
|
75
|
+
`mlflow.load_model from ${uri} without a pinned version / alias`,
|
|
76
|
+
'mlflow-untrusted-uri', 'medium', 'CWE-1357',
|
|
77
|
+
'Use a pinned MLflow URI: `models:/<name>/<version>` or `models:/<name>@<alias>`. Without it, model loads pick up whatever revision the registry serves at runtime — a registry compromise (or accidental promotion) silently changes your inference path.',
|
|
78
|
+
'MLflow URIs without explicit version pinning resolve to the current "latest" / champion. Model promotion events change the deployed model without redeploying your service. Same risk class as `:latest` Docker tags.'));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function detectOnnxNoProviderAllowlist(file, raw, code, out, seen) {
|
|
83
|
+
// onnxruntime.InferenceSession(...) without explicit providers=[...].
|
|
84
|
+
const re = /\b(?:onnxruntime|ort)\.InferenceSession\s*\([^)]+\)/g;
|
|
85
|
+
let m;
|
|
86
|
+
while ((m = re.exec(code))) {
|
|
87
|
+
if (/providers\s*=/.test(m[0])) continue;
|
|
88
|
+
const ln = _line(raw, m.index);
|
|
89
|
+
const id = `onnx-no-providers:${file}:${ln}`;
|
|
90
|
+
if (seen.has(id)) continue;
|
|
91
|
+
seen.add(id);
|
|
92
|
+
out.push(_shape(file, ln, 'onnx-no-providers',
|
|
93
|
+
'onnxruntime.InferenceSession without explicit providers list — falls back to default CPU+CUDA',
|
|
94
|
+
'onnx-providers', 'low', 'CWE-1357',
|
|
95
|
+
'Pass `providers=["CPUExecutionProvider"]` (or specific GPU provider). Without it, ONNX Runtime tries CUDA → DirectML → CPU in order; on shared machines this can leak inference state through GPU residual data.',
|
|
96
|
+
'Explicit providers=[...] also prevents accidental upgrades when ORT changes its provider auto-selection logic between versions.'));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function detectHfDatasetsTrustRemoteCode(file, raw, code, out, seen) {
|
|
101
|
+
const re = /\bload_dataset\s*\([^)]*trust_remote_code\s*=\s*True/g;
|
|
102
|
+
let m;
|
|
103
|
+
while ((m = re.exec(code))) {
|
|
104
|
+
const ln = _line(raw, m.index);
|
|
105
|
+
const id = `hf-datasets-trust-remote-code:${file}:${ln}`;
|
|
106
|
+
if (seen.has(id)) continue;
|
|
107
|
+
seen.add(id);
|
|
108
|
+
out.push(_shape(file, ln, 'hf-datasets-trust-remote-code',
|
|
109
|
+
'datasets.load_dataset(trust_remote_code=True) executes arbitrary loading code from HF Hub',
|
|
110
|
+
'hf-datasets-rce', 'critical', 'CWE-94',
|
|
111
|
+
'Remove trust_remote_code=True. If you need a dataset whose loader requires custom code, audit the loader script first and vendor it locally as a script under your repo.',
|
|
112
|
+
'HF datasets can ship a Python loader script (.py) that runs during load_dataset. trust_remote_code=True allows that script to run. Same RCE class as transformers.from_pretrained(trust_remote_code=True).'));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function detectStreamingDatasetUrl(file, raw, code, out, seen) {
|
|
117
|
+
// webdataset / mosaicml streaming from http(s) URL.
|
|
118
|
+
const patterns = [
|
|
119
|
+
/\bwebdataset\.(?:WebDataset|WebLoader)\s*\(\s*['"]https?:\/\//g,
|
|
120
|
+
/\bstreaming\.StreamingDataset\s*\(\s*[^)]*remote\s*=\s*['"]https?:\/\//g,
|
|
121
|
+
/\bdatasets\.load_dataset\s*\(\s*['"]https?:\/\//g,
|
|
122
|
+
];
|
|
123
|
+
for (const re of patterns) {
|
|
124
|
+
let m;
|
|
125
|
+
while ((m = re.exec(code))) {
|
|
126
|
+
const ln = _line(raw, m.index);
|
|
127
|
+
const id = `streaming-dataset-url:${file}:${ln}`;
|
|
128
|
+
if (seen.has(id)) continue;
|
|
129
|
+
seen.add(id);
|
|
130
|
+
out.push(_shape(file, ln, 'streaming-dataset-url',
|
|
131
|
+
'Streaming dataset loaded from HTTP(S) URL without integrity verification',
|
|
132
|
+
'streaming-dataset-url', 'medium', 'CWE-494',
|
|
133
|
+
'Mirror the dataset to a controlled store (S3 + signed URLs, GCS, internal HF Hub) and verify a manifest checksum before training. Public CDNs hosting datasets are routinely repointed.',
|
|
134
|
+
'Training-time data poisoning is a documented attack class — controlling even 0.1% of training data is enough to backdoor an LLM (Carlini et al., 2023). Mirror + checksum is the proven defense.'));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function detectPromptFromEnvOrUrl(file, raw, code, out, seen) {
|
|
140
|
+
// System prompt sourced from os.environ / requests.get / open() at runtime.
|
|
141
|
+
const indicators = [
|
|
142
|
+
{ re: /SYSTEM_PROMPT\s*=\s*os\.(?:environ\.get|getenv)\s*\(/g, src: 'os.environ' },
|
|
143
|
+
{ re: /system_prompt\s*=\s*os\.(?:environ\.get|getenv)\s*\(/g, src: 'os.environ' },
|
|
144
|
+
{ re: /(?:system_prompt|SYSTEM_PROMPT)\s*=\s*requests\.get\s*\(/g, src: 'requests.get' },
|
|
145
|
+
{ re: /(?:system_prompt|SYSTEM_PROMPT)\s*=\s*open\s*\(\s*['"][^'"]+['"]/g, src: 'open()' },
|
|
146
|
+
];
|
|
147
|
+
for (const ind of indicators) {
|
|
148
|
+
let m;
|
|
149
|
+
while ((m = ind.re.exec(code))) {
|
|
150
|
+
const ln = _line(raw, m.index);
|
|
151
|
+
const id = `prompt-from-env-or-url:${file}:${ln}`;
|
|
152
|
+
if (seen.has(id)) continue;
|
|
153
|
+
seen.add(id);
|
|
154
|
+
out.push(_shape(file, ln, 'prompt-from-env-or-url',
|
|
155
|
+
`System prompt loaded from ${ind.src} — modifiable at runtime`,
|
|
156
|
+
'prompt-integrity', 'medium', 'CWE-345',
|
|
157
|
+
'Bake the system prompt into the deployed artifact (Python string constant or repo-committed file). If you need runtime overrides, gate them behind a signed manifest (Sigstore) or HMAC-checked source. Environment variables and remote fetches are tamperable by anyone with deploy or network access.',
|
|
158
|
+
'A modifiable system prompt is one of the easiest ways to subvert an agent — change the instructions, change the behavior. Recent prompt-injection-via-config-file incidents (Replit Agent, Cursor) all reduce to this pattern.'));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// (Agent tools exposing exec/shell/subprocess to the LLM are detected by
|
|
164
|
+
// sast/llm-app.js detectToolExec — not duplicated here.)
|
|
165
|
+
|
|
166
|
+
function detectUnsafeModelFileFormat(file, raw, code, out, seen) {
|
|
167
|
+
// Loading .pt / .pth / .bin — recommend .safetensors.
|
|
168
|
+
// Only fires when path-string literally ends in one of those extensions.
|
|
169
|
+
const re = /['"]([^'"]+\.(?:pt|pth|bin|ckpt))['"]/g;
|
|
170
|
+
let m;
|
|
171
|
+
while ((m = re.exec(code))) {
|
|
172
|
+
const window = code.slice(Math.max(0, m.index - 100), m.index);
|
|
173
|
+
if (!/torch\.load|load_state_dict|from_pretrained|joblib\.load/.test(window)) continue;
|
|
174
|
+
const ln = _line(raw, m.index);
|
|
175
|
+
const id = `unsafe-model-format:${file}:${ln}`;
|
|
176
|
+
if (seen.has(id)) continue;
|
|
177
|
+
seen.add(id);
|
|
178
|
+
out.push(_shape(file, ln, 'unsafe-model-format',
|
|
179
|
+
`Loading ${m[1]} (.pt/.pth/.bin/.ckpt is pickle-based) — prefer .safetensors`,
|
|
180
|
+
'model-format', 'low', 'CWE-502',
|
|
181
|
+
'Convert to `.safetensors` format: `from safetensors.torch import save_file; save_file(state_dict, "model.safetensors")`. Safetensors is a header + raw tensor data — it cannot execute code during load.',
|
|
182
|
+
'pickle-based model formats are the canonical RCE attack vector for ML supply chain. Safetensors was created specifically to eliminate this class.'));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function detectGradioAuthDisabled(file, raw, code, out, seen) {
|
|
187
|
+
// gradio.launch(share=True) without auth=...
|
|
188
|
+
const re = /\b(?:gr|gradio)\.[A-Z]\w*\.launch\s*\(([^)]*)\)|\b(?:demo|app|iface)\.launch\s*\(([^)]*)\)/g;
|
|
189
|
+
let m;
|
|
190
|
+
while ((m = re.exec(code))) {
|
|
191
|
+
const args = (m[1] || m[2] || '');
|
|
192
|
+
if (!/share\s*=\s*True/.test(args)) continue;
|
|
193
|
+
if (/\bauth\s*=/.test(args)) continue;
|
|
194
|
+
const ln = _line(raw, m.index);
|
|
195
|
+
const id = `gradio-share-no-auth:${file}:${ln}`;
|
|
196
|
+
if (seen.has(id)) continue;
|
|
197
|
+
seen.add(id);
|
|
198
|
+
out.push(_shape(file, ln, 'gradio-share-no-auth',
|
|
199
|
+
'gradio launch with share=True but no auth — publicly accessible via gradio.live',
|
|
200
|
+
'gradio-auth', 'high', 'CWE-862',
|
|
201
|
+
'Add `auth=("user", "password")` or `auth=callable` to gradio.launch. share=True exposes the demo via gradio.live tunnel — a public URL that anyone with the link can reach.',
|
|
202
|
+
'gradio.live tunnels are routinely scraped by drift-bots for unauthenticated ML demos. Many demos run prediction APIs that consume rate-limited backend resources.'));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function detectCustomHfHubUrl(file, raw, code, out, seen) {
|
|
207
|
+
// HF_HUB_ENDPOINT / HF_ENDPOINT / HUGGINGFACE_HUB_CACHE pointing at a
|
|
208
|
+
// non-canonical URL.
|
|
209
|
+
const re = /(?:HF_HUB_ENDPOINT|HF_ENDPOINT|HUGGINGFACE_CO_URL|HUGGINGFACE_HUB_URL)\s*=\s*['"]([^'"]+)['"]/g;
|
|
210
|
+
let m;
|
|
211
|
+
while ((m = re.exec(code))) {
|
|
212
|
+
const url = m[1];
|
|
213
|
+
if (/huggingface\.co|hf\.co/.test(url)) continue;
|
|
214
|
+
const ln = _line(raw, m.index);
|
|
215
|
+
const id = `custom-hf-hub-url:${file}:${ln}`;
|
|
216
|
+
if (seen.has(id)) continue;
|
|
217
|
+
seen.add(id);
|
|
218
|
+
out.push(_shape(file, ln, 'custom-hf-hub-url',
|
|
219
|
+
`HF Hub endpoint overridden to ${url}`,
|
|
220
|
+
'hf-endpoint-override', 'medium', 'CWE-494',
|
|
221
|
+
'Verify this is an authorized mirror (e.g. corporate proxy with TLS termination + integrity verification). Some attacks substitute a hostile mirror that returns backdoored weights for popular model names.',
|
|
222
|
+
'Mirror substitution is a documented supply-chain pattern. The mirror returns valid weights for unknown queries and substituted weights for the model the attacker targets.'));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Entry point ────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
const _BENCH_FIXTURE_RE = /(?:^|\/|\\)(?:BenchmarkTest|JulietTestCase|CWE\d+_)[\w-]*\.(?:py|java|c|cpp|cs)$/i;
|
|
229
|
+
|
|
230
|
+
export function scanMlSupplyChain(fp, raw) {
|
|
231
|
+
if (process.env.AGENTIC_SECURITY_NO_ML_SUPPLY === '1') return [];
|
|
232
|
+
if (!raw || raw.length > 500_000) return [];
|
|
233
|
+
if (!_SCAN_EXT_RE.test(fp)) return [];
|
|
234
|
+
if (_BENCH_FIXTURE_RE.test(fp)) return [];
|
|
235
|
+
if (_NONPROD_PATH_RE.test(fp.replace(/\\/g, '/'))) return [];
|
|
236
|
+
if (!_RELEVANCE.test(raw)) return [];
|
|
237
|
+
|
|
238
|
+
const code = blankComments(raw, 'py');
|
|
239
|
+
const out = [];
|
|
240
|
+
const seen = new Set();
|
|
241
|
+
try { detectMlflowUntrustedUri(fp, raw, code, out, seen); } catch {}
|
|
242
|
+
try { detectOnnxNoProviderAllowlist(fp, raw, code, out, seen); } catch {}
|
|
243
|
+
try { detectHfDatasetsTrustRemoteCode(fp, raw, code, out, seen); } catch {}
|
|
244
|
+
try { detectStreamingDatasetUrl(fp, raw, code, out, seen); } catch {}
|
|
245
|
+
try { detectPromptFromEnvOrUrl(fp, raw, code, out, seen); } catch {}
|
|
246
|
+
try { detectUnsafeModelFileFormat(fp, raw, code, out, seen); } catch {}
|
|
247
|
+
try { detectGradioAuthDisabled(fp, raw, code, out, seen); } catch {}
|
|
248
|
+
try { detectCustomHfHubUrl(fp, raw, code, out, seen); } catch {}
|
|
249
|
+
for (const f of out) f.file = fp;
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const _internals = {
|
|
254
|
+
_RELEVANCE,
|
|
255
|
+
detectMlflowUntrustedUri, detectOnnxNoProviderAllowlist,
|
|
256
|
+
detectHfDatasetsTrustRemoteCode, detectStreamingDatasetUrl,
|
|
257
|
+
detectPromptFromEnvOrUrl,
|
|
258
|
+
detectUnsafeModelFileFormat, detectGradioAuthDisabled, detectCustomHfHubUrl,
|
|
259
|
+
};
|