@shomra/agent 0.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/LICENSE +201 -0
- package/NOTICE +10 -0
- package/README.md +220 -0
- package/code-sast.mjs +763 -0
- package/discovery.mjs +812 -0
- package/guard-signals.mjs +747 -0
- package/model-refs.mjs +130 -0
- package/package.json +51 -0
- package/shomra.mjs +4193 -0
package/code-sast.mjs
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local SAST rule engine for AI-artifact source code — a dependency-free port of
|
|
3
|
+
* the platform's src/checks/code-sast.ts, so the CLI can catch the same
|
|
4
|
+
* AI-vulnerability shapes ON-MACHINE (offline, pre-commit, in the IDE) that the
|
|
5
|
+
* model scan and workspace scan catch server-side. Nothing here is executed.
|
|
6
|
+
*
|
|
7
|
+
* Three analysis tiers, worst-first, tuned for low false positives:
|
|
8
|
+
*
|
|
9
|
+
* 1. Pattern rules (line/logical-line oriented regexes) over three languages —
|
|
10
|
+
* • Python: eval/exec/os.system/subprocess, pickle/torch.load/cloudpickle/
|
|
11
|
+
* jsonpickle deserialization, trust_remote_code, torch.hub/PackageImporter
|
|
12
|
+
* remote code, weights_only=False, Keras safe_mode=False Lambda RCE,
|
|
13
|
+
* pandas.read_pickle / mlflow load, unsafe YAML, RAG/FAISS unsafe deser,
|
|
14
|
+
* __reduce__ gadgets, the agentic code-executing frameworks (LangChain
|
|
15
|
+
* PythonREPL/PALChain, LlamaIndex PandasQueryEngine, smolagents CodeAgent /
|
|
16
|
+
* LocalPythonExecutor, AutoGen local code_execution, load_tools python_repl),
|
|
17
|
+
* LangChain serialized-chain loads, Gradio share=True exposure, hardcoded
|
|
18
|
+
* AI-provider keys, network egress, dynamic imports, decode-and-run, secrets.
|
|
19
|
+
* • JS/TS: eval/new Function/vm/string-setTimeout, child_process, decode-and-run
|
|
20
|
+
* packers, dynamic require/import, network egress, hardcoded AI keys.
|
|
21
|
+
* • config.json / tokenizer_config.json: auto_map / custom_pipeline / declared
|
|
22
|
+
* trust_remote_code → remote-code-under-load.
|
|
23
|
+
*
|
|
24
|
+
* 2. Lightweight taint tier — tracks variables assigned from an LLM call
|
|
25
|
+
* (`.generate` / `.invoke` / `.completions.create` / `.messages.create` …),
|
|
26
|
+
* propagates that taint across simple assignments, and raises a CRITICAL
|
|
27
|
+
* `*.llm_output_to_sink` when a tainted value reaches a code-execution sink.
|
|
28
|
+
* This is the prompt-injection → RCE shape that single-sink matching misses.
|
|
29
|
+
*
|
|
30
|
+
* 3. Cross-signal chain tier — synthesises a finding when two independently
|
|
31
|
+
* suspicious signals co-occur in one file: encoded-payload + code-exec
|
|
32
|
+
* (`chain.decode_exec`), or remote-code loading + network egress
|
|
33
|
+
* (`chain.remote_code_egress`). Conjunctions only, so no added false positives.
|
|
34
|
+
*
|
|
35
|
+
* Findings carry a stable dotted rule id, the matched SINK, an optional SOURCE, a
|
|
36
|
+
* CWE, a category, a 0–1 confidence, the FILE + physical LINE and a context
|
|
37
|
+
* SNIPPET — the exact shape the Risk Evaluation UI renders and shomra.mjs folds
|
|
38
|
+
* into a gate result. Keep the rule bodies in sync with src/checks/code-sast.ts —
|
|
39
|
+
* drift only costs recall on the local floor; the server remains the full check.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const MAX_SNIPPET = 400;
|
|
43
|
+
const CONTEXT_RADIUS = 3;
|
|
44
|
+
/**
|
|
45
|
+
* Cap on how many physical lines one logical statement may absorb. Bounds the
|
|
46
|
+
* regex work and stops a single unbalanced-bracket line (or a minified blob)
|
|
47
|
+
* from swallowing the rest of the file into one giant unit.
|
|
48
|
+
*/
|
|
49
|
+
const MAX_JOIN_LINES = 40;
|
|
50
|
+
|
|
51
|
+
// ── Python rules ──────────────────────────────────────────────────
|
|
52
|
+
const PY_RULES = [
|
|
53
|
+
{
|
|
54
|
+
id: 'python.dangerous_sinks',
|
|
55
|
+
title: 'Dangerous code-execution sink',
|
|
56
|
+
severity: 'CRITICAL',
|
|
57
|
+
category: 'code-exec',
|
|
58
|
+
confidence: 0.85,
|
|
59
|
+
re: /(?<![.\w])(eval|exec|compile)\s*\(|\bos\.(system|popen|exec[lv]?[pe]*)\s*\(|\bsubprocess\.(run|call|check_output|check_call|Popen)\s*\(|(?<![.\w])__import__\s*\(|(?<![.\w])getattr\s*\(\s*__builtins__/,
|
|
60
|
+
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
61
|
+
source: 'model load / forward()',
|
|
62
|
+
message: 'Model code invokes an arbitrary code-execution primitive. Under trust_remote_code this runs in the host process the moment the model is imported.',
|
|
63
|
+
remediation: 'Remove the eval/exec/os.system/subprocess call. Load this model only after reviewing the pinned revision; never with trust_remote_code=True from an untrusted publisher.',
|
|
64
|
+
cwe: 'CWE-94',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'python.pickle_deserialization',
|
|
68
|
+
title: 'Unsafe deserialization',
|
|
69
|
+
severity: 'CRITICAL',
|
|
70
|
+
category: 'deserialization',
|
|
71
|
+
confidence: 0.9,
|
|
72
|
+
// Pickle-backed loaders across the ML stack: raw pickle/dill/cloudpickle/
|
|
73
|
+
// jsonpickle, torch.load, joblib/skops, numpy allow_pickle, yaml.load without a
|
|
74
|
+
// safe Loader, shelve, pandas.read_pickle, mlflow.*.load_model. All run
|
|
75
|
+
// __reduce__ / arbitrary code on a crafted file the moment they load.
|
|
76
|
+
re: /\b(pickle|cpickle|dill|_pickle|cloudpickle)\.(loads?|Unpickler)\s*\(|\bjsonpickle\.(decode|loads)\s*\(|\btorch\.(load|jit\.load)\s*\(|\byaml\.(unsafe_load|load\s*\((?![^)]*Loader\s*=\s*yaml\.(Safe|Full)Loader))|\bjoblib\.load\s*\(|\bskops\.io\.load\s*\(|\bshelve\.open\s*\(|\bnumpy\.load\s*\([^)]*allow_pickle\s*=\s*True|\b(pandas|pd)\.read_pickle\s*\(|\bmlflow\.[\w.]+\.load_model\s*\(/,
|
|
77
|
+
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
78
|
+
source: 'weight / config file',
|
|
79
|
+
message: 'Deserializes data with a pickle-backed loader. A crafted file runs arbitrary code via __reduce__ on load — the primary model-hub malware vector.',
|
|
80
|
+
remediation: 'Load weights from safetensors (use_safetensors=True). For YAML use yaml.safe_load; for numpy set allow_pickle=False; avoid torch.load / cloudpickle / pandas.read_pickle / mlflow.load_model on untrusted files.',
|
|
81
|
+
cwe: 'CWE-502',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'python.trust_remote_code',
|
|
85
|
+
title: 'Model loaded with trust_remote_code',
|
|
86
|
+
severity: 'CRITICAL',
|
|
87
|
+
category: 'remote-code',
|
|
88
|
+
confidence: 0.95,
|
|
89
|
+
re: /trust_remote_code\s*=\s*True/,
|
|
90
|
+
sink: () => 'trust_remote_code=True',
|
|
91
|
+
source: 'model repository',
|
|
92
|
+
message: 'Loads a model/tokenizer/embedder with trust_remote_code=True, which imports and runs code shipped in the model repo inside the host process before any weights load — an instant RCE if the publisher, or a later silent revision, is malicious.',
|
|
93
|
+
remediation: 'Remove trust_remote_code=True. Prefer a model with native transformers support, or pin revision= to a specific reviewed commit hash and read the custom modeling code first.',
|
|
94
|
+
cwe: 'CWE-94',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'python.torch_remote_code',
|
|
98
|
+
title: 'Loads/executes remote or native code via torch',
|
|
99
|
+
severity: 'CRITICAL',
|
|
100
|
+
category: 'remote-code',
|
|
101
|
+
confidence: 0.9,
|
|
102
|
+
re: /\btorch\.hub\.load\s*\(|\btorch\.hub\.load_state_dict_from_url\s*\(|\btorch\.package\.PackageImporter\s*\(|\btorch\.classes\.load_library\s*\(/,
|
|
103
|
+
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
104
|
+
source: 'remote repo / packaged code',
|
|
105
|
+
message: 'Fetches and executes code that is not in this repository (torch.hub.load runs a remote hubconf.py; load_state_dict_from_url pulls a pickle; PackageImporter / load_library run packaged or native code) — an instant RCE at load time.',
|
|
106
|
+
remediation: 'Do not torch.hub.load untrusted repos (and never with trust_repo=True on an unreviewed source). Load a local, reviewed safetensors checkpoint instead.',
|
|
107
|
+
cwe: 'CWE-494',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: 'python.weights_only_false',
|
|
111
|
+
title: 'torch.load with weights_only=False',
|
|
112
|
+
severity: 'CRITICAL',
|
|
113
|
+
category: 'deserialization',
|
|
114
|
+
confidence: 0.95,
|
|
115
|
+
re: /\btorch\.load\s*\([^)]*weights_only\s*=\s*False/,
|
|
116
|
+
sink: () => 'torch.load(..., weights_only=False)',
|
|
117
|
+
source: 'weight file',
|
|
118
|
+
message: 'torch.load is called with weights_only=False, which turns the safe (default since torch 2.6) tensor-only loader back into the full pickle unpickler — a crafted checkpoint then runs arbitrary code via __reduce__ on load.',
|
|
119
|
+
remediation: 'Remove weights_only=False (let it default to True), or load from safetensors. Only ever disable it for a checkpoint you built yourself.',
|
|
120
|
+
cwe: 'CWE-502',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'python.keras_unsafe_load',
|
|
124
|
+
title: 'Keras load with safe_mode disabled',
|
|
125
|
+
severity: 'HIGH',
|
|
126
|
+
category: 'deserialization',
|
|
127
|
+
confidence: 0.85,
|
|
128
|
+
re: /\bsafe_mode\s*=\s*False/,
|
|
129
|
+
sink: () => 'safe_mode=False',
|
|
130
|
+
source: 'model file',
|
|
131
|
+
message: 'A Keras/TensorFlow model is loaded with safe_mode=False, which allows deserialization of Lambda layers — arbitrary Python bytecode that executes the moment the model loads (CVE-2024-3660 / CVE-2025-1550 class).',
|
|
132
|
+
remediation: 'Remove safe_mode=False. Load only models you trust; a Lambda layer in an untrusted model is remote code execution regardless of format (.h5 ignores safe_mode entirely).',
|
|
133
|
+
cwe: 'CWE-502',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: 'python.langchain_code_exec',
|
|
137
|
+
title: 'LLM-driven code-execution component',
|
|
138
|
+
severity: 'HIGH',
|
|
139
|
+
category: 'agentic',
|
|
140
|
+
confidence: 0.85,
|
|
141
|
+
// Agent-framework components that run LLM-generated code (exec/eval on model
|
|
142
|
+
// output). LangChain (PythonREPL, PAL/CPAL, LLMMathChain), LlamaIndex
|
|
143
|
+
// (PandasQueryEngine, PandasInstructionParser, CodeInterpreterToolSpec),
|
|
144
|
+
// smolagents (CodeAgent, LocalPythonExecutor), plus load_tools() wiring a
|
|
145
|
+
// python_repl/terminal/shell tool. Distinctive names → near-zero FP; any of
|
|
146
|
+
// them reached by untrusted model output is RCE in the agent host.
|
|
147
|
+
re: /\b(PythonREPL|PythonREPLTool|PythonAstREPLTool|PALChain|CPALChain|LLMMathChain|create_pandas_dataframe_agent|create_spark_dataframe_agent|create_csv_agent|PandasQueryEngine|PandasInstructionParser|CodeInterpreterToolSpec|CodeAgent|LocalPythonExecutor|local_python_executor|PythonInterpreterTool)\b|\bload_tools\s*\([^)]*['"](python_repl|terminal|shell|bash)/,
|
|
148
|
+
sink: (m) => m[0].trim(),
|
|
149
|
+
source: 'LLM output',
|
|
150
|
+
message: 'Uses an agent-framework component that executes LLM-generated code (exec/eval on model output). If the model can be steered (prompt injection), this is remote code execution in the agent host (CVE-2023-29374 / CVE-2024-4181 class).',
|
|
151
|
+
remediation: 'Avoid code-executing chains/tools on untrusted input. If unavoidable, run them in a locked-down sandbox with no host/network access and a strict output validator.',
|
|
152
|
+
cwe: 'CWE-94',
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: 'python.autogen_local_exec',
|
|
156
|
+
title: 'Agent executes LLM-written code locally',
|
|
157
|
+
severity: 'HIGH',
|
|
158
|
+
category: 'agentic',
|
|
159
|
+
confidence: 0.75,
|
|
160
|
+
// AutoGen / ag2 executes code the LLM writes. A dict code_execution_config
|
|
161
|
+
// (rather than False) enables it; use_docker=False forces it to run on the
|
|
162
|
+
// host instead of an isolated container — LLM-authored code as host RCE.
|
|
163
|
+
re: /code_execution_config\s*=\s*\{|use_docker\s*=\s*False/,
|
|
164
|
+
sink: (m) => m[0].replace(/\s*=\s*\{$/, '').trim(),
|
|
165
|
+
source: 'LLM output',
|
|
166
|
+
message: 'An AutoGen-style agent is configured to execute LLM-written code on the host (a dict code_execution_config / use_docker=False). Any prompt-injected instruction the model follows becomes code execution in the agent process.',
|
|
167
|
+
remediation: 'Set code_execution_config=False, or require use_docker=True (an isolated container) with no host mounts and a locked-down image. Never run model-authored code directly on the host.',
|
|
168
|
+
cwe: 'CWE-94',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: 'python.langchain_serialized_load',
|
|
172
|
+
title: 'Loads a serialized chain / prompt / agent',
|
|
173
|
+
severity: 'HIGH',
|
|
174
|
+
category: 'deserialization',
|
|
175
|
+
confidence: 0.75,
|
|
176
|
+
// LangChain load_chain/load_prompt/load_agent deserialize a JSON/YAML config
|
|
177
|
+
// that can instantiate arbitrary classes; hub.pull fetches a remote prompt/
|
|
178
|
+
// chain object. A poisoned artifact becomes code at construction time.
|
|
179
|
+
re: /\b(load_chain|load_prompt|load_agent)\s*\(|\bhub\.pull\s*\(/,
|
|
180
|
+
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
181
|
+
source: 'serialized chain / hub',
|
|
182
|
+
message: 'Deserializes a LangChain chain/prompt/agent from a file or the hub. The serialized config can name arbitrary classes to construct — a poisoned artifact is code execution when the object is built.',
|
|
183
|
+
remediation: 'Build chains in code from reviewed source, not from an untrusted serialized artifact; if you must load one, pin and review it and never load from a user-supplied path/URL.',
|
|
184
|
+
cwe: 'CWE-502',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 'python.rag_unsafe_deser',
|
|
188
|
+
title: 'Unsafe vector-store / RAG deserialization',
|
|
189
|
+
severity: 'CRITICAL',
|
|
190
|
+
category: 'deserialization',
|
|
191
|
+
confidence: 0.9,
|
|
192
|
+
re: /allow_dangerous_deserialization\s*=\s*True|\bFAISS\.load_local\s*\(|\b(pickle|joblib)\.load\s*\([^)]*(index|faiss|embedding|vector|chroma)/i,
|
|
193
|
+
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
194
|
+
source: 'vector store / embedding index',
|
|
195
|
+
message: 'Loads a RAG vector store or embedding index through a pickle-backed path (allow_dangerous_deserialization / FAISS.load_local / a pickled index). A poisoned index file executes arbitrary code the moment it is loaded — the embedding-store supply-chain vector.',
|
|
196
|
+
remediation: 'Never set allow_dangerous_deserialization=True on an index you did not build. Rebuild the vector store from source documents in your own environment, or use a non-pickle store format.',
|
|
197
|
+
cwe: 'CWE-502',
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'python.reduce_payload',
|
|
201
|
+
title: 'Custom __reduce__ (pickle RCE gadget)',
|
|
202
|
+
severity: 'CRITICAL',
|
|
203
|
+
category: 'deserialization',
|
|
204
|
+
confidence: 0.8,
|
|
205
|
+
re: /def\s+__reduce__\s*\(|def\s+__reduce_ex__\s*\(|def\s+__setstate__\s*\(/,
|
|
206
|
+
sink: (m) => m[0].replace(/^def\s+/, '').replace(/\s*\($/, '').trim(),
|
|
207
|
+
message: 'Defines a pickle reduction hook. These execute on unpickling and are the classic gadget used to hide code-exec inside a serialized model object.',
|
|
208
|
+
remediation: 'Verify why the class needs custom pickling. Do not unpickle objects from this repo; prefer safetensors serialization which has no code path.',
|
|
209
|
+
cwe: 'CWE-502',
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: 'python.network_egress',
|
|
213
|
+
title: 'Network egress from model code',
|
|
214
|
+
severity: 'HIGH',
|
|
215
|
+
category: 'egress',
|
|
216
|
+
confidence: 0.6,
|
|
217
|
+
re: /\b(requests|httpx)\.(get|post|put|request)\s*\(|\burllib\.request\.(urlopen|urlretrieve)\s*\(|\bsocket\.(socket|create_connection)\s*\(|\baiohttp\.ClientSession\s*\(/,
|
|
218
|
+
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
219
|
+
source: 'network',
|
|
220
|
+
message: 'Model code opens a network connection. Legitimate modeling/tokenizer code has no reason to phone out — this is the exfiltration / second-stage-download shape.',
|
|
221
|
+
remediation: 'Review the destination and payload. Model inference code should never make outbound requests; treat this model as hostile until proven otherwise.',
|
|
222
|
+
cwe: 'CWE-913',
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: 'python.dynamic_import',
|
|
226
|
+
title: 'Dynamic / obfuscated import',
|
|
227
|
+
severity: 'HIGH',
|
|
228
|
+
category: 'obfuscation',
|
|
229
|
+
confidence: 0.7,
|
|
230
|
+
re: /\bimportlib\.import_module\s*\(|\b__import__\s*\(\s*['"]?\s*(os|subprocess|socket|base64|marshal|ctypes)|\bexec\s*\(\s*(base64|bytes|marshal|codecs)/,
|
|
231
|
+
sink: (m) => m[0].trim(),
|
|
232
|
+
message: 'Imports or executes a module chosen at runtime, often to hide os/subprocess/socket usage from a quick read.',
|
|
233
|
+
remediation: 'Resolve what is imported and why. Obfuscated dynamic imports in model code are a strong malware tell.',
|
|
234
|
+
cwe: 'CWE-94',
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: 'python.encoded_payload',
|
|
238
|
+
title: 'Encoded payload decode-and-run',
|
|
239
|
+
severity: 'HIGH',
|
|
240
|
+
category: 'obfuscation',
|
|
241
|
+
confidence: 0.6,
|
|
242
|
+
re: /\b(base64|codecs|binascii|marshal)\.(b64decode|decode|unhexlify|loads)\s*\(|bytes\.fromhex\s*\(/,
|
|
243
|
+
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
244
|
+
message: 'Decodes an encoded blob. Combined with eval/exec this is the "decode a base64 string then run it" packer used to smuggle payloads past a skim.',
|
|
245
|
+
remediation: 'Decode the blob offline and inspect it. Never load model code that decodes-and-runs embedded strings.',
|
|
246
|
+
cwe: 'CWE-506',
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: 'python.gradio_public_share',
|
|
250
|
+
title: 'Model UI exposed via public share tunnel',
|
|
251
|
+
severity: 'MEDIUM',
|
|
252
|
+
category: 'exposure',
|
|
253
|
+
confidence: 0.8,
|
|
254
|
+
re: /\.launch\s*\([^)]*share\s*=\s*True|\.queue\s*\([^)]*\)\.launch\s*\([^)]*share\s*=\s*True/,
|
|
255
|
+
sink: () => 'launch(share=True)',
|
|
256
|
+
message: 'Launches a Gradio/model UI with share=True, publishing a public tunnel URL to a locally-running model — anyone with the link can drive inference (and any tools wired to it) with no auth.',
|
|
257
|
+
remediation: 'Remove share=True for anything beyond a throwaway demo. Bind to localhost or put the app behind authenticated ingress; never expose a tool-enabled agent this way.',
|
|
258
|
+
cwe: 'CWE-668',
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
id: 'python.hardcoded_ai_key',
|
|
262
|
+
title: 'Hardcoded AI-provider API key',
|
|
263
|
+
severity: 'MEDIUM',
|
|
264
|
+
category: 'secret',
|
|
265
|
+
confidence: 0.85,
|
|
266
|
+
// Provider key prefixes embedded as string literals: Anthropic (sk-ant-),
|
|
267
|
+
// OpenAI (sk-), HuggingFace (hf_), Google (AIza), Groq (gsk_).
|
|
268
|
+
re: /['"](sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9]{20,}|hf_[A-Za-z0-9]{20,}|AIza[A-Za-z0-9_-]{20,}|gsk_[A-Za-z0-9]{20,})['"]/,
|
|
269
|
+
sink: (m) => m[1].slice(0, 12) + '…',
|
|
270
|
+
source: 'source literal',
|
|
271
|
+
message: 'An AI-provider API key is hardcoded as a string literal. Anyone with read access to this repo can drain the account; committed keys are scraped within minutes.',
|
|
272
|
+
remediation: 'Remove the literal and load the key from an environment variable / secret manager at runtime. Rotate the exposed key immediately.',
|
|
273
|
+
cwe: 'CWE-798',
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
id: 'python.env_exfil',
|
|
277
|
+
title: 'Reads environment / secrets',
|
|
278
|
+
severity: 'MEDIUM',
|
|
279
|
+
category: 'secret',
|
|
280
|
+
confidence: 0.5,
|
|
281
|
+
re: /\bos\.environ\b|\bos\.getenv\s*\(|\bParameterStore|\bboto3\.client\s*\(\s*['"]s(ts|ecretsmanager)/,
|
|
282
|
+
sink: (m) => m[0].trim(),
|
|
283
|
+
source: 'process environment',
|
|
284
|
+
message: 'Reads environment variables or a secrets store. Paired with network egress this is credential exfiltration.',
|
|
285
|
+
remediation: 'Confirm the code has a legitimate need for the variable; model inference code generally should not read the environment.',
|
|
286
|
+
cwe: 'CWE-200',
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
// ── JavaScript / TypeScript rules ─────────────────────────────────
|
|
291
|
+
const JS_RULES = [
|
|
292
|
+
{
|
|
293
|
+
id: 'js.code_exec',
|
|
294
|
+
title: 'Dynamic code execution',
|
|
295
|
+
severity: 'CRITICAL',
|
|
296
|
+
category: 'code-exec',
|
|
297
|
+
confidence: 0.85,
|
|
298
|
+
re: /(?<![.\w])eval\s*\(|\bnew\s+Function\s*\(|\bvm\.(runInContext|runInNewContext|runInThisContext|compileFunction)\s*\(|\bnew\s+vm\.Script\s*\(|\b(setTimeout|setInterval)\s*\(\s*['"`]/,
|
|
299
|
+
sink: (m) => m[0].replace(/\s*\($/, '').replace(/\s*\(\s*['"`]$/, '').trim(),
|
|
300
|
+
source: 'tool input / model output',
|
|
301
|
+
message: 'Runs a string as code via eval / new Function / vm / a string-valued setTimeout|setInterval. In an MCP server or agent tool this turns any attacker-influenced string into host code execution.',
|
|
302
|
+
remediation: 'Never eval strings. Parse structured input explicitly (JSON.parse) and dispatch on a fixed allowlist of handlers.',
|
|
303
|
+
cwe: 'CWE-94',
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
id: 'js.command_exec',
|
|
307
|
+
title: 'Shell / process execution',
|
|
308
|
+
severity: 'CRITICAL',
|
|
309
|
+
category: 'code-exec',
|
|
310
|
+
confidence: 0.8,
|
|
311
|
+
re: /\bchild_process\b|require\(\s*['"]child_process['"]\s*\)|\bfrom\s+['"]child_process['"]|\b(execSync|execFileSync|spawnSync|execFile)\s*\(/,
|
|
312
|
+
sink: (m) => m[0].trim(),
|
|
313
|
+
source: 'tool input / model output',
|
|
314
|
+
message: 'Spawns a shell or child process. If any argument derives from tool input or model output this is command injection / RCE in the agent host.',
|
|
315
|
+
remediation: 'Avoid shelling out. If unavoidable, use execFile with a fixed binary and an argument array (never a shell string), and validate every argument.',
|
|
316
|
+
cwe: 'CWE-78',
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
id: 'js.decode_and_run',
|
|
320
|
+
title: 'Encoded payload decode-and-run',
|
|
321
|
+
severity: 'CRITICAL',
|
|
322
|
+
category: 'obfuscation',
|
|
323
|
+
confidence: 0.85,
|
|
324
|
+
re: /(?<![.\w])(eval|Function)\s*\(\s*(atob|unescape|decodeURIComponent|Buffer\.from)\b/,
|
|
325
|
+
sink: (m) => m[0].replace(/\s*$/, '').trim(),
|
|
326
|
+
message: 'Decodes an encoded string and immediately executes it — the packer pattern used to hide malicious code inside an otherwise innocuous-looking tool.',
|
|
327
|
+
remediation: 'Decode the blob offline and inspect it. Remove any decode-and-execute path from shipped tool code.',
|
|
328
|
+
cwe: 'CWE-506',
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
id: 'js.dynamic_require',
|
|
332
|
+
title: 'Dynamic / obfuscated module load',
|
|
333
|
+
severity: 'HIGH',
|
|
334
|
+
category: 'obfuscation',
|
|
335
|
+
confidence: 0.6,
|
|
336
|
+
re: /(?<![.\w])require\s*\(\s*[^'"\s)]|(?<![.\w])import\s*\(\s*[^'"\s)]/,
|
|
337
|
+
sink: (m) => m[0].trim(),
|
|
338
|
+
message: 'Loads a module chosen at runtime rather than a string literal, often to conceal which dangerous module is imported.',
|
|
339
|
+
remediation: 'Import modules by string literal so the dependency is statically reviewable; remove runtime-computed requires.',
|
|
340
|
+
cwe: 'CWE-829',
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
id: 'js.network_egress',
|
|
344
|
+
title: 'Network egress from tool code',
|
|
345
|
+
severity: 'HIGH',
|
|
346
|
+
category: 'egress',
|
|
347
|
+
confidence: 0.6,
|
|
348
|
+
re: /\baxios\s*\.\s*(get|post|put|request)\s*\(|\bhttps?\.request\s*\(|\bnet\.(connect|createConnection)\s*\(|\bnew\s+WebSocket\s*\(|require\(\s*['"](node-fetch|got|undici|axios)['"]/,
|
|
349
|
+
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
350
|
+
source: 'network',
|
|
351
|
+
message: 'Opens an outbound connection from tool code. Paired with reads of secrets or files this is the exfiltration / second-stage-download shape.',
|
|
352
|
+
remediation: 'Confirm the destination is expected and necessary; agent tools should not phone out to arbitrary hosts.',
|
|
353
|
+
cwe: 'CWE-913',
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
id: 'js.hardcoded_ai_key',
|
|
357
|
+
title: 'Hardcoded AI-provider API key',
|
|
358
|
+
severity: 'MEDIUM',
|
|
359
|
+
category: 'secret',
|
|
360
|
+
confidence: 0.85,
|
|
361
|
+
re: /['"](sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9]{20,}|hf_[A-Za-z0-9]{20,}|AIza[A-Za-z0-9_-]{20,}|gsk_[A-Za-z0-9]{20,})['"]/,
|
|
362
|
+
sink: (m) => m[1].slice(0, 12) + '…',
|
|
363
|
+
source: 'source literal',
|
|
364
|
+
message: 'An AI-provider API key is hardcoded as a string literal. Anyone with read access to this repo can drain the account; committed keys are scraped within minutes.',
|
|
365
|
+
remediation: 'Remove the literal and load the key from an environment variable / secret manager at runtime. Rotate the exposed key immediately.',
|
|
366
|
+
cwe: 'CWE-798',
|
|
367
|
+
},
|
|
368
|
+
];
|
|
369
|
+
|
|
370
|
+
// ── config.json rules (auto_map → trust_remote_code target) ───────
|
|
371
|
+
const CONFIG_RULES = [
|
|
372
|
+
{
|
|
373
|
+
id: 'json.automodel_usage',
|
|
374
|
+
title: 'AutoModel bound to remote code',
|
|
375
|
+
severity: 'HIGH',
|
|
376
|
+
category: 'remote-code',
|
|
377
|
+
confidence: 0.8,
|
|
378
|
+
re: /"(AutoModel[A-Za-z]*|AutoConfig)"\s*:\s*"([^"]+)"/,
|
|
379
|
+
sink: (m) => `auto_map.${m[1]}`,
|
|
380
|
+
message: 'config.json maps an Auto* class to a class shipped in this repo. Loading the model with trust_remote_code imports and runs that code before any weights.',
|
|
381
|
+
remediation: 'Do not use the AutoModel path for this repo. Pin revision= to a reviewed commit, or load a model with native transformers support.',
|
|
382
|
+
cwe: 'CWE-829',
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
id: 'json.autotokenizer_usage',
|
|
386
|
+
title: 'AutoTokenizer bound to remote code',
|
|
387
|
+
severity: 'HIGH',
|
|
388
|
+
category: 'remote-code',
|
|
389
|
+
confidence: 0.8,
|
|
390
|
+
re: /"(AutoTokenizer|AutoProcessor|AutoFeatureExtractor|AutoImageProcessor)"\s*:\s*"([^"]+)"/,
|
|
391
|
+
sink: (m) => `auto_map.${m[1]}`,
|
|
392
|
+
message: 'config maps a tokenizer/processor class to repo-shipped code, executed under trust_remote_code when the tokenizer loads.',
|
|
393
|
+
remediation: 'Review the referenced tokenizer code before loading; prefer a model whose tokenizer ships with transformers.',
|
|
394
|
+
cwe: 'CWE-829',
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
id: 'json.trust_remote_code',
|
|
398
|
+
title: 'Config declares trust_remote_code',
|
|
399
|
+
severity: 'HIGH',
|
|
400
|
+
category: 'remote-code',
|
|
401
|
+
confidence: 0.85,
|
|
402
|
+
re: /"trust_remote_code"\s*:\s*true/i,
|
|
403
|
+
sink: () => '"trust_remote_code": true',
|
|
404
|
+
message: 'The config pins trust_remote_code on, so any loader that honours it (transformers, sentence-transformers) will import and run the repo\'s custom code without the caller opting in.',
|
|
405
|
+
remediation: 'Remove the trust_remote_code flag from the config and require callers to opt in explicitly against a reviewed, pinned revision.',
|
|
406
|
+
cwe: 'CWE-94',
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
id: 'json.custom_pipeline',
|
|
410
|
+
title: 'Config binds a custom pipeline to remote code',
|
|
411
|
+
severity: 'HIGH',
|
|
412
|
+
category: 'remote-code',
|
|
413
|
+
confidence: 0.8,
|
|
414
|
+
re: /"custom_pipelines?"\s*:\s*[{"]/,
|
|
415
|
+
sink: (m) => m[0].replace(/\s*:\s*[{"]$/, '').trim(),
|
|
416
|
+
message: 'config.json declares a custom_pipeline, which binds the pipeline loader to code shipped in this repo — executed under trust_remote_code, before any weights, exactly like auto_map.',
|
|
417
|
+
remediation: 'Remove the custom_pipeline entry, or pin revision= to a reviewed commit and read the referenced pipeline code before loading.',
|
|
418
|
+
cwe: 'CWE-829',
|
|
419
|
+
},
|
|
420
|
+
];
|
|
421
|
+
|
|
422
|
+
// ── Taint tier config: LLM output → code-execution sink ────────────
|
|
423
|
+
// Per-language patterns for the dataflow pass. `aiCall` marks a variable tainted
|
|
424
|
+
// when its RHS is an LLM/model call; `execSink` is the dangerous consumer. A
|
|
425
|
+
// tainted value reaching a sink is prompt-injection → RCE.
|
|
426
|
+
const PY_TAINT = {
|
|
427
|
+
lang: 'python',
|
|
428
|
+
ruleId: 'python.llm_output_to_sink',
|
|
429
|
+
aiCall: /\.(a?generate|a?predict|a?invoke|a?run|complete|acomplete|chat|stream|__call__|predict_messages)\s*\(|\.(chat\.)?completions\.create\s*\(|\.messages\.create\s*\(|\bllm\s*\(/,
|
|
430
|
+
execSink: /(?<![.\w])(eval|exec|compile)\s*\(|\bos\.(system|popen)\s*\(|\bsubprocess\.(run|call|check_output|check_call|Popen)\s*\(/g,
|
|
431
|
+
};
|
|
432
|
+
const JS_TAINT = {
|
|
433
|
+
lang: 'js',
|
|
434
|
+
ruleId: 'js.llm_output_to_sink',
|
|
435
|
+
aiCall: /\.(generate|invoke|run|complete|stream|predict|call)\s*\(|\.chat\.completions\.create\s*\(|\.messages\.create\s*\(|\.create(Chat)?Completion\s*\(/,
|
|
436
|
+
execSink: /(?<![.\w])eval\s*\(|\bnew\s+Function\s*\(|\b(execSync|execFileSync|spawnSync|execFile|exec|spawn)\s*\(|\bvm\.\w+\s*\(/g,
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// ── Chain tier config: two co-occurring signals → one synthesised finding ──
|
|
440
|
+
const CHAINS = [
|
|
441
|
+
{
|
|
442
|
+
id: 'chain.decode_exec',
|
|
443
|
+
title: 'Decode-and-execute packer (multi-signal)',
|
|
444
|
+
severity: 'CRITICAL',
|
|
445
|
+
category: 'chain',
|
|
446
|
+
confidence: 0.8,
|
|
447
|
+
// an encoded-blob decode AND a code-exec sink in the same file
|
|
448
|
+
parts: ['python.encoded_payload', 'js.decode_and_run', 'python.dangerous_sinks', 'js.code_exec', 'python.dynamic_import'],
|
|
449
|
+
needs: (ids) => (ids.has('python.encoded_payload') || ids.has('js.decode_and_run')) &&
|
|
450
|
+
(ids.has('python.dangerous_sinks') || ids.has('js.code_exec') || ids.has('python.dynamic_import')),
|
|
451
|
+
anchor: ['python.dangerous_sinks', 'js.code_exec', 'python.encoded_payload', 'js.decode_and_run'],
|
|
452
|
+
message: 'This file both decodes an encoded blob and contains a code-execution sink — the two halves of a decode-then-run packer. Even split across lines, together they smuggle and execute a hidden payload.',
|
|
453
|
+
remediation: 'Decode every embedded blob offline and inspect it, and remove the decode → eval/exec path entirely. Do not ship artifacts that assemble code at runtime.',
|
|
454
|
+
cwe: 'CWE-506',
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
id: 'chain.remote_code_egress',
|
|
458
|
+
title: 'Remote-code model that also phones out',
|
|
459
|
+
severity: 'CRITICAL',
|
|
460
|
+
category: 'chain',
|
|
461
|
+
confidence: 0.8,
|
|
462
|
+
// remote-code loading AND network egress in the same file
|
|
463
|
+
parts: ['python.trust_remote_code', 'python.torch_remote_code', 'json.automodel_usage', 'json.autotokenizer_usage', 'python.network_egress', 'js.network_egress'],
|
|
464
|
+
needs: (ids) => (ids.has('python.trust_remote_code') || ids.has('python.torch_remote_code') ||
|
|
465
|
+
ids.has('json.automodel_usage') || ids.has('json.autotokenizer_usage')) &&
|
|
466
|
+
(ids.has('python.network_egress') || ids.has('js.network_egress')),
|
|
467
|
+
anchor: ['python.network_egress', 'js.network_egress', 'python.trust_remote_code', 'python.torch_remote_code'],
|
|
468
|
+
message: 'This file loads code shipped in a model repo (trust_remote_code / auto_map / torch.hub) AND opens a network connection. Remote-code modeling that also phones out is the classic staged-download / exfiltration shape.',
|
|
469
|
+
remediation: 'Do not load remote model code; use a native-transformers model or a reviewed, pinned revision. Model code should never make outbound requests — treat this artifact as hostile.',
|
|
470
|
+
cwe: 'CWE-494',
|
|
471
|
+
},
|
|
472
|
+
];
|
|
473
|
+
|
|
474
|
+
function contextChunk(lines, idx) {
|
|
475
|
+
const start = Math.max(0, idx - CONTEXT_RADIUS);
|
|
476
|
+
const end = Math.min(lines.length - 1, idx + CONTEXT_RADIUS);
|
|
477
|
+
const snippet = lines.slice(start, end + 1).join('\n').slice(0, MAX_SNIPPET);
|
|
478
|
+
return { snippet, snippetStartLine: start + 1 };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function isCommentLine(trimmed) {
|
|
482
|
+
return trimmed.startsWith('#') || trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Net bracket-depth change contributed by one physical line, plus whether it ends
|
|
487
|
+
* in a Python line-continuation backslash. Quote- and comment-aware so brackets
|
|
488
|
+
* inside string literals or after `#` / `//` don't skew the count.
|
|
489
|
+
*/
|
|
490
|
+
function lineDepthDelta(line) {
|
|
491
|
+
let delta = 0;
|
|
492
|
+
let quote = null;
|
|
493
|
+
for (let i = 0; i < line.length; i++) {
|
|
494
|
+
const c = line[i];
|
|
495
|
+
if (quote) {
|
|
496
|
+
if (c === '\\') { i++; continue; }
|
|
497
|
+
if (c === quote) quote = null;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (c === '"' || c === "'" || c === '`') { quote = c; continue; }
|
|
501
|
+
if (c === '#') break;
|
|
502
|
+
if (c === '/' && line[i + 1] === '/') break;
|
|
503
|
+
if (c === '(' || c === '[' || c === '{') delta++;
|
|
504
|
+
else if (c === ')' || c === ']' || c === '}') delta--;
|
|
505
|
+
}
|
|
506
|
+
return { delta, backslash: !quote && /\\\s*$/.test(line) };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Group physical lines into logical statements. A line continues the current
|
|
511
|
+
* statement while brackets stay open (call arguments / dicts / arrays that span
|
|
512
|
+
* lines) or it ends in a backslash. This is what lets one line-oriented regex
|
|
513
|
+
* match a call whose sink and its dangerous argument sit on DIFFERENT lines —
|
|
514
|
+
* e.g. `torch.load(\n ckpt,\n weights_only=False,\n)` — which a strict
|
|
515
|
+
* per-physical-line scan silently misses. Bounded by MAX_JOIN_LINES.
|
|
516
|
+
*/
|
|
517
|
+
function logicalLines(lines) {
|
|
518
|
+
const out = [];
|
|
519
|
+
let i = 0;
|
|
520
|
+
while (i < lines.length) {
|
|
521
|
+
const startLine = i + 1;
|
|
522
|
+
const buf = [];
|
|
523
|
+
let depth = 0;
|
|
524
|
+
while (i < lines.length) {
|
|
525
|
+
const line = lines[i];
|
|
526
|
+
buf.push(line);
|
|
527
|
+
const { delta, backslash } = lineDepthDelta(line);
|
|
528
|
+
depth += delta;
|
|
529
|
+
i++;
|
|
530
|
+
if ((depth <= 0 && !backslash) || buf.length >= MAX_JOIN_LINES) break;
|
|
531
|
+
}
|
|
532
|
+
out.push({ text: buf.join('\n'), startLine });
|
|
533
|
+
}
|
|
534
|
+
return out;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function escapeRe(s) {
|
|
538
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Map a match offset inside a logical unit back to a 0-based physical line. */
|
|
542
|
+
function physicalIdx(unit, offset) {
|
|
543
|
+
const newlines = unit.text.slice(0, offset).match(/\n/g);
|
|
544
|
+
return unit.startLine - 1 + (newlines ? newlines.length : 0);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Parse a leading assignment out of a logical unit: `x = …`, `const x = …`,
|
|
549
|
+
* `x: T = …`, or tuple unpacking `a, b = …`. Rejects `==`/`=>`/`>=` etc. Returns
|
|
550
|
+
* the assigned variable names and the RHS text, or null.
|
|
551
|
+
*/
|
|
552
|
+
function parseAssign(text) {
|
|
553
|
+
const m = /^\s*(?:export\s+)?(?:const|let|var|await\s+)?\s*([A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*)\s*(?::[^=\n]+?)?=(?![=>])\s*([\s\S]+)$/.exec(text);
|
|
554
|
+
if (!m) return null;
|
|
555
|
+
const vars = m[1].split(',').map((s) => s.trim()).filter(Boolean);
|
|
556
|
+
return { vars, rhs: m[2] };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Lightweight intra-file taint pass. Marks variables assigned from an LLM call as
|
|
561
|
+
* tainted, propagates that across simple assignments (bounded fixed point), then
|
|
562
|
+
* emits a CRITICAL finding wherever a tainted value flows into a code-exec sink —
|
|
563
|
+
* the prompt-injection → RCE shape a single-sink regex can't see.
|
|
564
|
+
*/
|
|
565
|
+
function taintFindings(lines, units, file, cfg) {
|
|
566
|
+
const tainted = new Set();
|
|
567
|
+
// Seed + propagate. Two passes cover simple multi-hop chains (resp → text → exec).
|
|
568
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
569
|
+
for (const unit of units) {
|
|
570
|
+
const a = parseAssign(unit.text);
|
|
571
|
+
if (!a) continue;
|
|
572
|
+
let taint = cfg.aiCall.test(a.rhs);
|
|
573
|
+
if (!taint) {
|
|
574
|
+
for (const t of tainted) {
|
|
575
|
+
if (new RegExp(`\\b${escapeRe(t)}\\b`).test(a.rhs)) { taint = true; break; }
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (taint) for (const v of a.vars) tainted.add(v);
|
|
579
|
+
}
|
|
580
|
+
if (!tainted.size) break;
|
|
581
|
+
}
|
|
582
|
+
if (!tainted.size) return [];
|
|
583
|
+
|
|
584
|
+
const out = [];
|
|
585
|
+
const seen = new Set();
|
|
586
|
+
for (const unit of units) {
|
|
587
|
+
cfg.execSink.lastIndex = 0;
|
|
588
|
+
let m;
|
|
589
|
+
while ((m = cfg.execSink.exec(unit.text))) {
|
|
590
|
+
// Argument region: from the sink's '(' to the end of the logical unit.
|
|
591
|
+
const paren = unit.text.indexOf('(', m.index);
|
|
592
|
+
const argRegion = paren >= 0 ? unit.text.slice(paren) : '';
|
|
593
|
+
let via = null;
|
|
594
|
+
for (const t of tainted) {
|
|
595
|
+
if (new RegExp(`\\b${escapeRe(t)}\\b`).test(argRegion)) { via = t; break; }
|
|
596
|
+
}
|
|
597
|
+
if (via) {
|
|
598
|
+
const idx = physicalIdx(unit, m.index);
|
|
599
|
+
const trimmed = (lines[idx] ?? '').trim();
|
|
600
|
+
if (trimmed && !isCommentLine(trimmed) && !seen.has(idx)) {
|
|
601
|
+
seen.add(idx);
|
|
602
|
+
const sink = m[0].replace(/\s*\($/, '').trim();
|
|
603
|
+
out.push({
|
|
604
|
+
ruleId: cfg.ruleId,
|
|
605
|
+
title: 'LLM output reaches a code-execution sink',
|
|
606
|
+
severity: 'CRITICAL',
|
|
607
|
+
category: 'taint',
|
|
608
|
+
confidence: 0.85,
|
|
609
|
+
file,
|
|
610
|
+
line: idx + 1,
|
|
611
|
+
sink: sink.slice(0, 120),
|
|
612
|
+
source: 'LLM output',
|
|
613
|
+
taint: `${via} (LLM output) → ${sink}`,
|
|
614
|
+
...contextChunk(lines, idx),
|
|
615
|
+
message: `A value derived from an LLM call ("${via}") flows into ${sink}. A prompt-injected instruction the model emits becomes code execution in the host — the highest-severity agent vulnerability.`,
|
|
616
|
+
remediation: 'Never pass model output to eval/exec/subprocess. Constrain the model to structured output (a fixed schema / tool-call allowlist), validate it, and dispatch on named handlers — never execute it.',
|
|
617
|
+
cwe: 'CWE-94',
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (!cfg.execSink.global) break;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return out;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Synthesise chain findings from co-occurring rule hits in one file. Conjunctions
|
|
629
|
+
* only (both halves independently suspicious), so no added false positives.
|
|
630
|
+
*/
|
|
631
|
+
function chainFindings(lines, findings, file) {
|
|
632
|
+
const ids = new Set(findings.map((f) => f.ruleId));
|
|
633
|
+
const out = [];
|
|
634
|
+
for (const chain of CHAINS) {
|
|
635
|
+
if (!chain.needs(ids)) continue;
|
|
636
|
+
// Anchor the synthesised finding on a real contributing line for the snippet.
|
|
637
|
+
const anchor = findings.find((f) => chain.anchor.includes(f.ruleId));
|
|
638
|
+
const line = anchor ? anchor.line : 1;
|
|
639
|
+
const idx = Math.max(0, line - 1);
|
|
640
|
+
out.push({
|
|
641
|
+
ruleId: chain.id,
|
|
642
|
+
title: chain.title,
|
|
643
|
+
severity: chain.severity,
|
|
644
|
+
category: chain.category,
|
|
645
|
+
confidence: chain.confidence,
|
|
646
|
+
file,
|
|
647
|
+
line,
|
|
648
|
+
sink: 'multi-signal',
|
|
649
|
+
chain: [...new Set(findings.filter((f) => chain.parts.includes(f.ruleId)).map((f) => f.ruleId))],
|
|
650
|
+
...contextChunk(lines, idx),
|
|
651
|
+
message: chain.message,
|
|
652
|
+
remediation: chain.remediation,
|
|
653
|
+
cwe: chain.cwe,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
return out;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Run each rule over the file, grouping physical lines into logical statements
|
|
661
|
+
* first so multi-line calls are matched, then layer the taint and chain tiers on
|
|
662
|
+
* top. The reported `line` is the exact physical line the sink lands on, so the
|
|
663
|
+
* numbered snippet still highlights the right row. Hits on pure comment lines are
|
|
664
|
+
* skipped.
|
|
665
|
+
*/
|
|
666
|
+
function scanLines(text, file, rules, taintCfg) {
|
|
667
|
+
const lines = text.split(/\r?\n/);
|
|
668
|
+
const units = logicalLines(lines);
|
|
669
|
+
const out = [];
|
|
670
|
+
const seen = new Set(); // dedupe by ruleId@line
|
|
671
|
+
for (const unit of units) {
|
|
672
|
+
for (const rule of rules) {
|
|
673
|
+
rule.re.lastIndex = 0;
|
|
674
|
+
const m = rule.re.exec(unit.text);
|
|
675
|
+
if (!m) continue;
|
|
676
|
+
const idx = physicalIdx(unit, m.index);
|
|
677
|
+
const trimmed = (lines[idx] ?? '').trim();
|
|
678
|
+
if (!trimmed || isCommentLine(trimmed)) continue;
|
|
679
|
+
const key = `${rule.id}@${idx}`;
|
|
680
|
+
if (seen.has(key)) continue;
|
|
681
|
+
seen.add(key);
|
|
682
|
+
out.push({
|
|
683
|
+
ruleId: rule.id,
|
|
684
|
+
title: rule.title,
|
|
685
|
+
severity: rule.severity,
|
|
686
|
+
category: rule.category,
|
|
687
|
+
confidence: rule.confidence,
|
|
688
|
+
file,
|
|
689
|
+
line: idx + 1,
|
|
690
|
+
sink: (rule.sink ? rule.sink(m) : m[0]).slice(0, 120),
|
|
691
|
+
source: rule.source || undefined,
|
|
692
|
+
...contextChunk(lines, idx),
|
|
693
|
+
message: rule.message,
|
|
694
|
+
remediation: rule.remediation,
|
|
695
|
+
cwe: rule.cwe,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (taintCfg) out.push(...taintFindings(lines, units, file, taintCfg));
|
|
700
|
+
out.push(...chainFindings(lines, out, file));
|
|
701
|
+
return out;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export function scanPythonSource(text, file) { return text ? scanLines(text, file, PY_RULES, PY_TAINT) : []; }
|
|
705
|
+
export function scanJsSource(text, file) { return text ? scanLines(text, file, JS_RULES, JS_TAINT) : []; }
|
|
706
|
+
export function scanModelConfig(text, file) { return text ? scanLines(text, file, CONFIG_RULES, null) : []; }
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Scan a Jupyter notebook (`.ipynb`, which is JSON). Notebooks ship executable
|
|
710
|
+
* code cells and are a first-class model-hub / agent delivery vector, but line
|
|
711
|
+
* numbers only make sense per cell, so each `code` cell is scanned on its own and
|
|
712
|
+
* tagged `<file>#cell<N>` with 1-based lines within that cell. The kernel language
|
|
713
|
+
* routes Python vs JS rules (default Python). Malformed JSON yields nothing rather
|
|
714
|
+
* than throwing, so one bad file never breaks a scan.
|
|
715
|
+
*/
|
|
716
|
+
export function scanNotebook(text, file) {
|
|
717
|
+
if (!text) return [];
|
|
718
|
+
let nb;
|
|
719
|
+
try { nb = JSON.parse(text); } catch { return []; }
|
|
720
|
+
const cells = Array.isArray(nb?.cells) ? nb.cells : [];
|
|
721
|
+
const lang = String(nb?.metadata?.kernelspec?.language || nb?.metadata?.language_info?.name || 'python').toLowerCase();
|
|
722
|
+
const isJs = /javascript|typescript|deno|node|^js$|^ts$/.test(lang);
|
|
723
|
+
const rules = isJs ? JS_RULES : PY_RULES;
|
|
724
|
+
const taintCfg = isJs ? JS_TAINT : PY_TAINT;
|
|
725
|
+
const out = [];
|
|
726
|
+
let codeCell = 0;
|
|
727
|
+
for (const cell of cells) {
|
|
728
|
+
if (cell?.cell_type !== 'code') continue;
|
|
729
|
+
codeCell++;
|
|
730
|
+
const src = Array.isArray(cell.source) ? cell.source.join('') : String(cell.source ?? '');
|
|
731
|
+
if (!src.trim()) continue;
|
|
732
|
+
out.push(...scanLines(src, `${file}#cell${codeCell}`, rules, taintCfg));
|
|
733
|
+
}
|
|
734
|
+
return out;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const PY_EXT = /\.py$/i;
|
|
738
|
+
const JS_EXT = /\.(m|c)?[jt]sx?$/i;
|
|
739
|
+
const NB_EXT = /\.ipynb$/i;
|
|
740
|
+
const MODEL_CONFIG_RE = /(^|\/)(config|tokenizer_config|generation_config|preprocessor_config)\.json$/i;
|
|
741
|
+
|
|
742
|
+
/** True when `path` is a source file one of the language rule sets can scan. */
|
|
743
|
+
export function isScannableSource(path) {
|
|
744
|
+
return PY_EXT.test(path) || JS_EXT.test(path) || NB_EXT.test(path);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** True when `path` is a HF-style model config the auto_map rules understand. */
|
|
748
|
+
export function isModelConfig(path) {
|
|
749
|
+
return MODEL_CONFIG_RE.test(String(path ?? '').split(/[\\/]+/).join('/'));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Route one file to the right rule set by name/extension and return its hits.
|
|
754
|
+
* Unknown files yield nothing.
|
|
755
|
+
*/
|
|
756
|
+
export function scanSourceFile(text, file) {
|
|
757
|
+
if (!text) return [];
|
|
758
|
+
if (NB_EXT.test(file)) return scanNotebook(text, file);
|
|
759
|
+
if (PY_EXT.test(file)) return scanPythonSource(text, file);
|
|
760
|
+
if (JS_EXT.test(file)) return scanJsSource(text, file);
|
|
761
|
+
if (isModelConfig(file)) return scanModelConfig(text, file);
|
|
762
|
+
return [];
|
|
763
|
+
}
|