@shomra/agent 0.3.16 → 0.3.18
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/NOTICE +1 -1
- package/README.md +57 -57
- package/package.json +3 -9
- package/shomra.mjs +9 -7168
- package/src/agents/hook-command.mjs +19 -0
- package/src/agents/hook-files.mjs +41 -0
- package/src/agents/installers.mjs +203 -0
- package/src/artifacts/matchers.mjs +59 -0
- package/src/artifacts/report.mjs +50 -0
- package/src/cli/flags.mjs +68 -0
- package/src/cli/help-sections.mjs +309 -0
- package/src/cli/help.mjs +27 -0
- package/src/cli/main.mjs +55 -0
- package/src/cli/registry.mjs +80 -0
- package/src/cli/suggestions.mjs +33 -0
- package/src/commands/add.mjs +149 -0
- package/src/commands/agent-identity.mjs +46 -0
- package/src/commands/check.mjs +194 -0
- package/src/commands/corpus.mjs +126 -0
- package/src/commands/design.mjs +168 -0
- package/src/commands/doctor.mjs +209 -0
- package/src/commands/fix.mjs +115 -0
- package/src/commands/gate.mjs +154 -0
- package/src/commands/git-hooks.mjs +163 -0
- package/src/commands/init.mjs +36 -0
- package/src/commands/install-hook.mjs +51 -0
- package/src/commands/llm-proxy.mjs +153 -0
- package/src/commands/mcp-add.mjs +185 -0
- package/src/commands/mcp.mjs +143 -0
- package/src/commands/memory-scan.mjs +181 -0
- package/src/commands/model-scan.mjs +99 -0
- package/src/commands/models.mjs +145 -0
- package/src/commands/new.mjs +64 -0
- package/src/commands/plan.mjs +87 -0
- package/src/commands/pr.mjs +249 -0
- package/src/commands/protect.mjs +38 -0
- package/src/commands/provenance.mjs +91 -0
- package/src/commands/redteam.mjs +166 -0
- package/src/commands/rules.mjs +220 -0
- package/src/commands/run.mjs +128 -0
- package/src/commands/scan-zip.mjs +118 -0
- package/src/commands/scan.mjs +102 -0
- package/src/commands/secrets.mjs +99 -0
- package/src/commands/status.mjs +50 -0
- package/src/commands/why.mjs +88 -0
- package/src/core/api-client.mjs +66 -0
- package/src/core/api-key.mjs +6 -0
- package/src/core/circuit-breaker.mjs +42 -0
- package/src/core/config.mjs +37 -0
- package/src/core/exit-codes.mjs +9 -0
- package/src/core/json-file.mjs +13 -0
- package/src/core/numbers.mjs +4 -0
- package/src/core/package-root.mjs +10 -0
- package/src/core/terminal.mjs +16 -0
- package/src/core/version.mjs +14 -0
- package/src/core/wire-limits.mjs +53 -0
- package/src/corpus/screening.mjs +127 -0
- package/{ai-usage.mjs → src/detect/ai-usage.mjs} +0 -27
- package/src/detect/code-sast.mjs +2 -0
- package/{design.mjs → src/detect/design.mjs} +18 -107
- package/src/detect/guard-signals.mjs +18 -0
- package/{model-refs.mjs → src/detect/model-refs.mjs} +18 -77
- package/src/detect/sast/chains.mjs +30 -0
- package/src/detect/sast/path-expressions.mjs +76 -0
- package/src/detect/sast/rules-chains.mjs +33 -0
- package/src/detect/sast/rules-config.mjs +51 -0
- package/src/detect/sast/rules-javascript.mjs +109 -0
- package/src/detect/sast/rules-python.mjs +292 -0
- package/src/detect/sast/scanner.mjs +104 -0
- package/src/detect/sast/source-lines.mjs +115 -0
- package/src/detect/sast/taint.mjs +71 -0
- package/src/detect/signals/artifacts.mjs +113 -0
- package/src/detect/signals/autonomy.mjs +55 -0
- package/src/detect/signals/config-markers.mjs +28 -0
- package/src/detect/signals/credential-harvest.mjs +64 -0
- package/src/detect/signals/durable-claims.mjs +73 -0
- package/src/detect/signals/egress.mjs +56 -0
- package/src/detect/signals/execution-hijack.mjs +128 -0
- package/src/detect/signals/gate.mjs +91 -0
- package/src/detect/signals/injection.mjs +55 -0
- package/src/detect/signals/lines.mjs +42 -0
- package/src/detect/signals/masking.mjs +99 -0
- package/src/detect/signals/memory.mjs +357 -0
- package/src/detect/signals/packages.mjs +45 -0
- package/src/detect/signals/propagation.mjs +86 -0
- package/src/detect/signals/prose-context.mjs +82 -0
- package/src/detect/signals/scan.mjs +91 -0
- package/src/detect/signals/secrets.mjs +85 -0
- package/src/detect/signals/sensitive.mjs +9 -0
- package/src/detect/signals/severity.mjs +10 -0
- package/src/detect/signals/shell.mjs +96 -0
- package/src/detect/signals/staged-fetch.mjs +66 -0
- package/src/detect/signals/text-match.mjs +35 -0
- package/src/gate/batch.mjs +157 -0
- package/src/gate/environment.mjs +122 -0
- package/src/gate/repo-policy.mjs +65 -0
- package/src/gate/result.mjs +53 -0
- package/src/gate/sarif.mjs +33 -0
- package/src/gate/sast.mjs +64 -0
- package/src/gate/suppressions.mjs +0 -0
- package/src/guard/classify.mjs +50 -0
- package/src/guard/emit.mjs +51 -0
- package/src/guard/ignore.mjs +24 -0
- package/src/guard/ledger.mjs +112 -0
- package/src/guard/model-load.mjs +50 -0
- package/src/guard/normalize.mjs +77 -0
- package/src/guard/options.mjs +10 -0
- package/src/guard/prompt-guard.mjs +184 -0
- package/src/guard/report.mjs +35 -0
- package/src/guard/result-guard.mjs +140 -0
- package/src/guard/tool-guard.mjs +166 -0
- package/src/inventory/agent-artifacts.mjs +5 -0
- package/src/inventory/agent-posture.mjs +249 -0
- package/src/inventory/artifacts/classify.mjs +27 -0
- package/src/inventory/artifacts/discover.mjs +187 -0
- package/src/inventory/artifacts/file-read.mjs +42 -0
- package/src/inventory/artifacts/hooks.mjs +14 -0
- package/src/inventory/artifacts/limits.mjs +37 -0
- package/src/inventory/artifacts/marketplaces.mjs +45 -0
- package/src/inventory/artifacts/roots.mjs +20 -0
- package/src/inventory/artifacts/walk.mjs +36 -0
- package/src/inventory/discovery/ai-dependencies.mjs +161 -0
- package/src/inventory/discovery/ai-tools.mjs +23 -0
- package/src/inventory/discovery/all.mjs +40 -0
- package/src/inventory/discovery/coding-agents.mjs +77 -0
- package/src/inventory/discovery/fs-read.mjs +36 -0
- package/src/inventory/discovery/local-runtimes.mjs +53 -0
- package/src/inventory/discovery/mcp-clients.mjs +67 -0
- package/src/inventory/discovery/mcp-servers.mjs +78 -0
- package/src/inventory/discovery/model-keys.mjs +97 -0
- package/src/inventory/discovery/platform.mjs +16 -0
- package/src/inventory/discovery/rules-files.mjs +25 -0
- package/src/inventory/discovery/vector-stores.mjs +176 -0
- package/src/inventory/discovery/workspace.mjs +124 -0
- package/src/inventory/discovery.mjs +10 -0
- package/src/mcp/child-process.mjs +50 -0
- package/src/mcp/config-wrapping.mjs +75 -0
- package/src/mcp/connect-gate.mjs +45 -0
- package/src/mcp/hosts.mjs +16 -0
- package/src/mcp/jsonrpc.mjs +48 -0
- package/src/mcp/lookup.mjs +50 -0
- package/src/mcp/screening.mjs +103 -0
- package/src/mcp/server-tools.mjs +97 -0
- package/src/mcp/server.mjs +102 -0
- package/src/mcp/shim.mjs +205 -0
- package/src/models/lookup.mjs +79 -0
- package/src/models/references.mjs +103 -0
- package/src/rules/context.mjs +98 -0
- package/src/rules/generate.mjs +103 -0
- package/src/rules/sections.mjs +145 -0
- package/src/scaffold/agent-project.mjs +185 -0
- package/src/scaffold/artifact-templates.mjs +35 -0
- package/code-sast.mjs +0 -1063
- package/discovery.mjs +0 -977
- package/guard-ledger.mjs +0 -239
- package/guard-signals.mjs +0 -1268
package/code-sast.mjs
DELETED
|
@@ -1,1063 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Local SAST rule engine for AI-artifact source code — a dependency-free port of
|
|
3
|
-
* the server-side engine, so the CLI can catch the same AI-vulnerability shapes
|
|
4
|
-
* ON-MACHINE (offline, pre-commit, in the IDE) that the model scan and workspace
|
|
5
|
-
* 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. Heuristic LLM-output propagation tier (NOT true dataflow) — marks a
|
|
25
|
-
* variable assigned from an LLM call (`.generate` / `.invoke` /
|
|
26
|
-
* `.completions.create` …) as tainted, propagates by NAME SUBSTRING across
|
|
27
|
-
* simple assignments, and raises an `*.llm_output_to_sink` finding when a
|
|
28
|
-
* tainted name appears in a code-execution sink's arguments. Surfaces the
|
|
29
|
-
* prompt-injection → RCE shape single-sink matching misses, but it is a
|
|
30
|
-
* low-confidence heuristic, not analysis: no scope, no reassignment/kill, no
|
|
31
|
-
* sanitizer awareness, no interprocedural tracking — it can over- and
|
|
32
|
-
* under-report. Findings are HIGH at low confidence with hedged wording.
|
|
33
|
-
*
|
|
34
|
-
* 3. Cross-signal chain tier — synthesises a finding when two independently
|
|
35
|
-
* suspicious signals co-occur in one file: encoded-payload + code-exec
|
|
36
|
-
* (`chain.decode_exec`), or remote-code loading + network egress
|
|
37
|
-
* (`chain.remote_code_egress`). Conjunctions only, so no added false positives.
|
|
38
|
-
*
|
|
39
|
-
* Findings carry a stable dotted rule id, the matched SINK, an optional SOURCE, a
|
|
40
|
-
* CWE, a category, a 0–1 confidence, the FILE + physical LINE and a context
|
|
41
|
-
* SNIPPET — the exact shape shomra.mjs folds into a gate result. The rule bodies
|
|
42
|
-
* mirror the server engine; drift only costs recall on the local floor, and the
|
|
43
|
-
* server remains the full check.
|
|
44
|
-
*/
|
|
45
|
-
|
|
46
|
-
const MAX_SNIPPET = 400;
|
|
47
|
-
const CONTEXT_RADIUS = 3;
|
|
48
|
-
/**
|
|
49
|
-
* Cap on how many physical lines one logical statement may absorb. Bounds the
|
|
50
|
-
* regex work and stops a single unbalanced-bracket line (or a minified blob)
|
|
51
|
-
* from swallowing the rest of the file into one giant unit.
|
|
52
|
-
*/
|
|
53
|
-
const MAX_JOIN_LINES = 40;
|
|
54
|
-
|
|
55
|
-
// ── Python rules ──────────────────────────────────────────────────
|
|
56
|
-
const PY_RULES = [
|
|
57
|
-
{
|
|
58
|
-
id: 'python.dangerous_sinks',
|
|
59
|
-
title: 'Dangerous code-execution sink',
|
|
60
|
-
severity: 'CRITICAL',
|
|
61
|
-
category: 'code-exec',
|
|
62
|
-
confidence: 0.85,
|
|
63
|
-
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__/,
|
|
64
|
-
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
65
|
-
source: 'model load / forward()',
|
|
66
|
-
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.',
|
|
67
|
-
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.',
|
|
68
|
-
cwe: 'CWE-94',
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
id: 'python.pickle_deserialization',
|
|
72
|
-
title: 'Unsafe deserialization',
|
|
73
|
-
severity: 'CRITICAL',
|
|
74
|
-
category: 'deserialization',
|
|
75
|
-
confidence: 0.9,
|
|
76
|
-
// Pickle-backed loaders across the ML stack: raw pickle/dill/cloudpickle/
|
|
77
|
-
// jsonpickle, torch.load, joblib/skops, numpy allow_pickle, yaml.load without a
|
|
78
|
-
// safe Loader, shelve, pandas.read_pickle, mlflow.*.load_model. All run
|
|
79
|
-
// __reduce__ / arbitrary code on a crafted file the moment they load.
|
|
80
|
-
re: /\b(pickle|cpickle|dill|_pickle|cloudpickle)\.(loads?|Unpickler)\s*\(|\bjsonpickle\.(decode|loads)\s*\(|\bmarshal\.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*\(/,
|
|
81
|
-
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
82
|
-
source: 'weight / config file',
|
|
83
|
-
message: 'Deserializes data with a pickle-backed loader. A crafted file runs arbitrary code via __reduce__ on load — the primary model-hub malware vector.',
|
|
84
|
-
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.',
|
|
85
|
-
cwe: 'CWE-502',
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
id: 'python.trust_remote_code',
|
|
89
|
-
title: 'Model loaded with trust_remote_code',
|
|
90
|
-
severity: 'CRITICAL',
|
|
91
|
-
category: 'remote-code',
|
|
92
|
-
confidence: 0.95,
|
|
93
|
-
// codeOnly: keyword appears in docstrings / log warnings / usage examples
|
|
94
|
-
// (e.g. Falcon's "load without the trust_remote_code=True argument").
|
|
95
|
-
codeOnly: true,
|
|
96
|
-
re: /trust_remote_code\s*=\s*True/,
|
|
97
|
-
sink: () => 'trust_remote_code=True',
|
|
98
|
-
source: 'model repository',
|
|
99
|
-
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.',
|
|
100
|
-
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.',
|
|
101
|
-
cwe: 'CWE-94',
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
id: 'python.dataset_remote_code',
|
|
105
|
-
title: 'Dataset loaded with trust_remote_code',
|
|
106
|
-
severity: 'CRITICAL',
|
|
107
|
-
category: 'remote-code',
|
|
108
|
-
confidence: 0.9,
|
|
109
|
-
codeOnly: true,
|
|
110
|
-
// datasets.load_dataset(..., trust_remote_code=True) runs the dataset's own
|
|
111
|
-
// Python loading script — a DISTINCT RCE vector from model trust_remote_code.
|
|
112
|
-
re: /\bload_dataset\s*\((?=[^)]*trust_remote_code\s*=\s*True)/,
|
|
113
|
-
sink: () => 'load_dataset(trust_remote_code=True)',
|
|
114
|
-
source: 'dataset repository',
|
|
115
|
-
message: 'Loads a Hugging Face dataset with trust_remote_code=True, which imports and runs the dataset\'s Python loading script in the host process — arbitrary code execution from the dataset publisher.',
|
|
116
|
-
remediation: 'Remove trust_remote_code=True. Use a non-script dataset format (Parquet/Arrow/CSV/JSON), or pin revision= to a reviewed commit and read the loading script first.',
|
|
117
|
-
cwe: 'CWE-94',
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
id: 'python.native_code_load',
|
|
121
|
-
title: 'Loads a native library (ctypes)',
|
|
122
|
-
severity: 'HIGH',
|
|
123
|
-
category: 'code-exec',
|
|
124
|
-
confidence: 0.85,
|
|
125
|
-
re: /\bctypes\.(CDLL|WinDLL|OleDLL|PyDLL|cdll|windll|oledll)\b|\bcdll\.LoadLibrary\s*\(|\bwindll\.LoadLibrary\s*\(/,
|
|
126
|
-
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
127
|
-
source: 'native library',
|
|
128
|
-
message: 'Loads a native/shared library via ctypes. Model code has no reason to dlopen libc or an arbitrary .so/.dll — it is an execution primitive (e.g. ctypes.CDLL("libc.so.6").system(cmd)).',
|
|
129
|
-
remediation: 'Remove the ctypes native-library load from model/loader code. Treat any model that dlopens libraries on load as hostile.',
|
|
130
|
-
cwe: 'CWE-94',
|
|
131
|
-
},
|
|
132
|
-
{
|
|
133
|
-
id: 'python.dynamic_file_exec',
|
|
134
|
-
title: 'Loads and executes a Python file at runtime',
|
|
135
|
-
severity: 'HIGH',
|
|
136
|
-
category: 'code-exec',
|
|
137
|
-
confidence: 0.85,
|
|
138
|
-
re: /\bSourceFileLoader\s*\(|\bspec_from_file_location\s*\(|\bimp\.load_source\s*\(|\bexec_module\s*\(/,
|
|
139
|
-
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
140
|
-
source: 'file path',
|
|
141
|
-
message: 'Imports and executes a Python file from a path at runtime (SourceFileLoader / spec_from_file_location / imp.load_source). This runs code that is not statically visible as an import — a common way to hide an execution path.',
|
|
142
|
-
remediation: 'Remove runtime file-based module loading from model code. Import only reviewed, statically-visible modules.',
|
|
143
|
-
cwe: 'CWE-94',
|
|
144
|
-
},
|
|
145
|
-
{
|
|
146
|
-
id: 'python.torch_remote_code',
|
|
147
|
-
title: 'Loads/executes remote or native code via torch',
|
|
148
|
-
severity: 'CRITICAL',
|
|
149
|
-
category: 'remote-code',
|
|
150
|
-
confidence: 0.9,
|
|
151
|
-
re: /\btorch\.hub\.load\s*\(|\btorch\.hub\.load_state_dict_from_url\s*\(|\btorch\.package\.PackageImporter\s*\(|\btorch\.classes\.load_library\s*\(/,
|
|
152
|
-
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
153
|
-
source: 'remote repo / packaged code',
|
|
154
|
-
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.',
|
|
155
|
-
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.',
|
|
156
|
-
cwe: 'CWE-494',
|
|
157
|
-
},
|
|
158
|
-
{
|
|
159
|
-
id: 'python.weights_only_false',
|
|
160
|
-
title: 'torch.load with weights_only=False',
|
|
161
|
-
severity: 'CRITICAL',
|
|
162
|
-
category: 'deserialization',
|
|
163
|
-
confidence: 0.95,
|
|
164
|
-
re: /\btorch\.load\s*\([^)]*weights_only\s*=\s*False/,
|
|
165
|
-
sink: () => 'torch.load(..., weights_only=False)',
|
|
166
|
-
source: 'weight file',
|
|
167
|
-
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.',
|
|
168
|
-
remediation: 'Remove weights_only=False (let it default to True), or load from safetensors. Only ever disable it for a checkpoint you built yourself.',
|
|
169
|
-
cwe: 'CWE-502',
|
|
170
|
-
},
|
|
171
|
-
{
|
|
172
|
-
id: 'python.keras_unsafe_load',
|
|
173
|
-
title: 'Keras load with safe_mode disabled',
|
|
174
|
-
severity: 'HIGH',
|
|
175
|
-
category: 'deserialization',
|
|
176
|
-
confidence: 0.85,
|
|
177
|
-
re: /\bsafe_mode\s*=\s*False/,
|
|
178
|
-
sink: () => 'safe_mode=False',
|
|
179
|
-
source: 'model file',
|
|
180
|
-
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).',
|
|
181
|
-
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).',
|
|
182
|
-
cwe: 'CWE-502',
|
|
183
|
-
},
|
|
184
|
-
{
|
|
185
|
-
id: 'python.langchain_code_exec',
|
|
186
|
-
title: 'LLM-driven code-execution component',
|
|
187
|
-
severity: 'HIGH',
|
|
188
|
-
category: 'agentic',
|
|
189
|
-
confidence: 0.85,
|
|
190
|
-
// Agent-framework components that run LLM-generated code (exec/eval on model
|
|
191
|
-
// output). LangChain (PythonREPL, PAL/CPAL, LLMMathChain), LlamaIndex
|
|
192
|
-
// (PandasQueryEngine, PandasInstructionParser, CodeInterpreterToolSpec),
|
|
193
|
-
// smolagents (CodeAgent, LocalPythonExecutor), plus load_tools() wiring a
|
|
194
|
-
// python_repl/terminal/shell tool. Distinctive names → near-zero FP; any of
|
|
195
|
-
// them reached by untrusted model output is RCE in the agent host.
|
|
196
|
-
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)/,
|
|
197
|
-
sink: (m) => m[0].trim(),
|
|
198
|
-
source: 'LLM output',
|
|
199
|
-
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).',
|
|
200
|
-
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.',
|
|
201
|
-
cwe: 'CWE-94',
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
id: 'python.autogen_local_exec',
|
|
205
|
-
title: 'Agent executes LLM-written code locally',
|
|
206
|
-
severity: 'HIGH',
|
|
207
|
-
category: 'agentic',
|
|
208
|
-
confidence: 0.75,
|
|
209
|
-
// AutoGen / ag2 executes code the LLM writes. A dict code_execution_config
|
|
210
|
-
// (rather than False) enables it; use_docker=False forces it to run on the
|
|
211
|
-
// host instead of an isolated container — LLM-authored code as host RCE.
|
|
212
|
-
re: /code_execution_config\s*=\s*\{|use_docker\s*=\s*False/,
|
|
213
|
-
sink: (m) => m[0].replace(/\s*=\s*\{$/, '').trim(),
|
|
214
|
-
source: 'LLM output',
|
|
215
|
-
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.',
|
|
216
|
-
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.',
|
|
217
|
-
cwe: 'CWE-94',
|
|
218
|
-
},
|
|
219
|
-
{
|
|
220
|
-
id: 'python.langchain_serialized_load',
|
|
221
|
-
title: 'Loads a serialized chain / prompt / agent',
|
|
222
|
-
severity: 'HIGH',
|
|
223
|
-
category: 'deserialization',
|
|
224
|
-
confidence: 0.75,
|
|
225
|
-
// LangChain load_chain/load_prompt/load_agent deserialize a JSON/YAML config
|
|
226
|
-
// that can instantiate arbitrary classes; hub.pull fetches a remote prompt/
|
|
227
|
-
// chain object. A poisoned artifact becomes code at construction time.
|
|
228
|
-
re: /\b(load_chain|load_prompt|load_agent)\s*\(|\bhub\.pull\s*\(/,
|
|
229
|
-
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
230
|
-
source: 'serialized chain / hub',
|
|
231
|
-
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.',
|
|
232
|
-
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.',
|
|
233
|
-
cwe: 'CWE-502',
|
|
234
|
-
},
|
|
235
|
-
{
|
|
236
|
-
id: 'python.rag_unsafe_deser',
|
|
237
|
-
title: 'Unsafe vector-store / RAG deserialization',
|
|
238
|
-
severity: 'CRITICAL',
|
|
239
|
-
category: 'deserialization',
|
|
240
|
-
confidence: 0.9,
|
|
241
|
-
re: /allow_dangerous_deserialization\s*=\s*True|\bFAISS\.load_local\s*\(|\b(pickle|joblib)\.load\s*\([^)]*(index|faiss|embedding|vector|chroma)/i,
|
|
242
|
-
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
243
|
-
source: 'vector store / embedding index',
|
|
244
|
-
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.',
|
|
245
|
-
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.',
|
|
246
|
-
cwe: 'CWE-502',
|
|
247
|
-
},
|
|
248
|
-
{
|
|
249
|
-
id: 'python.mcp_client',
|
|
250
|
-
title: 'MCP client integration (untrusted tool-output ingress)',
|
|
251
|
-
// LOW capability twin of js.mcp_client: importing the MCP SDK client surface
|
|
252
|
-
// says this file acts as an MCP host that feeds server tool output to a model.
|
|
253
|
-
severity: 'LOW',
|
|
254
|
-
category: 'agentic',
|
|
255
|
-
confidence: 0.55,
|
|
256
|
-
// Precise: `mcp.client.*` and `from mcp.client…import` are unambiguous;
|
|
257
|
-
// `from mcp import` only counts WITH ClientSession (excludes server-authoring
|
|
258
|
-
// imports); bare ClientSession( is NOT matched (aiohttp.ClientSession FP).
|
|
259
|
-
re: /\bfrom\s+mcp\.client[\w.]*\s+import\b|\bimport\s+mcp\.client\b|\bmcp\.client\.\w+|\bfrom\s+mcp\s+import\b[^\n]*\bClientSession\b/,
|
|
260
|
-
codeOnly: true,
|
|
261
|
-
sink: (m) => m[0].trim(),
|
|
262
|
-
source: 'MCP server tool output',
|
|
263
|
-
message: 'Acts as an MCP client/host: opens a ClientSession to MCP servers and passes their tool descriptions and results back to a model. Every server it reaches is an untrusted-input ingress — a poisoned tool description or result can hijack the agent (prompt injection / tool poisoning).',
|
|
264
|
-
remediation: 'Pin exactly which MCP servers this client may connect to and run each through governance before trusting it. Treat all server output as untrusted data, never instructions, and screen it (runtime firewall) before it reaches the model.',
|
|
265
|
-
cwe: 'CWE-829',
|
|
266
|
-
},
|
|
267
|
-
{
|
|
268
|
-
id: 'python.reduce_payload',
|
|
269
|
-
title: 'Custom __reduce__ (pickle RCE gadget)',
|
|
270
|
-
severity: 'CRITICAL',
|
|
271
|
-
category: 'deserialization',
|
|
272
|
-
confidence: 0.8,
|
|
273
|
-
re: /def\s+__reduce__\s*\(|def\s+__reduce_ex__\s*\(|def\s+__setstate__\s*\(/,
|
|
274
|
-
sink: (m) => m[0].replace(/^def\s+/, '').replace(/\s*\($/, '').trim(),
|
|
275
|
-
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.',
|
|
276
|
-
remediation: 'Verify why the class needs custom pickling. Do not unpickle objects from this repo; prefer safetensors serialization which has no code path.',
|
|
277
|
-
cwe: 'CWE-502',
|
|
278
|
-
},
|
|
279
|
-
{
|
|
280
|
-
id: 'python.network_egress',
|
|
281
|
-
title: 'Network egress',
|
|
282
|
-
// ⚠ MEDIUM, and titled 'Network egress' NOT 'from model code' — in lockstep
|
|
283
|
-
// with checks/code-sast.ts (python.network_egress). This rule fires on ANY
|
|
284
|
-
// `requests.get`/`httpx`/`socket`, and this SAST tier runs over EVERY source
|
|
285
|
-
// file in a repo, not just model loaders — so an ordinary RAG/app file that
|
|
286
|
-
// makes an outbound call (`benign/src/rag.py`) got a HIGH "from model code"
|
|
287
|
-
// finding for doing the most normal thing an application does. A bare
|
|
288
|
-
// outbound call is an informational capability standalone; it is DANGEROUS
|
|
289
|
-
// only chained with a remote-code load, which `chain.remote_code_egress`
|
|
290
|
-
// already escalates to CRITICAL. Drifting this back to HIGH re-breaks the
|
|
291
|
-
// parity the local-mirror bench exists to protect.
|
|
292
|
-
severity: 'MEDIUM',
|
|
293
|
-
category: 'egress',
|
|
294
|
-
confidence: 0.5,
|
|
295
|
-
re: /\b(requests|httpx)\.(get|post|put|request)\s*\(|\burllib\.request\.(urlopen|urlretrieve)\s*\(|\bsocket\.(socket|create_connection)\s*\(|\baiohttp\.ClientSession\s*\(/,
|
|
296
|
-
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
297
|
-
source: 'network',
|
|
298
|
-
message: 'Opens an outbound network connection. Normal in application code; in model/tokenizer code that should never phone out, or chained with a remote-code load, this is the exfiltration / second-stage-download shape.',
|
|
299
|
-
remediation: 'Confirm the destination and payload. Model inference code should never make outbound requests; treat a phoning-out model as hostile until proven otherwise.',
|
|
300
|
-
cwe: 'CWE-913',
|
|
301
|
-
},
|
|
302
|
-
{
|
|
303
|
-
id: 'python.dynamic_import',
|
|
304
|
-
title: 'Dynamic / obfuscated import',
|
|
305
|
-
severity: 'HIGH',
|
|
306
|
-
category: 'obfuscation',
|
|
307
|
-
confidence: 0.7,
|
|
308
|
-
re: /\bimportlib\.import_module\s*\(|\b__import__\s*\(\s*['"]?\s*(os|subprocess|socket|base64|marshal|ctypes)|\bexec\s*\(\s*(base64|bytes|marshal|codecs)/,
|
|
309
|
-
sink: (m) => m[0].trim(),
|
|
310
|
-
message: 'Imports or executes a module chosen at runtime, often to hide os/subprocess/socket usage from a quick read.',
|
|
311
|
-
remediation: 'Resolve what is imported and why. Obfuscated dynamic imports in model code are a strong malware tell.',
|
|
312
|
-
cwe: 'CWE-94',
|
|
313
|
-
},
|
|
314
|
-
{
|
|
315
|
-
id: 'python.encoded_payload',
|
|
316
|
-
title: 'Encoded blob decode',
|
|
317
|
-
// LOW on its own: base64/hex decode is ubiquitous & benign in ML (tokenizer
|
|
318
|
-
// vocabs, quant kernels). The dangerous decode→exec case is elevated to
|
|
319
|
-
// CRITICAL by chain.decode_exec. marshal.loads moved to pickle_deserialization.
|
|
320
|
-
severity: 'LOW',
|
|
321
|
-
category: 'obfuscation',
|
|
322
|
-
confidence: 0.5,
|
|
323
|
-
re: /\b(base64|codecs|binascii)\.(b64decode|decode|unhexlify)\s*\(|bytes\.fromhex\s*\(/,
|
|
324
|
-
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
325
|
-
message: 'Decodes an encoded blob. Benign on its own (common in tokenizers/quantization), but the "decode then run it" packer combines this with eval/exec — see any decode-and-run chain finding on this file.',
|
|
326
|
-
remediation: 'If this decode feeds eval/exec/import, decode the blob offline and inspect it. A lone decode of vocab/kernel data is expected.',
|
|
327
|
-
cwe: 'CWE-506',
|
|
328
|
-
},
|
|
329
|
-
{
|
|
330
|
-
id: 'python.gradio_public_share',
|
|
331
|
-
title: 'Model UI exposed via public share tunnel',
|
|
332
|
-
severity: 'MEDIUM',
|
|
333
|
-
category: 'exposure',
|
|
334
|
-
confidence: 0.8,
|
|
335
|
-
re: /\.launch\s*\([^)]*share\s*=\s*True|\.queue\s*\([^)]*\)\.launch\s*\([^)]*share\s*=\s*True/,
|
|
336
|
-
sink: () => 'launch(share=True)',
|
|
337
|
-
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.',
|
|
338
|
-
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.',
|
|
339
|
-
cwe: 'CWE-668',
|
|
340
|
-
},
|
|
341
|
-
{
|
|
342
|
-
id: 'python.hardcoded_ai_key',
|
|
343
|
-
title: 'Hardcoded AI-provider API key',
|
|
344
|
-
severity: 'MEDIUM',
|
|
345
|
-
category: 'secret',
|
|
346
|
-
confidence: 0.85,
|
|
347
|
-
// Provider key prefixes embedded as string literals: Anthropic (sk-ant-),
|
|
348
|
-
// OpenAI (sk-), HuggingFace (hf_), Google (AIza), Groq (gsk_).
|
|
349
|
-
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,})['"]/,
|
|
350
|
-
sink: (m) => m[1].slice(0, 12) + '…',
|
|
351
|
-
source: 'source literal',
|
|
352
|
-
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.',
|
|
353
|
-
remediation: 'Remove the literal and load the key from an environment variable / secret manager at runtime. Rotate the exposed key immediately.',
|
|
354
|
-
cwe: 'CWE-798',
|
|
355
|
-
},
|
|
356
|
-
{
|
|
357
|
-
id: 'python.env_exfil',
|
|
358
|
-
title: 'Reads environment / secrets',
|
|
359
|
-
severity: 'MEDIUM',
|
|
360
|
-
category: 'secret',
|
|
361
|
-
confidence: 0.5,
|
|
362
|
-
re: /\bos\.environ\b|\bos\.getenv\s*\(|\bParameterStore|\bboto3\.client\s*\(\s*['"]s(ts|ecretsmanager)/,
|
|
363
|
-
sink: (m) => m[0].trim(),
|
|
364
|
-
source: 'process environment',
|
|
365
|
-
message: 'Reads environment variables or a secrets store. Paired with network egress this is credential exfiltration.',
|
|
366
|
-
remediation: 'Confirm the code has a legitimate need for the variable; model inference code generally should not read the environment.',
|
|
367
|
-
cwe: 'CWE-200',
|
|
368
|
-
},
|
|
369
|
-
];
|
|
370
|
-
|
|
371
|
-
// ── JavaScript / TypeScript rules ─────────────────────────────────
|
|
372
|
-
const JS_RULES = [
|
|
373
|
-
{
|
|
374
|
-
id: 'js.code_exec',
|
|
375
|
-
title: 'Dynamic code execution',
|
|
376
|
-
severity: 'CRITICAL',
|
|
377
|
-
category: 'code-exec',
|
|
378
|
-
confidence: 0.85,
|
|
379
|
-
re: /(?<![.\w])eval\s*\(|\bnew\s+Function\s*\(|\bvm\.(runInContext|runInNewContext|runInThisContext|compileFunction)\s*\(|\bnew\s+vm\.Script\s*\(|\b(setTimeout|setInterval)\s*\(\s*['"`]/,
|
|
380
|
-
sink: (m) => m[0].replace(/\s*\($/, '').replace(/\s*\(\s*['"`]$/, '').trim(),
|
|
381
|
-
source: 'tool input / model output',
|
|
382
|
-
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.',
|
|
383
|
-
remediation: 'Never eval strings. Parse structured input explicitly (JSON.parse) and dispatch on a fixed allowlist of handlers.',
|
|
384
|
-
cwe: 'CWE-94',
|
|
385
|
-
},
|
|
386
|
-
{
|
|
387
|
-
id: 'js.command_exec',
|
|
388
|
-
title: 'Shell / process execution',
|
|
389
|
-
severity: 'CRITICAL',
|
|
390
|
-
category: 'code-exec',
|
|
391
|
-
confidence: 0.8,
|
|
392
|
-
re: /\bchild_process\b|require\(\s*['"]child_process['"]\s*\)|\bfrom\s+['"]child_process['"]|\b(execSync|execFileSync|spawnSync|execFile)\s*\(/,
|
|
393
|
-
sink: (m) => m[0].trim(),
|
|
394
|
-
source: 'tool input / model output',
|
|
395
|
-
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.',
|
|
396
|
-
remediation: 'Avoid shelling out. If unavoidable, use execFile with a fixed binary and an argument array (never a shell string), and validate every argument.',
|
|
397
|
-
cwe: 'CWE-78',
|
|
398
|
-
},
|
|
399
|
-
{
|
|
400
|
-
id: 'js.mcp_client',
|
|
401
|
-
title: 'MCP client integration (untrusted tool-output ingress)',
|
|
402
|
-
// LOW capability signal: the /client SDK entrypoint or a *ClientTransport says
|
|
403
|
-
// this file acts as an MCP host — it connects to servers and feeds their tool
|
|
404
|
-
// output to a model. That is the toxic-flow ingress; on its own it is a lead.
|
|
405
|
-
severity: 'LOW',
|
|
406
|
-
category: 'agentic',
|
|
407
|
-
confidence: 0.55,
|
|
408
|
-
// /client subpath + transport class names are unambiguous. `new Client(` is NOT
|
|
409
|
-
// matched — too many libs export a generic Client. Not codeOnly: the /client
|
|
410
|
-
// import path lives inside a require()/import string.
|
|
411
|
-
re: /@modelcontextprotocol\/sdk\/client|\b(StdioClientTransport|SSEClientTransport|StreamableHTTPClientTransport|WebSocketClientTransport)\b/,
|
|
412
|
-
sink: (m) => m[0].trim(),
|
|
413
|
-
source: 'MCP server tool output',
|
|
414
|
-
message: 'Acts as an MCP client/host: connects to MCP servers and passes their tool descriptions and results back to a model. Every server it reaches is an untrusted-input ingress — a poisoned tool description or result can hijack the agent (prompt injection / tool poisoning).',
|
|
415
|
-
remediation: 'Pin exactly which MCP servers this client may connect to and run each through governance before trusting it. Treat all server output as untrusted data, never instructions, and screen it (runtime firewall) before it reaches the model.',
|
|
416
|
-
cwe: 'CWE-829',
|
|
417
|
-
},
|
|
418
|
-
{
|
|
419
|
-
id: 'js.decode_and_run',
|
|
420
|
-
title: 'Encoded payload decode-and-run',
|
|
421
|
-
severity: 'CRITICAL',
|
|
422
|
-
category: 'obfuscation',
|
|
423
|
-
confidence: 0.85,
|
|
424
|
-
re: /(?<![.\w])(eval|Function)\s*\(\s*(atob|unescape|decodeURIComponent|Buffer\.from)\b/,
|
|
425
|
-
sink: (m) => m[0].replace(/\s*$/, '').trim(),
|
|
426
|
-
message: 'Decodes an encoded string and immediately executes it — the packer pattern used to hide malicious code inside an otherwise innocuous-looking tool.',
|
|
427
|
-
remediation: 'Decode the blob offline and inspect it. Remove any decode-and-execute path from shipped tool code.',
|
|
428
|
-
cwe: 'CWE-506',
|
|
429
|
-
},
|
|
430
|
-
{
|
|
431
|
-
id: 'js.dynamic_require',
|
|
432
|
-
title: 'Dynamic / obfuscated module load',
|
|
433
|
-
severity: 'HIGH',
|
|
434
|
-
category: 'obfuscation',
|
|
435
|
-
confidence: 0.6,
|
|
436
|
-
re: /(?<![.\w])require\s*\(\s*[^'"\s)]|(?<![.\w])import\s*\(\s*[^'"\s)]/,
|
|
437
|
-
// …but a path BUILT from literals and __dirname is a literal spelled across
|
|
438
|
-
// path.join — it conceals nothing. `require(path.join(__dirname,'..','generated','prisma'))`
|
|
439
|
-
// (a Prisma client import) was the shape that made this rule noisy. The CLI has
|
|
440
|
-
// no AST tier, so without this veto that FP lands at HIGH — a blocking severity.
|
|
441
|
-
suppress: (m, unitText, ctx) => {
|
|
442
|
-
const arg = callArgText(unitText, m.index);
|
|
443
|
-
return isNotAModuleLoad(m, unitText, arg) || isStaticPathExpr(arg, ctx.pathNs, ctx.constPaths);
|
|
444
|
-
},
|
|
445
|
-
sink: (m) => m[0].trim(),
|
|
446
|
-
message: 'Loads a module chosen at runtime rather than a string literal, often to conceal which dangerous module is imported.',
|
|
447
|
-
remediation: 'Import modules by string literal so the dependency is statically reviewable; remove runtime-computed requires.',
|
|
448
|
-
cwe: 'CWE-829',
|
|
449
|
-
},
|
|
450
|
-
{
|
|
451
|
-
id: 'js.network_egress',
|
|
452
|
-
title: 'Network egress',
|
|
453
|
-
// MEDIUM + neutral title, in lockstep with checks/code-sast.ts js.network_egress.
|
|
454
|
-
// Same reasoning as the python twin above: a bare outbound call is ordinary
|
|
455
|
-
// application behaviour, HIGH only when chained with a remote-code load.
|
|
456
|
-
severity: 'MEDIUM',
|
|
457
|
-
category: 'egress',
|
|
458
|
-
confidence: 0.5,
|
|
459
|
-
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)['"]/,
|
|
460
|
-
sink: (m) => m[0].replace(/\s*\($/, '').trim(),
|
|
461
|
-
source: 'network',
|
|
462
|
-
message: 'Opens an outbound network connection. Normal in application code; chained with a reads-secrets or remote-code-load finding this is the exfiltration / second-stage-download shape.',
|
|
463
|
-
remediation: 'Confirm the destination is expected and necessary; agent tools should not phone out to arbitrary hosts.',
|
|
464
|
-
cwe: 'CWE-913',
|
|
465
|
-
},
|
|
466
|
-
{
|
|
467
|
-
id: 'js.hardcoded_ai_key',
|
|
468
|
-
title: 'Hardcoded AI-provider API key',
|
|
469
|
-
severity: 'MEDIUM',
|
|
470
|
-
category: 'secret',
|
|
471
|
-
confidence: 0.85,
|
|
472
|
-
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,})['"]/,
|
|
473
|
-
sink: (m) => m[1].slice(0, 12) + '…',
|
|
474
|
-
source: 'source literal',
|
|
475
|
-
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.',
|
|
476
|
-
remediation: 'Remove the literal and load the key from an environment variable / secret manager at runtime. Rotate the exposed key immediately.',
|
|
477
|
-
cwe: 'CWE-798',
|
|
478
|
-
},
|
|
479
|
-
];
|
|
480
|
-
|
|
481
|
-
// ── config.json rules (auto_map → trust_remote_code target) ───────
|
|
482
|
-
const CONFIG_RULES = [
|
|
483
|
-
{
|
|
484
|
-
id: 'json.automodel_usage',
|
|
485
|
-
// MEDIUM, not HIGH: capability/posture fact (ships trust_remote_code code) —
|
|
486
|
-
// true of every legit custom model, so HIGH is alert fatigue. A real sink in
|
|
487
|
-
// the routed module is elevated to CRITICAL by chain.config_module_rce.
|
|
488
|
-
title: 'AutoModel bound to repo-shipped code (trust_remote_code)',
|
|
489
|
-
severity: 'MEDIUM',
|
|
490
|
-
category: 'remote-code',
|
|
491
|
-
confidence: 0.8,
|
|
492
|
-
re: /"(AutoModel[A-Za-z]*|AutoConfig)"\s*:\s*"([^"]+)"/,
|
|
493
|
-
sink: (m) => `auto_map.${m[1]}`,
|
|
494
|
-
message: 'config.json maps an Auto* class to code shipped in this repo. Loading with trust_remote_code imports and runs that code before any weights — review the publisher and the referenced module. (A dangerous sink in that module is reported separately at higher severity.)',
|
|
495
|
-
remediation: 'Review the referenced module before loading, pin revision= to a reviewed commit, or use a model with native transformers support.',
|
|
496
|
-
cwe: 'CWE-829',
|
|
497
|
-
},
|
|
498
|
-
{
|
|
499
|
-
id: 'json.autotokenizer_usage',
|
|
500
|
-
title: 'AutoTokenizer bound to remote code',
|
|
501
|
-
severity: 'HIGH',
|
|
502
|
-
category: 'remote-code',
|
|
503
|
-
confidence: 0.8,
|
|
504
|
-
re: /"(AutoTokenizer|AutoProcessor|AutoFeatureExtractor|AutoImageProcessor)"\s*:\s*"([^"]+)"/,
|
|
505
|
-
sink: (m) => `auto_map.${m[1]}`,
|
|
506
|
-
message: 'config maps a tokenizer/processor class to repo-shipped code, executed under trust_remote_code when the tokenizer loads.',
|
|
507
|
-
remediation: 'Review the referenced tokenizer code before loading; prefer a model whose tokenizer ships with transformers.',
|
|
508
|
-
cwe: 'CWE-829',
|
|
509
|
-
},
|
|
510
|
-
{
|
|
511
|
-
id: 'json.trust_remote_code',
|
|
512
|
-
title: 'Config declares trust_remote_code',
|
|
513
|
-
severity: 'HIGH',
|
|
514
|
-
category: 'remote-code',
|
|
515
|
-
confidence: 0.85,
|
|
516
|
-
re: /"trust_remote_code"\s*:\s*true/i,
|
|
517
|
-
sink: () => '"trust_remote_code": true',
|
|
518
|
-
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.',
|
|
519
|
-
remediation: 'Remove the trust_remote_code flag from the config and require callers to opt in explicitly against a reviewed, pinned revision.',
|
|
520
|
-
cwe: 'CWE-94',
|
|
521
|
-
},
|
|
522
|
-
{
|
|
523
|
-
id: 'json.custom_pipeline',
|
|
524
|
-
title: 'Config binds a custom pipeline to remote code',
|
|
525
|
-
severity: 'HIGH',
|
|
526
|
-
category: 'remote-code',
|
|
527
|
-
confidence: 0.8,
|
|
528
|
-
re: /"custom_pipelines?"\s*:\s*[{"]/,
|
|
529
|
-
sink: (m) => m[0].replace(/\s*:\s*[{"]$/, '').trim(),
|
|
530
|
-
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.',
|
|
531
|
-
remediation: 'Remove the custom_pipeline entry, or pin revision= to a reviewed commit and read the referenced pipeline code before loading.',
|
|
532
|
-
cwe: 'CWE-829',
|
|
533
|
-
},
|
|
534
|
-
];
|
|
535
|
-
|
|
536
|
-
// ── LLM-output propagation config: LLM output → code-execution sink ─────────
|
|
537
|
-
// Per-language patterns for the heuristic name-propagation pass (NOT dataflow).
|
|
538
|
-
// `aiCall` marks a variable tainted when its RHS is an LLM/model call; `execSink`
|
|
539
|
-
// is the dangerous consumer. A tainted name reaching a sink is the
|
|
540
|
-
// prompt-injection → RCE shape — reported low-confidence, to be confirmed.
|
|
541
|
-
const PY_TAINT = {
|
|
542
|
-
lang: 'python',
|
|
543
|
-
ruleId: 'python.llm_output_to_sink',
|
|
544
|
-
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*\(/,
|
|
545
|
-
execSink: /(?<![.\w])(eval|exec|compile)\s*\(|\bos\.(system|popen)\s*\(|\bsubprocess\.(run|call|check_output|check_call|Popen)\s*\(/g,
|
|
546
|
-
};
|
|
547
|
-
const JS_TAINT = {
|
|
548
|
-
lang: 'js',
|
|
549
|
-
ruleId: 'js.llm_output_to_sink',
|
|
550
|
-
aiCall: /\.(generate|invoke|run|complete|stream|predict|call)\s*\(|\.chat\.completions\.create\s*\(|\.messages\.create\s*\(|\.create(Chat)?Completion\s*\(/,
|
|
551
|
-
execSink: /(?<![.\w])eval\s*\(|\bnew\s+Function\s*\(|\b(execSync|execFileSync|spawnSync|execFile|exec|spawn)\s*\(|\bvm\.\w+\s*\(/g,
|
|
552
|
-
};
|
|
553
|
-
|
|
554
|
-
// ── Chain tier config: two co-occurring signals → one synthesised finding ──
|
|
555
|
-
const CHAINS = [
|
|
556
|
-
{
|
|
557
|
-
id: 'chain.decode_exec',
|
|
558
|
-
title: 'Decode-and-execute packer (multi-signal)',
|
|
559
|
-
severity: 'CRITICAL',
|
|
560
|
-
category: 'chain',
|
|
561
|
-
confidence: 0.6,
|
|
562
|
-
// an encoded-blob decode AND a code-exec sink in the same file
|
|
563
|
-
parts: ['python.encoded_payload', 'js.decode_and_run', 'python.dangerous_sinks', 'js.code_exec', 'python.dynamic_import'],
|
|
564
|
-
needs: (ids) => (ids.has('python.encoded_payload') || ids.has('js.decode_and_run')) &&
|
|
565
|
-
(ids.has('python.dangerous_sinks') || ids.has('js.code_exec') || ids.has('python.dynamic_import')),
|
|
566
|
-
anchor: ['python.dangerous_sinks', 'js.code_exec', 'python.encoded_payload', 'js.decode_and_run'],
|
|
567
|
-
message: 'This file BOTH decodes an encoded blob AND contains a code-execution sink — the two halves of a decode-then-run packer. Detected by co-occurrence in one file, not a proven decode→exec path, so confirm the decoded blob is what reaches the sink; when it is, this is how a hidden payload is smuggled and run.',
|
|
568
|
-
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.',
|
|
569
|
-
cwe: 'CWE-506',
|
|
570
|
-
},
|
|
571
|
-
{
|
|
572
|
-
id: 'chain.remote_code_egress',
|
|
573
|
-
title: 'Remote-code model that also phones out',
|
|
574
|
-
severity: 'CRITICAL',
|
|
575
|
-
category: 'chain',
|
|
576
|
-
confidence: 0.6,
|
|
577
|
-
// remote-code loading AND network egress in the same file
|
|
578
|
-
parts: ['python.trust_remote_code', 'python.torch_remote_code', 'json.automodel_usage', 'json.autotokenizer_usage', 'python.network_egress', 'js.network_egress'],
|
|
579
|
-
needs: (ids) => (ids.has('python.trust_remote_code') || ids.has('python.torch_remote_code') ||
|
|
580
|
-
ids.has('json.automodel_usage') || ids.has('json.autotokenizer_usage')) &&
|
|
581
|
-
(ids.has('python.network_egress') || ids.has('js.network_egress')),
|
|
582
|
-
anchor: ['python.network_egress', 'js.network_egress', 'python.trust_remote_code', 'python.torch_remote_code'],
|
|
583
|
-
message: 'This file loads code shipped in a model repo (trust_remote_code / auto_map / torch.hub) AND opens a network connection. Detected by co-occurrence in one file, not a proven load→egress path. Remote-code modeling that also phones out is the classic staged-download / exfiltration shape — confirm whether the network call is reachable from the model-code load.',
|
|
584
|
-
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.',
|
|
585
|
-
cwe: 'CWE-494',
|
|
586
|
-
},
|
|
587
|
-
];
|
|
588
|
-
|
|
589
|
-
function contextChunk(lines, idx) {
|
|
590
|
-
const start = Math.max(0, idx - CONTEXT_RADIUS);
|
|
591
|
-
const end = Math.min(lines.length - 1, idx + CONTEXT_RADIUS);
|
|
592
|
-
const snippet = lines.slice(start, end + 1).join('\n').slice(0, MAX_SNIPPET);
|
|
593
|
-
return { snippet, snippetStartLine: start + 1 };
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
function isCommentLine(trimmed) {
|
|
597
|
-
return trimmed.startsWith('#') || trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/**
|
|
601
|
-
* Net bracket-depth change contributed by one physical line, plus whether it ends
|
|
602
|
-
* in a Python line-continuation backslash. Quote- and comment-aware so brackets
|
|
603
|
-
* inside string literals or after `#` / `//` don't skew the count.
|
|
604
|
-
*/
|
|
605
|
-
function lineDepthDelta(line) {
|
|
606
|
-
let delta = 0;
|
|
607
|
-
let quote = null;
|
|
608
|
-
for (let i = 0; i < line.length; i++) {
|
|
609
|
-
const c = line[i];
|
|
610
|
-
if (quote) {
|
|
611
|
-
if (c === '\\') { i++; continue; }
|
|
612
|
-
if (c === quote) quote = null;
|
|
613
|
-
continue;
|
|
614
|
-
}
|
|
615
|
-
if (c === '"' || c === "'" || c === '`') { quote = c; continue; }
|
|
616
|
-
if (c === '#') break;
|
|
617
|
-
if (c === '/' && line[i + 1] === '/') break;
|
|
618
|
-
if (c === '(' || c === '[' || c === '{') delta++;
|
|
619
|
-
else if (c === ')' || c === ']' || c === '}') delta--;
|
|
620
|
-
}
|
|
621
|
-
return { delta, backslash: !quote && /\\\s*$/.test(line) };
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* Group physical lines into logical statements. A line continues the current
|
|
626
|
-
* statement while brackets stay open (call arguments / dicts / arrays that span
|
|
627
|
-
* lines) or it ends in a backslash. This is what lets one line-oriented regex
|
|
628
|
-
* match a call whose sink and its dangerous argument sit on DIFFERENT lines —
|
|
629
|
-
* e.g. `torch.load(\n ckpt,\n weights_only=False,\n)` — which a strict
|
|
630
|
-
* per-physical-line scan silently misses. Bounded by MAX_JOIN_LINES.
|
|
631
|
-
*/
|
|
632
|
-
function logicalLines(lines) {
|
|
633
|
-
const out = [];
|
|
634
|
-
let i = 0;
|
|
635
|
-
while (i < lines.length) {
|
|
636
|
-
const startLine = i + 1;
|
|
637
|
-
const buf = [];
|
|
638
|
-
let depth = 0;
|
|
639
|
-
while (i < lines.length) {
|
|
640
|
-
const line = lines[i];
|
|
641
|
-
buf.push(line);
|
|
642
|
-
const { delta, backslash } = lineDepthDelta(line);
|
|
643
|
-
depth += delta;
|
|
644
|
-
i++;
|
|
645
|
-
if ((depth <= 0 && !backslash) || buf.length >= MAX_JOIN_LINES) break;
|
|
646
|
-
}
|
|
647
|
-
out.push({ text: buf.join('\n'), startLine });
|
|
648
|
-
}
|
|
649
|
-
return out;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
function escapeRe(s) {
|
|
653
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
/**
|
|
657
|
-
* The argument text of the call whose `(` follows `from`, read with balanced
|
|
658
|
-
* brackets and string-aware so a `)` inside a literal doesn't end it early.
|
|
659
|
-
* Empty string when the call is unterminated inside the logical line.
|
|
660
|
-
*/
|
|
661
|
-
function callArgText(text, from) {
|
|
662
|
-
const open = text.indexOf('(', from);
|
|
663
|
-
if (open < 0) return '';
|
|
664
|
-
let depth = 0;
|
|
665
|
-
let quote = '';
|
|
666
|
-
for (let i = open; i < text.length; i++) {
|
|
667
|
-
const ch = text[i];
|
|
668
|
-
if (quote) {
|
|
669
|
-
if (ch === '\\') i++;
|
|
670
|
-
else if (ch === quote) quote = '';
|
|
671
|
-
continue;
|
|
672
|
-
}
|
|
673
|
-
if (ch === '"' || ch === "'" || ch === '`') quote = ch;
|
|
674
|
-
else if (ch === '(') depth++;
|
|
675
|
-
else if (ch === ')') {
|
|
676
|
-
depth--;
|
|
677
|
-
if (depth === 0) return text.slice(open + 1, i);
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
return '';
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
/**
|
|
684
|
-
* True when the `require(`/`import(` the regex matched is not Node's module
|
|
685
|
-
* loader at all. Two shapes, both found in first-party code at HIGH severity:
|
|
686
|
-
*
|
|
687
|
-
* • A DECLARATION of something named `require` — `private async require(orgId, id)`
|
|
688
|
-
* is a repository helper, not a module load. The Python rules already carry the
|
|
689
|
-
* equivalent `(?<!def )` guard; the JS rule never got one.
|
|
690
|
-
* • A call with more than one top-level argument. `require()` takes exactly one.
|
|
691
|
-
* (NOT applied to `import()`, which legitimately takes import attributes as a
|
|
692
|
-
* second argument.)
|
|
693
|
-
*/
|
|
694
|
-
const DECL_PREFIX_RE = /\b(?:function|async|get|set|static|private|public|protected|readonly)\s*\*?\s*$/;
|
|
695
|
-
function isNotAModuleLoad(m, unitText, argText) {
|
|
696
|
-
if (DECL_PREFIX_RE.test(unitText.slice(Math.max(0, m.index - 24), m.index))) return true;
|
|
697
|
-
if (/^\s*import\b/.test(m[0])) return false; // import attributes are a real 2nd arg
|
|
698
|
-
let depth = 0;
|
|
699
|
-
let quote = '';
|
|
700
|
-
for (let i = 0; i < argText.length; i++) {
|
|
701
|
-
const ch = argText[i];
|
|
702
|
-
if (quote) {
|
|
703
|
-
if (ch === '\\') i++;
|
|
704
|
-
else if (ch === quote) quote = '';
|
|
705
|
-
continue;
|
|
706
|
-
}
|
|
707
|
-
if (ch === '"' || ch === "'" || ch === '`') quote = ch;
|
|
708
|
-
else if ('([{'.includes(ch)) depth++;
|
|
709
|
-
else if (')]}'.includes(ch)) depth--;
|
|
710
|
-
else if (ch === ',' && depth === 0) return true;
|
|
711
|
-
}
|
|
712
|
-
return false;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
/**
|
|
716
|
-
* Local names bound to the `path` module in this file. The builder call is
|
|
717
|
-
* `path.join` only by convention — `const p = require('node:path')` is just as
|
|
718
|
-
* common, and keying the constant-folder off the literal name "path" missed it.
|
|
719
|
-
*/
|
|
720
|
-
const PATH_BIND_RE =
|
|
721
|
-
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"](?:node:)?path(?:\/(?:posix|win32))?['"]\s*\)|import\s+(?:\*\s+as\s+)?([A-Za-z_$][\w$]*)\s+from\s*['"](?:node:)?path(?:\/(?:posix|win32))?['"]/g;
|
|
722
|
-
|
|
723
|
-
function pathBindings(text) {
|
|
724
|
-
const ns = new Set(['path']);
|
|
725
|
-
PATH_BIND_RE.lastIndex = 0;
|
|
726
|
-
for (let m = PATH_BIND_RE.exec(text); m; m = PATH_BIND_RE.exec(text)) ns.add(m[1] || m[2]);
|
|
727
|
-
return ns;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
/**
|
|
731
|
-
* Identifiers whose declaration folds to a constant path. Requires the RHS to
|
|
732
|
-
* mention a path-shaped token — a bare `const m = 'child_process'` must stay
|
|
733
|
-
* unfolded so `require(m)` still reads as a hidden dangerous import. Two rounds,
|
|
734
|
-
* so `const ROOT = …; const SRC = `${ROOT}/src`` both land.
|
|
735
|
-
*/
|
|
736
|
-
const CONST_DECL_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^\n;]+)/g;
|
|
737
|
-
const PATHISH_RE = /__dirname|__filename|import\.meta\.url|process\.cwd|os\.(?:homedir|tmpdir)|fileURLToPath|new\s+URL|\.(?:join|resolve|normalize)\s*\(/;
|
|
738
|
-
|
|
739
|
-
function constPathBindings(text, pathNs) {
|
|
740
|
-
const found = new Set();
|
|
741
|
-
for (let round = 0; round < 2; round++) {
|
|
742
|
-
CONST_DECL_RE.lastIndex = 0;
|
|
743
|
-
for (let m = CONST_DECL_RE.exec(text); m; m = CONST_DECL_RE.exec(text)) {
|
|
744
|
-
const [, name, rhsRaw] = m;
|
|
745
|
-
if (found.has(name)) continue;
|
|
746
|
-
const rhs = rhsRaw.replace(/[,;]\s*$/, '').trim();
|
|
747
|
-
// Must be path-shaped, OR built on a constant this pass already proved —
|
|
748
|
-
// `const SRC = `${ROOT}src/`` inherits ROOT's provenance. Anything else
|
|
749
|
-
// (a plain string constant) stays unfolded on purpose.
|
|
750
|
-
const buildsOnKnown = [...found].some((n) => new RegExp(`\\b${escapeRe(n)}\\b`).test(rhs));
|
|
751
|
-
if (!PATHISH_RE.test(rhs) && !buildsOnKnown) continue;
|
|
752
|
-
if (isStaticPathExpr(rhs, pathNs, found)) found.add(name);
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
return found;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
/** Pure path builders: constant arguments in ⇒ one constant path out. */
|
|
759
|
-
const PATH_FNS = 'join|resolve|normalize|relative|dirname|basename|extname';
|
|
760
|
-
|
|
761
|
-
/**
|
|
762
|
-
* True when `argText` provably evaluates to one fixed path: nothing survives
|
|
763
|
-
* after removing string literals and the build-time tokens (pure `path.*`
|
|
764
|
-
* builders, `__dirname`, `import.meta.url`, …). Conservative — any identifier it
|
|
765
|
-
* does not recognise (a parameter, a config value, a model result) leaves a
|
|
766
|
-
* residue and the hit stands. Mirrors `isStaticPathExpr` in the backend's
|
|
767
|
-
* code-sast.ts, and the structural `isStaticModulePath` in code-ast.ts.
|
|
768
|
-
*/
|
|
769
|
-
export function isStaticPathExpr(argText, pathNs = new Set(['path']), constPaths = new Set()) {
|
|
770
|
-
if (!argText.trim()) return false;
|
|
771
|
-
const ns = [...pathNs].map(escapeRe).join('|');
|
|
772
|
-
const consts = constPaths.size ? `|${[...constPaths].map(escapeRe).join('|')}` : '';
|
|
773
|
-
const staticTokens = new RegExp(
|
|
774
|
-
`\\b(?:(?:${ns})(?:\\.(?:posix|win32))?\\.(?:${PATH_FNS})|__dirname|__filename|import\\.meta\\.url|process\\.cwd|os\\.(?:homedir|tmpdir)|fileURLToPath|require\\.resolve|new\\s+URL|String\\.raw${consts})\\b`,
|
|
775
|
-
'g',
|
|
776
|
-
);
|
|
777
|
-
// A member read off whatever remains — `new URL(…).href`, `.toString()`. Applied
|
|
778
|
-
// as a token strip, so `cfg.modulePath` still leaves `cfg` behind and reports.
|
|
779
|
-
const pureMembers = /\.(?:href|pathname|toString|toLowerCase|toUpperCase|trim|normalize|valueOf)\b/g;
|
|
780
|
-
// A template literal reduces to its ${…} expressions — those must be constant
|
|
781
|
-
// too; its fixed text is just a literal. Plain literals collapse away entirely.
|
|
782
|
-
let t = argText
|
|
783
|
-
.replace(/`(?:[^`\\]|\\.)*`/g, (lit) => ` ${[...lit.matchAll(/\$\{([^{}]*)\}/g)].map((x) => x[1]).join(' , ')} `)
|
|
784
|
-
.replace(/'(?:[^'\\]|\\.)*'/g, ' ')
|
|
785
|
-
.replace(/"(?:[^"\\]|\\.)*"/g, ' ');
|
|
786
|
-
t = t.replace(pureMembers, ' ').replace(staticTokens, ' ');
|
|
787
|
-
// Structure-only residue (separators, concatenation, empty call parens) is fine.
|
|
788
|
-
return !/[A-Za-z0-9_$]/.test(t.replace(/[\s(),.+[\]/\\:-]/g, ''));
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
/**
|
|
792
|
-
* Whether byte `offset` in `text` falls inside a string literal — tracks single/
|
|
793
|
-
* double/backtick + triple quotes, honouring escapes. Drops `codeOnly` rule
|
|
794
|
-
* matches that land in a docstring / log message / usage example (the Falcon
|
|
795
|
-
* `trust_remote_code=True`-in-a-warning FP). Approximate and only ever more
|
|
796
|
-
* conservative (may skip a real hit, never invents one).
|
|
797
|
-
*/
|
|
798
|
-
function isInsideString(text, offset) {
|
|
799
|
-
let quote = null;
|
|
800
|
-
let triple = false;
|
|
801
|
-
for (let i = 0; i < offset && i < text.length; i++) {
|
|
802
|
-
const c = text[i];
|
|
803
|
-
if (quote) {
|
|
804
|
-
if (c === '\\') { i++; continue; }
|
|
805
|
-
if (triple) {
|
|
806
|
-
if (c === quote && text[i + 1] === quote && text[i + 2] === quote) { i += 2; quote = null; triple = false; }
|
|
807
|
-
} else if (c === quote) {
|
|
808
|
-
quote = null;
|
|
809
|
-
}
|
|
810
|
-
continue;
|
|
811
|
-
}
|
|
812
|
-
if (c === '#') {
|
|
813
|
-
const nl = text.indexOf('\n', i);
|
|
814
|
-
if (nl === -1) return false;
|
|
815
|
-
i = nl;
|
|
816
|
-
continue;
|
|
817
|
-
}
|
|
818
|
-
if (c === '"' || c === "'" || c === '`') {
|
|
819
|
-
quote = c;
|
|
820
|
-
triple = text[i + 1] === c && text[i + 2] === c;
|
|
821
|
-
if (triple) i += 2;
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
return quote !== null;
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
/** Map a match offset inside a logical unit back to a 0-based physical line. */
|
|
828
|
-
function physicalIdx(unit, offset) {
|
|
829
|
-
const newlines = unit.text.slice(0, offset).match(/\n/g);
|
|
830
|
-
return unit.startLine - 1 + (newlines ? newlines.length : 0);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
/**
|
|
834
|
-
* Parse a leading assignment out of a logical unit: `x = …`, `const x = …`,
|
|
835
|
-
* `x: T = …`, or tuple unpacking `a, b = …`. Rejects `==`/`=>`/`>=` etc. Returns
|
|
836
|
-
* the assigned variable names and the RHS text, or null.
|
|
837
|
-
*/
|
|
838
|
-
function parseAssign(text) {
|
|
839
|
-
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);
|
|
840
|
-
if (!m) return null;
|
|
841
|
-
const vars = m[1].split(',').map((s) => s.trim()).filter(Boolean);
|
|
842
|
-
return { vars, rhs: m[2] };
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
/**
|
|
846
|
-
* Heuristic intra-file LLM-output propagation (NOT true taint analysis). Marks
|
|
847
|
-
* variables assigned from an LLM call as tainted, propagates by name-substring
|
|
848
|
-
* across simple assignments (bounded fixed point), then emits a finding wherever
|
|
849
|
-
* a tainted name appears in a code-exec sink's arguments — the prompt-injection →
|
|
850
|
-
* RCE shape a single-sink regex can't see. No scope, kill, sanitizer or
|
|
851
|
-
* interprocedural tracking, so it can over- and under-report; findings are HIGH
|
|
852
|
-
* at low confidence with hedged wording, i.e. leads to confirm, not proof.
|
|
853
|
-
*/
|
|
854
|
-
function taintFindings(lines, units, file, cfg) {
|
|
855
|
-
const tainted = new Set();
|
|
856
|
-
// Seed + propagate. Two passes cover simple multi-hop chains (resp → text → exec).
|
|
857
|
-
for (let pass = 0; pass < 2; pass++) {
|
|
858
|
-
for (const unit of units) {
|
|
859
|
-
const a = parseAssign(unit.text);
|
|
860
|
-
if (!a) continue;
|
|
861
|
-
let taint = cfg.aiCall.test(a.rhs);
|
|
862
|
-
if (!taint) {
|
|
863
|
-
for (const t of tainted) {
|
|
864
|
-
if (new RegExp(`\\b${escapeRe(t)}\\b`).test(a.rhs)) { taint = true; break; }
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
if (taint) for (const v of a.vars) tainted.add(v);
|
|
868
|
-
}
|
|
869
|
-
if (!tainted.size) break;
|
|
870
|
-
}
|
|
871
|
-
if (!tainted.size) return [];
|
|
872
|
-
|
|
873
|
-
const out = [];
|
|
874
|
-
const seen = new Set();
|
|
875
|
-
for (const unit of units) {
|
|
876
|
-
cfg.execSink.lastIndex = 0;
|
|
877
|
-
let m;
|
|
878
|
-
while ((m = cfg.execSink.exec(unit.text))) {
|
|
879
|
-
// Argument region: from the sink's '(' to the end of the logical unit.
|
|
880
|
-
const paren = unit.text.indexOf('(', m.index);
|
|
881
|
-
const argRegion = paren >= 0 ? unit.text.slice(paren) : '';
|
|
882
|
-
let via = null;
|
|
883
|
-
for (const t of tainted) {
|
|
884
|
-
if (new RegExp(`\\b${escapeRe(t)}\\b`).test(argRegion)) { via = t; break; }
|
|
885
|
-
}
|
|
886
|
-
if (via) {
|
|
887
|
-
const idx = physicalIdx(unit, m.index);
|
|
888
|
-
const trimmed = (lines[idx] ?? '').trim();
|
|
889
|
-
if (trimmed && !isCommentLine(trimmed) && !seen.has(idx)) {
|
|
890
|
-
seen.add(idx);
|
|
891
|
-
const sink = m[0].replace(/\s*\($/, '').trim();
|
|
892
|
-
out.push({
|
|
893
|
-
ruleId: cfg.ruleId,
|
|
894
|
-
title: 'LLM output may reach a code-execution sink (heuristic)',
|
|
895
|
-
// HIGH, not CRITICAL: name-propagation heuristic, not proven dataflow.
|
|
896
|
-
severity: 'HIGH',
|
|
897
|
-
category: 'taint-heuristic',
|
|
898
|
-
confidence: 0.5,
|
|
899
|
-
file,
|
|
900
|
-
line: idx + 1,
|
|
901
|
-
sink: sink.slice(0, 120),
|
|
902
|
-
source: 'LLM output',
|
|
903
|
-
taint: `${via} (LLM output) → ${sink} (heuristic name match — confirm)`,
|
|
904
|
-
...contextChunk(lines, idx),
|
|
905
|
-
message: `A variable that appears to derive from an LLM call ("${via}") is used in ${sink}. If that value really is model-controlled, a prompt-injected instruction becomes code execution in the host — a critical agent vulnerability. Flagged by a name-propagation heuristic (no dataflow proof), so confirm the value is actually the model output and not reassigned/sanitized before this line.`,
|
|
906
|
-
remediation: 'If confirmed, 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.',
|
|
907
|
-
cwe: 'CWE-94',
|
|
908
|
-
});
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
if (!cfg.execSink.global) break;
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
return out;
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
/**
|
|
918
|
-
* Synthesise chain findings from co-occurring rule hits in one file. Conjunctions
|
|
919
|
-
* only (both halves independently suspicious), so no added false positives.
|
|
920
|
-
*/
|
|
921
|
-
function chainFindings(lines, findings, file) {
|
|
922
|
-
const ids = new Set(findings.map((f) => f.ruleId));
|
|
923
|
-
const out = [];
|
|
924
|
-
for (const chain of CHAINS) {
|
|
925
|
-
if (!chain.needs(ids)) continue;
|
|
926
|
-
// Anchor the synthesised finding on a real contributing line for the snippet.
|
|
927
|
-
const anchor = findings.find((f) => chain.anchor.includes(f.ruleId));
|
|
928
|
-
const line = anchor ? anchor.line : 1;
|
|
929
|
-
const idx = Math.max(0, line - 1);
|
|
930
|
-
out.push({
|
|
931
|
-
ruleId: chain.id,
|
|
932
|
-
title: chain.title,
|
|
933
|
-
severity: chain.severity,
|
|
934
|
-
category: chain.category,
|
|
935
|
-
confidence: chain.confidence,
|
|
936
|
-
file,
|
|
937
|
-
line,
|
|
938
|
-
sink: 'multi-signal',
|
|
939
|
-
chain: [...new Set(findings.filter((f) => chain.parts.includes(f.ruleId)).map((f) => f.ruleId))],
|
|
940
|
-
...contextChunk(lines, idx),
|
|
941
|
-
message: chain.message,
|
|
942
|
-
remediation: chain.remediation,
|
|
943
|
-
cwe: chain.cwe,
|
|
944
|
-
});
|
|
945
|
-
}
|
|
946
|
-
return out;
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
/**
|
|
950
|
-
* Run each rule over the file, grouping physical lines into logical statements
|
|
951
|
-
* first so multi-line calls are matched, then layer the taint and chain tiers on
|
|
952
|
-
* top. The reported `line` is the exact physical line the sink lands on, so the
|
|
953
|
-
* numbered snippet still highlights the right row. Hits on pure comment lines are
|
|
954
|
-
* skipped.
|
|
955
|
-
*/
|
|
956
|
-
function scanLines(text, file, rules, taintCfg) {
|
|
957
|
-
const lines = text.split(/\r?\n/);
|
|
958
|
-
const units = logicalLines(lines);
|
|
959
|
-
const out = [];
|
|
960
|
-
const seen = new Set(); // dedupe by ruleId@line
|
|
961
|
-
// Whole-file facts a `suppress` predicate may consult; computed once per scan.
|
|
962
|
-
const pathNs = pathBindings(text);
|
|
963
|
-
const ctx = { pathNs, constPaths: constPathBindings(text, pathNs) };
|
|
964
|
-
for (const unit of units) {
|
|
965
|
-
for (const rule of rules) {
|
|
966
|
-
rule.re.lastIndex = 0;
|
|
967
|
-
const m = rule.re.exec(unit.text);
|
|
968
|
-
if (!m) continue;
|
|
969
|
-
// Drop code-construct rules whose match lands inside a string literal
|
|
970
|
-
// (docstring / log message / usage example) — the Falcon-class FP.
|
|
971
|
-
if (rule.codeOnly && isInsideString(unit.text, m.index)) continue;
|
|
972
|
-
// Last-word veto on a match the regex accepted: the signal is real code but
|
|
973
|
-
// the ARGUMENT proves it benign — needs balanced-bracket reading a regex
|
|
974
|
-
// cannot express. Returning true drops the hit.
|
|
975
|
-
if (rule.suppress && rule.suppress(m, unit.text, ctx)) continue;
|
|
976
|
-
const idx = physicalIdx(unit, m.index);
|
|
977
|
-
const trimmed = (lines[idx] ?? '').trim();
|
|
978
|
-
if (!trimmed || isCommentLine(trimmed)) continue;
|
|
979
|
-
const key = `${rule.id}@${idx}`;
|
|
980
|
-
if (seen.has(key)) continue;
|
|
981
|
-
seen.add(key);
|
|
982
|
-
out.push({
|
|
983
|
-
ruleId: rule.id,
|
|
984
|
-
title: rule.title,
|
|
985
|
-
severity: rule.severity,
|
|
986
|
-
category: rule.category,
|
|
987
|
-
confidence: rule.confidence,
|
|
988
|
-
file,
|
|
989
|
-
line: idx + 1,
|
|
990
|
-
sink: (rule.sink ? rule.sink(m) : m[0]).slice(0, 120),
|
|
991
|
-
source: rule.source || undefined,
|
|
992
|
-
...contextChunk(lines, idx),
|
|
993
|
-
message: rule.message,
|
|
994
|
-
remediation: rule.remediation,
|
|
995
|
-
cwe: rule.cwe,
|
|
996
|
-
});
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
if (taintCfg) out.push(...taintFindings(lines, units, file, taintCfg));
|
|
1000
|
-
out.push(...chainFindings(lines, out, file));
|
|
1001
|
-
return out;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
export function scanPythonSource(text, file) { return text ? scanLines(text, file, PY_RULES, PY_TAINT) : []; }
|
|
1005
|
-
export function scanJsSource(text, file) { return text ? scanLines(text, file, JS_RULES, JS_TAINT) : []; }
|
|
1006
|
-
export function scanModelConfig(text, file) { return text ? scanLines(text, file, CONFIG_RULES, null) : []; }
|
|
1007
|
-
|
|
1008
|
-
/**
|
|
1009
|
-
* Scan a Jupyter notebook (`.ipynb`, which is JSON). Notebooks ship executable
|
|
1010
|
-
* code cells and are a first-class model-hub / agent delivery vector, but line
|
|
1011
|
-
* numbers only make sense per cell, so each `code` cell is scanned on its own and
|
|
1012
|
-
* tagged `<file>#cell<N>` with 1-based lines within that cell. The kernel language
|
|
1013
|
-
* routes Python vs JS rules (default Python). Malformed JSON yields nothing rather
|
|
1014
|
-
* than throwing, so one bad file never breaks a scan.
|
|
1015
|
-
*/
|
|
1016
|
-
export function scanNotebook(text, file) {
|
|
1017
|
-
if (!text) return [];
|
|
1018
|
-
let nb;
|
|
1019
|
-
try { nb = JSON.parse(text); } catch { return []; }
|
|
1020
|
-
const cells = Array.isArray(nb?.cells) ? nb.cells : [];
|
|
1021
|
-
const lang = String(nb?.metadata?.kernelspec?.language || nb?.metadata?.language_info?.name || 'python').toLowerCase();
|
|
1022
|
-
const isJs = /javascript|typescript|deno|node|^js$|^ts$/.test(lang);
|
|
1023
|
-
const rules = isJs ? JS_RULES : PY_RULES;
|
|
1024
|
-
const taintCfg = isJs ? JS_TAINT : PY_TAINT;
|
|
1025
|
-
const out = [];
|
|
1026
|
-
let codeCell = 0;
|
|
1027
|
-
for (const cell of cells) {
|
|
1028
|
-
if (cell?.cell_type !== 'code') continue;
|
|
1029
|
-
codeCell++;
|
|
1030
|
-
const src = Array.isArray(cell.source) ? cell.source.join('') : String(cell.source ?? '');
|
|
1031
|
-
if (!src.trim()) continue;
|
|
1032
|
-
out.push(...scanLines(src, `${file}#cell${codeCell}`, rules, taintCfg));
|
|
1033
|
-
}
|
|
1034
|
-
return out;
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
const PY_EXT = /\.py$/i;
|
|
1038
|
-
const JS_EXT = /\.(m|c)?[jt]sx?$/i;
|
|
1039
|
-
const NB_EXT = /\.ipynb$/i;
|
|
1040
|
-
const MODEL_CONFIG_RE = /(^|\/)(config|tokenizer_config|generation_config|preprocessor_config)\.json$/i;
|
|
1041
|
-
|
|
1042
|
-
/** True when `path` is a source file one of the language rule sets can scan. */
|
|
1043
|
-
export function isScannableSource(path) {
|
|
1044
|
-
return PY_EXT.test(path) || JS_EXT.test(path) || NB_EXT.test(path);
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
/** True when `path` is a HF-style model config the auto_map rules understand. */
|
|
1048
|
-
export function isModelConfig(path) {
|
|
1049
|
-
return MODEL_CONFIG_RE.test(String(path ?? '').split(/[\\/]+/).join('/'));
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
/**
|
|
1053
|
-
* Route one file to the right rule set by name/extension and return its hits.
|
|
1054
|
-
* Unknown files yield nothing.
|
|
1055
|
-
*/
|
|
1056
|
-
export function scanSourceFile(text, file) {
|
|
1057
|
-
if (!text) return [];
|
|
1058
|
-
if (NB_EXT.test(file)) return scanNotebook(text, file);
|
|
1059
|
-
if (PY_EXT.test(file)) return scanPythonSource(text, file);
|
|
1060
|
-
if (JS_EXT.test(file)) return scanJsSource(text, file);
|
|
1061
|
-
if (isModelConfig(file)) return scanModelConfig(text, file);
|
|
1062
|
-
return [];
|
|
1063
|
-
}
|