@shomra/agent 0.3.28 → 0.3.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/package.json +1 -1
  2. package/src/agents/hook-command.mjs +1 -1
  3. package/src/artifacts/matchers.mjs +7 -0
  4. package/src/cli/flags.mjs +2 -2
  5. package/src/cli/help-sections.mjs +7 -0
  6. package/src/cli/help.mjs +1 -1
  7. package/src/commands/check.mjs +3 -11
  8. package/src/commands/gate.mjs +26 -4
  9. package/src/commands/git-hooks.mjs +2 -2
  10. package/src/commands/ledger.mjs +0 -1
  11. package/src/commands/mcp-add.mjs +2 -1
  12. package/src/commands/memory-scan.mjs +135 -47
  13. package/src/commands/pr.mjs +7 -10
  14. package/src/commands/provenance.mjs +8 -13
  15. package/src/commands/scan.mjs +7 -1
  16. package/src/commands/secrets.mjs +4 -5
  17. package/src/core/git-exec.mjs +79 -0
  18. package/src/core/yaml-lite.mjs +300 -0
  19. package/src/core/zip-lite.mjs +37 -0
  20. package/src/detect/local-redact.mjs +1 -3
  21. package/src/detect/sast/rules-config.mjs +1 -1
  22. package/src/detect/sast/rules-javascript.mjs +16 -3
  23. package/src/detect/sast/rules-python.mjs +8 -6
  24. package/src/detect/sast/scanner.mjs +1 -1
  25. package/src/detect/signals/agent-frameworks.mjs +231 -0
  26. package/src/detect/signals/agent-graph-surface.mjs +113 -0
  27. package/src/detect/signals/agentic-ci-surface.mjs +314 -0
  28. package/src/detect/signals/agentic-shim.mjs +82 -0
  29. package/src/detect/signals/artifacts.mjs +8 -36
  30. package/src/detect/signals/autonomy.mjs +9 -1
  31. package/src/detect/signals/chat-template.mjs +211 -0
  32. package/src/detect/signals/ci-workflow.mjs +169 -0
  33. package/src/detect/signals/credential-harvest.mjs +1 -1
  34. package/src/detect/signals/execution-hijack.mjs +4 -2
  35. package/src/detect/signals/gate.mjs +84 -11
  36. package/src/detect/signals/guardrail-shape.mjs +564 -0
  37. package/src/detect/signals/guardrail-surface.mjs +221 -0
  38. package/src/detect/signals/injection.mjs +8 -0
  39. package/src/detect/signals/inspect-shim.mjs +7 -0
  40. package/src/detect/signals/instruction-paths.mjs +60 -0
  41. package/src/detect/signals/manifests.mjs +302 -0
  42. package/src/detect/signals/masking.mjs +14 -1
  43. package/src/detect/signals/mcp-advisories.mjs +109 -0
  44. package/src/detect/signals/mcp-config.mjs +598 -0
  45. package/src/detect/signals/memory-directives.mjs +661 -0
  46. package/src/detect/signals/memory-locations.mjs +158 -0
  47. package/src/detect/signals/memory.mjs +56 -31
  48. package/src/detect/signals/model-config-rules.mjs +655 -0
  49. package/src/detect/signals/model-config.mjs +61 -0
  50. package/src/detect/signals/packages.mjs +2 -2
  51. package/src/detect/signals/prose-context.mjs +6 -9
  52. package/src/detect/signals/scan.mjs +4 -4
  53. package/src/detect/signals/secret-scanner.mjs +241 -0
  54. package/src/detect/signals/secrets.mjs +1 -48
  55. package/src/detect/signals/shell.mjs +10 -10
  56. package/src/gate/advisories.mjs +16 -0
  57. package/src/gate/batch.mjs +10 -0
  58. package/src/gate/environment.mjs +8 -53
  59. package/src/guard/artifact-paths.mjs +107 -0
  60. package/src/guard/classify.mjs +165 -7
  61. package/src/guard/command-resolve.mjs +35 -5
  62. package/src/guard/memory-write.mjs +218 -0
  63. package/src/guard/prompt-guard.mjs +0 -1
  64. package/src/guard/tool-guard.mjs +52 -77
  65. package/src/inventory/agent-posture.mjs +236 -57
  66. package/src/inventory/artifacts/classify.mjs +10 -1
  67. package/src/inventory/artifacts/discover.mjs +113 -3
  68. package/src/inventory/artifacts/extensions.mjs +70 -0
  69. package/src/inventory/artifacts/hook-scripts.mjs +128 -0
  70. package/src/inventory/artifacts/limits.mjs +1 -1
  71. package/src/inventory/artifacts/plugins.mjs +105 -0
  72. package/src/inventory/artifacts/roots.mjs +40 -0
  73. package/src/inventory/discovery/ai-dependencies.mjs +39 -12
  74. package/src/inventory/discovery/all.mjs +4 -0
  75. package/src/inventory/discovery/cloud-clis.mjs +472 -0
  76. package/src/inventory/discovery/coding-agents.mjs +19 -4
  77. package/src/inventory/discovery/mcp-clients.mjs +16 -10
  78. package/src/inventory/discovery/mcp-servers.mjs +125 -35
  79. package/src/inventory/discovery/mcp-stores.mjs +207 -0
  80. package/src/inventory/env-redirect.mjs +148 -0
  81. package/src/inventory/grant-extract.mjs +463 -0
  82. package/src/inventory/project-roots.mjs +108 -0
  83. package/src/inventory/vscode-state.mjs +153 -0
  84. package/src/mcp/server-tools.mjs +1 -1
@@ -0,0 +1,655 @@
1
+ // GENERATED MIRROR of Dragox.Backend model-formats/model-config-rules.ts (types stripped, imports swapped).
2
+ // Do not hand-edit the rules here - change the backend file and regenerate, so
3
+ // `shomra gate` and the platform grade a model config identically.
4
+ import { parseYaml } from '../../core/yaml-lite.mjs';
5
+ import { inspectText } from './inspect-shim.mjs';
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+ const MAX_WALK_NODES = 20_000;
34
+ const isObj = (v ) => !!v && typeof v === 'object' && !Array.isArray(v);
35
+ const base = (p ) => String(p ?? '').replace(/\\/g, '/').split('/').pop() ?? '';
36
+
37
+ /* ─── what counts as a host-execution primitive ─────────────────────────── */
38
+
39
+ const DANGEROUS_CALLABLE_RE =
40
+ /^(?:builtins?\.|__builtin__\.)?(?:eval|exec|compile|__import__|open|getattr|setattr|breakpoint|input)$|^(?:os|posix|nt|subprocess|pty|shutil|socket|runpy|importlib|imp|pickle|cPickle|dill|marshal|code|codeop|ctypes|multiprocessing|webbrowser|sys|commands|platform\.popen|pdb|asyncio\.subprocess)(?:\.|$)|^(?:builtins?|__builtin__)$|^(?:keras\.utils\.get_file|tensorflow\.keras\.utils\.get_file|tf\.keras\.utils\.get_file|keras\.src\.utils\.file_utils\.get_file|torch\.hub\.load|torch\.load|numpy\.load|joblib\.load)$|^(?:os\.system|os\.popen|os\.exec\w*|os\.spawn\w*)$/i;
41
+
42
+ const isDangerousCallable = (v ) => DANGEROUS_CALLABLE_RE.test(String(v ?? '').trim().replace(/^:/, ''));
43
+
44
+ /* ─── classification ────────────────────────────────────────────────────── */
45
+
46
+ const HF_JSON = new Set([
47
+ 'config.json', 'generation_config.json', 'processor_config.json', 'preprocessor_config.json',
48
+ 'config_sentence_transformers.json', 'feature_extractor_config.json', 'image_processor_config.json', 'video_preprocessor_config.json',
49
+ ]);
50
+ const TOKENIZER_JSON = new Set(['tokenizer_config.json', 'special_tokens_map.json', 'tokenizer.json', 'added_tokens.json']);
51
+ const VLLM_KEYS = ['tensor-parallel-size', 'tensor_parallel_size', 'gpu-memory-utilization', 'gpu_memory_utilization', 'max-model-len', 'max_model_len', 'served-model-name', 'served_model_name', 'trust-remote-code', 'enable-lora', 'kv-transfer-config', 'allowed-local-media-path'];
52
+ const TORCHSERVE_KEYS = /^\s*(?:inference_address|management_address|metrics_address|model_store|load_models|allowed_urls|disable_token_authorization)\s*=/m;
53
+
54
+ export function modelConfigFormat(path , text ) {
55
+ const b = base(path);
56
+ const lower = b.toLowerCase();
57
+ const t = String(text ?? '');
58
+ if (!t.trim()) return null;
59
+ if (lower === 'adapter_config.json') return 'peft-adapter';
60
+ if (lower === 'model_index.json' && /"_class_name"/.test(t)) return 'diffusers-index';
61
+ if (lower === 'modules.json' && /"type"\s*:\s*"/.test(t) && /"path"\s*:/.test(t)) return 'st-modules';
62
+ if (TOKENIZER_JSON.has(lower)) return 'tokenizer';
63
+ if (HF_JSON.has(lower)) return 'hf-config';
64
+ if (b === 'MLmodel' || lower === 'mlmodel') return 'mlflow';
65
+ if (lower === 'config.pbtxt') return 'triton';
66
+ if (lower.endsWith('.properties') && TORCHSERVE_KEYS.test(t)) return 'torchserve';
67
+ if (lower === 'bentofile.yaml' || lower === 'bentofile.yml') return 'bentoml';
68
+ if (/\.ya?ml$/i.test(lower)) {
69
+ if (/^model_list\s*:/m.test(t)) return 'litellm';
70
+ if (VLLM_KEYS.filter((k) => new RegExp(`^${k.replace(/[-_]/g, '[-_]')}\\s*:`, 'm').test(t)).length >= 2) return 'vllm';
71
+ if (/(^|\s)_target_\s*:|!!python\//.test(t)) return 'hydra-yaml';
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /* ─── generic walkers ───────────────────────────────────────────────────── */
77
+
78
+ const MAX_WALK_DEPTH = 64;
79
+
80
+ function walk(node , visit , path = '', budget = { n: 0 }, depth = 0) {
81
+ if (budget.n++ > MAX_WALK_NODES || depth > MAX_WALK_DEPTH || node == null || typeof node !== 'object') return;
82
+ if (Array.isArray(node)) {
83
+ node.forEach((v, i) => walk(v, visit, `${path}[${i}]`, budget, depth + 1));
84
+ return;
85
+ }
86
+ for (const [k, v] of Object.entries(node)) {
87
+ const p = path ? `${path}.${k}` : k;
88
+ visit(k, v, p);
89
+ walk(v, visit, p, budget, depth + 1);
90
+ }
91
+ }
92
+
93
+ /** Hydra / OmegaConf / PyTorchModelHubMixin instantiation: `_target_` anywhere in the tree. */
94
+ function targetFindings(doc , file , opts ) {
95
+ const out = [];
96
+ const dangerous = [];
97
+ const foreign = [];
98
+ walk(doc, (k, v, p) => {
99
+ if ((k === '_target_' || k === '_partial_target_' || k === 'target_class') && typeof v === 'string') {
100
+ if (isDangerousCallable(v)) dangerous.push({ path: p, target: v });
101
+ else foreign.push({ path: p, target: v });
102
+ }
103
+ });
104
+ if (dangerous.length) {
105
+ out.push({
106
+ ruleId: 'model_config.target_exec',
107
+ cls: 'SANDBOX', severity: 'CRITICAL', cwe: 'CWE-94', anchor: dangerous[0].target,
108
+ title: `Model config instantiates a host-execution primitive (${dangerous[0].target})`,
109
+ detail:
110
+ `\`${dangerous[0].path}: ${dangerous[0].target}\` in ${file}. A Hydra/OmegaConf \`_target_\` is IMPORTED AND CALLED when the config is instantiated - loading the model is enough; this is the NeMo (CVE-2025-23304) and Uni2TS/PyTorchModelHubMixin class of config-to-RCE.`,
111
+ remediation: 'Do not load this model. A model config never needs to name os/subprocess/eval/builtins; restore the upstream config and load with an instantiate() that allowlists targets.',
112
+ evidence: { targets: dangerous.slice(0, 8), file },
113
+ });
114
+ }
115
+ if (!dangerous.length && foreign.length && opts.inWeightsConfig) {
116
+ out.push({
117
+ ruleId: 'model_config.target_instantiation',
118
+ cls: 'SANDBOX', severity: 'MEDIUM', cwe: 'CWE-470', anchor: foreign[0].target,
119
+ title: `Model config instantiates classes by import path (${foreign[0].target})`,
120
+ detail:
121
+ `${file} names ${foreign.length} class${foreign.length === 1 ? '' : 'es'} via \`_target_\` (${foreign.slice(0, 3).map((f) => f.target).join(', ')}). Whatever loads this config imports and constructs them, so the config - not the code - decides what runs. Ordinary for NeMo/Lightning checkpoints; the risk is a republished config that swaps the path.`,
122
+ remediation: 'Pin the checkpoint to a reviewed revision and load with an allowlist of permitted `_target_` namespaces (e.g. NeMo safe_instantiate).',
123
+ evidence: { targets: foreign.slice(0, 8), file },
124
+ });
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /* ─── Hugging Face configs ──────────────────────────────────────────────── */
130
+
131
+ const HUB_REPO_RE = /^[A-Za-z0-9][\w.-]*\/[\w.-]+(?:@[\w.-]+)?(?::[\w.-]+)?$/;
132
+ const TRUSTED_KERNEL_ORGS = /^kernels-community\//i;
133
+
134
+ function hfConfigFindings(doc , file ) {
135
+ const out = [];
136
+ if (!isObj(doc)) return out;
137
+
138
+ const kernels = [];
139
+ walk(doc, (k, v) => {
140
+ if (typeof v === 'string' && /^_?attn_implementation(?:_internal|_autoset)?$|^_?(?:kernel|kernels)(?:_repo|_config)?$/.test(k) && HUB_REPO_RE.test(v.trim())) kernels.push({ key: k, value: v.trim() });
141
+ });
142
+ for (const kx of kernels.slice(0, 3)) {
143
+ const privateKey = kx.key.startsWith('_');
144
+ out.push({
145
+ ruleId: privateKey ? 'model_config.private_kernel_injection' : 'model_config.hub_kernel',
146
+ cls: 'SANDBOX',
147
+ severity: privateKey ? 'CRITICAL' : TRUSTED_KERNEL_ORGS.test(kx.value) ? 'MEDIUM' : 'HIGH',
148
+ cwe: 'CWE-829', anchor: kx.key,
149
+ title: privateKey
150
+ ? `Model config smuggles a kernel repo through a private key (${kx.key} = ${kx.value})`
151
+ : `Model config loads attention kernels from a Hub repo (${kx.value})`,
152
+ detail: privateKey
153
+ ? `\`${kx.key}\` is an internal transformers attribute, never part of a published config. Set in config.json it makes the \`kernels\` loader download and import \`${kx.value}\` when the model loads - remote code that bypasses trust_remote_code (reported against transformers 4.56-5.2, fixed in 5.3.0).`
154
+ : `\`${kx.key}: ${kx.value}\` makes transformers fetch and import compiled/Python kernels from that repository at load time - code execution decided by the config${TRUSTED_KERNEL_ORGS.test(kx.value) ? ' (from the kernels-community org, still unpinned code)' : ', from a publisher that is not the kernels-community org'}.`,
155
+ remediation: privateKey
156
+ ? 'Do not load this model; remove the private key and upgrade transformers to 5.3.0 or later.'
157
+ : 'Pin the kernel repo to a reviewed revision, or drop the key and use a built-in attention implementation (sdpa / flash_attention_2).',
158
+ evidence: { key: kx.key, repo: kx.value, file },
159
+ });
160
+ }
161
+
162
+ out.push(...kerasFindings(doc, file));
163
+ out.push(...targetFindings(doc, file, { inWeightsConfig: true }));
164
+ return out;
165
+ }
166
+
167
+ function kerasFindings(doc , file ) {
168
+ const looksKeras = isObj(doc) && (typeof doc.keras_version === 'string' || (typeof doc.class_name === 'string' && isObj(doc.config) && (Array.isArray(doc.config.layers) || 'layers' in doc.config)));
169
+ if (!looksKeras) return [];
170
+ const lambdas = [];
171
+ const foreign = [];
172
+ const dangerous = [];
173
+ walk(doc, (k, v) => {
174
+ if (k === 'class_name' && v === 'Lambda') lambdas.push('Lambda');
175
+ if ((k === 'module' || k === 'registered_name' || k === 'function' || k === 'fn') && typeof v === 'string') {
176
+ if (isDangerousCallable(v) || /(^|\.)get_file$/.test(v)) dangerous.push(v);
177
+ else if (k === 'module' && !/^(?:keras|tf_keras|tensorflow|keras_hub|keras_nlp|keras_cv|builtins?$)/.test(v)) foreign.push(v);
178
+ }
179
+ });
180
+ const out = [];
181
+ if (dangerous.length) {
182
+ out.push({
183
+ ruleId: 'keras.config_arbitrary_import', cls: 'SANDBOX', severity: 'CRITICAL', cwe: 'CWE-94', anchor: dangerous[0],
184
+ title: `Keras config imports a host-execution function (${dangerous[0]})`,
185
+ detail: `${file} names \`${dangerous[0]}\` as a layer module/function. Keras resolves these on load even with safe_mode (CVE-2025-1550, fixed 3.9) and \`keras.utils.get_file\` was reused for arbitrary file writes (CVE-2025-8747, fixed 3.11).`,
186
+ remediation: 'Do not load this model. Upgrade Keras to 3.11+ and load only models whose config names keras.* layers.',
187
+ evidence: { modules: dangerous.slice(0, 6), file },
188
+ });
189
+ }
190
+ if (lambdas.length) {
191
+ out.push({
192
+ ruleId: 'keras.config_lambda', cls: 'SANDBOX', severity: 'HIGH', cwe: 'CWE-502', anchor: '"Lambda"',
193
+ title: 'Keras config contains a Lambda layer (serialized Python code)',
194
+ detail: `${file} declares ${lambdas.length} Lambda layer${lambdas.length === 1 ? '' : 's'}. A Lambda carries marshalled Python bytecode that runs on load; safe_mode blocks it for .keras but NOT for .h5 (CVE-2025-9905) or Keras 2 (CVE-2024-3660).`,
195
+ remediation: 'Replace the Lambda with a registered custom layer, load with safe_mode=True on Keras 3.11.3+, and never load the .h5 form of this model.',
196
+ evidence: { lambdas: lambdas.length, file },
197
+ });
198
+ }
199
+ if (!dangerous.length && foreign.length) {
200
+ out.push({
201
+ ruleId: 'keras.config_foreign_module', cls: 'SANDBOX', severity: 'MEDIUM', cwe: 'CWE-470', anchor: foreign[0],
202
+ title: `Keras config resolves layers from a non-Keras module (${foreign[0]})`,
203
+ detail: `${file} deserializes layers from \`${[...new Set(foreign)].slice(0, 3).join(', ')}\`, which is imported on load. Ordinary for a registered custom layer; a swapped module name is how config-injection gets code into a Keras load.`,
204
+ remediation: 'Confirm the module is your own registered layer package and pin it.',
205
+ evidence: { modules: [...new Set(foreign)].slice(0, 6), file },
206
+ });
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /* ─── tokenizer files ──────────────────────────────────────────────────── */
212
+
213
+ const TOKENISH_RE = /^(?:<[^<>\s]{1,40}>|<\|[^|]{1,40}\|>|\[[A-Z_\/]{1,24}\]|▁?\S{1,24})$/;
214
+
215
+ function tokenizerFindings(doc , file ) {
216
+ const out = [];
217
+ const contents = [];
218
+ const collect = (v ) => {
219
+ if (typeof v === 'string') contents.push(v);
220
+ else if (isObj(v) && typeof v.content === 'string') contents.push(v.content);
221
+ };
222
+ if (isObj(doc)) {
223
+ for (const t of Array.isArray(doc.added_tokens) ? doc.added_tokens.slice(0, 5000) : []) collect(t);
224
+ for (const t of Array.isArray(doc.additional_special_tokens) ? doc.additional_special_tokens : []) collect(t);
225
+ for (const k of ['bos_token', 'eos_token', 'unk_token', 'sep_token', 'pad_token', 'cls_token', 'mask_token']) collect(doc[k]);
226
+ if (isObj(doc.added_tokens_decoder)) for (const t of Object.values(doc.added_tokens_decoder).slice(0, 5000)) collect(t);
227
+ if (file.toLowerCase().endsWith('added_tokens.json')) for (const k of Object.keys(doc).slice(0, 5000)) contents.push(k);
228
+ }
229
+ const prose = contents.filter((c) => !TOKENISH_RE.test(c.trim()) && (c.trim().split(/\s+/).length >= 4 || /https?:\/\//i.test(c)));
230
+ const injected = prose.filter((c) => /https?:\/\//i.test(c) || inspectText(c, { categories: ['injection'] }).matches.length > 0);
231
+ if (injected.length) {
232
+ out.push({
233
+ ruleId: 'tokenizer.special_token_payload', cls: 'INJECTION_FLAW', severity: 'HIGH', cwe: 'CWE-94', anchor: injected[0].slice(0, 40),
234
+ title: 'Tokenizer defines a special token that carries instructions or a URL',
235
+ detail: `${file} registers ${injected.length} added/special token${injected.length === 1 ? '' : 's'} whose text is an instruction or a link (${JSON.stringify(injected[0].slice(0, 80))}). A special token is emitted and consumed as ONE unit the model was trained to obey - tokenizer tampering survives fine-tuning and is invisible in any prompt.`,
236
+ remediation: 'Compare this tokenizer against the base model\'s published tokenizer and restore it; do not serve a model whose special tokens were edited.',
237
+ evidence: { tokens: injected.slice(0, 5).map((t) => t.slice(0, 120)), file },
238
+ });
239
+ } else if (prose.length >= 3) {
240
+ out.push({
241
+ ruleId: 'tokenizer.prose_special_tokens', cls: 'INJECTION_FLAW', severity: 'MEDIUM', cwe: 'CWE-94', anchor: prose[0].slice(0, 40),
242
+ title: 'Tokenizer defines sentence-length special tokens',
243
+ detail: `${file} adds ${prose.length} special tokens that read as sentences rather than markers (${JSON.stringify(prose[0].slice(0, 60))}). Legitimate added tokens are delimiters; whole phrases as single tokens are a tokenizer-tampering pattern.`,
244
+ remediation: 'Diff against the base model\'s tokenizer and confirm who added these tokens and why.',
245
+ evidence: { tokens: prose.slice(0, 5).map((t) => t.slice(0, 120)), file },
246
+ });
247
+ }
248
+ return out;
249
+ }
250
+
251
+ /* ─── PEFT adapters ─────────────────────────────────────────────────────── */
252
+
253
+ function peftFindings(doc , file ) {
254
+ const out = [];
255
+ if (!isObj(doc)) return out;
256
+ if (doc.auto_mapping != null && (isObj(doc.auto_mapping) || typeof doc.auto_mapping === 'string')) {
257
+ out.push({
258
+ ruleId: 'peft.auto_mapping', cls: 'SANDBOX', severity: 'HIGH', cwe: 'CWE-829', anchor: 'auto_mapping',
259
+ title: 'Adapter config binds a custom class (auto_mapping)',
260
+ detail: `${file} sets \`auto_mapping\`, which points PEFT at a custom model/tuner class shipped with the adapter - imported when the adapter loads.`,
261
+ remediation: 'Load only adapters that use built-in PEFT tuners, or read and pin the referenced class.',
262
+ evidence: { autoMapping: doc.auto_mapping, file },
263
+ });
264
+ }
265
+ const baseModel = typeof doc.base_model_name_or_path === 'string' ? doc.base_model_name_or_path.trim() : '';
266
+ if (/^https?:\/\//i.test(baseModel)) {
267
+ out.push({
268
+ ruleId: 'peft.base_model_url', cls: 'BAD_DEPENDENCY', severity: 'MEDIUM', cwe: 'CWE-494', anchor: baseModel,
269
+ title: `Adapter pulls its base model from a URL (${baseModel.slice(0, 80)})`,
270
+ detail: `Loading this adapter fetches the base weights from \`${baseModel}\`, outside any model registry, unverified by digest.`,
271
+ remediation: 'Point base_model_name_or_path at a registry repo pinned by revision.',
272
+ evidence: { baseModel, file },
273
+ });
274
+ } else if (HUB_REPO_RE.test(baseModel) && !doc.revision) {
275
+ out.push({
276
+ ruleId: 'peft.unpinned_base', cls: 'BAD_DEPENDENCY', severity: 'LOW', cwe: 'CWE-1357', anchor: baseModel,
277
+ title: `Adapter loads an unpinned base model (${baseModel})`,
278
+ detail: `\`base_model_name_or_path: ${baseModel}\` with no \`revision\`: loading the adapter pulls whatever that repo holds today - including an auto_map, a pickle or a chat template that were not there when the adapter was reviewed.`,
279
+ remediation: 'Set `revision` to the reviewed base-model commit.',
280
+ evidence: { baseModel, file },
281
+ });
282
+ }
283
+ return out;
284
+ }
285
+
286
+ /* ─── diffusers model_index.json ────────────────────────────────────────── */
287
+
288
+ const DIFFUSERS_LIBS = new Set(['diffusers', 'transformers', 'peft', 'sentence_transformers', 'onnxruntime', 'onnxruntime.training', null ]);
289
+
290
+ function diffusersFindings(doc , file ) {
291
+ const out = [];
292
+ if (!isObj(doc)) return out;
293
+ const custom = [];
294
+ if (Array.isArray(doc._class_name)) custom.push(`pipeline ${doc._class_name.join('.')}`);
295
+ if (typeof doc.custom_pipeline === 'string' || doc.custom_pipeline != null) custom.push(`custom_pipeline ${String(doc.custom_pipeline)}`);
296
+ for (const [k, v] of Object.entries(doc)) {
297
+ if (k.startsWith('_') || !Array.isArray(v) || v.length !== 2) continue;
298
+ const lib = v[0];
299
+ if (!DIFFUSERS_LIBS.has(lib) && typeof lib === 'string') custom.push(`${k} → ${lib}.${v[1]}`);
300
+ }
301
+ if (custom.length) {
302
+ out.push({
303
+ ruleId: 'diffusers.custom_component', cls: 'SANDBOX', severity: 'HIGH', cwe: 'CWE-829', anchor: custom[0].split(/[ →.]/)[0],
304
+ title: `Diffusion pipeline loads components from custom code (${custom[0]})`,
305
+ detail: `${file} binds ${custom.slice(0, 3).join('; ')} - modules outside diffusers/transformers that from_pretrained imports. Two 2026 diffusers advisories let such code run WITHOUT trust_remote_code (a local snapshot, and \`custom_pipeline=None\` resolving to \`None.py\`), fixed in 0.38.0.`,
306
+ remediation: 'Upgrade diffusers to 0.38.0+, pin the repo to a reviewed commit, and read every component .py it ships before loading.',
307
+ evidence: { components: custom.slice(0, 10), file },
308
+ });
309
+ }
310
+ return out;
311
+ }
312
+
313
+ /* ─── sentence-transformers modules.json ────────────────────────────────── */
314
+
315
+ function stModulesFindings(doc , file ) {
316
+ if (!Array.isArray(doc)) return [];
317
+ const custom = doc.filter((m ) => isObj(m) && typeof m.type === 'string' && !/^sentence_transformers\./.test(m.type)).map((m ) => `${m.type}${m.path ? ` (${m.path})` : ''}`);
318
+ if (!custom.length) return [];
319
+ return [{
320
+ ruleId: 'sentence_transformers.custom_module', cls: 'SANDBOX', severity: 'HIGH', cwe: 'CWE-829', anchor: custom[0].split(' ')[0],
321
+ title: `Embedding model chains a custom module (${custom[0]})`,
322
+ detail: `${file} lists modules outside sentence_transformers (${custom.slice(0, 3).join(', ')}); SentenceTransformer(...) imports them to build the pipeline. trust_remote_code=False was reported as not honoured for local paths, so a copied repo runs them regardless.`,
323
+ remediation: 'Load only embedders whose modules.json names sentence_transformers.* modules, or pin and read the custom module.',
324
+ evidence: { modules: custom.slice(0, 10), file },
325
+ }];
326
+ }
327
+
328
+ /* ─── MLflow MLmodel ────────────────────────────────────────────────────── */
329
+
330
+ function mlflowFindings(doc , file ) {
331
+ const out = [];
332
+ if (!isObj(doc) || !isObj(doc.flavors)) return out;
333
+ const pyfunc = doc.flavors.python_function;
334
+ const loader = isObj(pyfunc) && typeof pyfunc.loader_module === 'string' ? pyfunc.loader_module : null;
335
+ if (loader && !/^mlflow\./.test(loader)) {
336
+ out.push({
337
+ ruleId: 'mlflow.custom_loader_module', cls: 'SANDBOX', severity: isDangerousCallable(loader) ? 'CRITICAL' : 'HIGH', cwe: 'CWE-829', anchor: loader,
338
+ title: `MLflow model loads through a custom loader module (${loader})`,
339
+ detail: `\`flavors.python_function.loader_module: ${loader}\` is imported and called by mlflow.pyfunc.load_model - the MLmodel file, not the caller, chooses what runs.`,
340
+ remediation: 'Use a built-in mlflow.* flavor, or vendor and pin the loader module you have reviewed.',
341
+ evidence: { loaderModule: loader, file },
342
+ });
343
+ }
344
+ const pickled = Object.entries(doc.flavors).filter(([, f] ) => isObj(f) && (f.cloudpickle_version || f.python_model || /pickle/i.test(String(f.serialization_format ?? f.pickled_model ?? ''))));
345
+ if (pickled.length) {
346
+ out.push({
347
+ ruleId: 'mlflow.pickled_model', cls: 'SANDBOX', severity: 'MEDIUM', cwe: 'CWE-502', anchor: pickled[0][0],
348
+ title: `MLflow model is deserialized from a pickle (${pickled.map(([k]) => k).join(', ')})`,
349
+ detail: `The ${pickled.map(([k]) => k).join(', ')} flavor stores a (cloud)pickled object; loading it runs whatever the pickle's reduce calls - the class of CVE-2024-37052..37060.`,
350
+ remediation: 'Load only MLflow models from a registry you control, and prefer a non-pickle flavor (ONNX, safetensors-backed transformers).',
351
+ evidence: { flavors: pickled.map(([k]) => k), file },
352
+ });
353
+ }
354
+ const codePaths = (isObj(pyfunc) && (pyfunc.code || pyfunc.code_paths)) || doc.code_paths;
355
+ if (codePaths) {
356
+ out.push({
357
+ ruleId: 'mlflow.code_paths', cls: 'SANDBOX', severity: 'MEDIUM', cwe: 'CWE-829', anchor: 'code',
358
+ title: 'MLflow model ships code that is imported on load',
359
+ detail: `${file} declares bundled code (\`${JSON.stringify(codePaths).slice(0, 80)}\`) that MLflow puts on sys.path and imports when the model loads.`,
360
+ remediation: 'Review and pin the bundled code; treat the model artifact as executable.',
361
+ evidence: { codePaths, file },
362
+ });
363
+ }
364
+ return out;
365
+ }
366
+
367
+ /* ─── Triton config.pbtxt ───────────────────────────────────────────────── */
368
+
369
+ function tritonFindings(text , file ) {
370
+ const out = [];
371
+ const backend = /^\s*(?:backend|platform)\s*:\s*"python"/m.test(text);
372
+ if (backend) {
373
+ out.push({
374
+ ruleId: 'triton.python_backend', cls: 'SANDBOX', severity: 'MEDIUM', cwe: 'CWE-94', anchor: '"python"',
375
+ title: 'Triton model runs on the Python backend (model.py executes on load)',
376
+ detail: `${file} selects \`backend: "python"\`: Triton imports the model directory's model.py into the server process when the model loads. Whoever can write that repository directory runs code in the inference server.`,
377
+ remediation: 'Review and pin model.py, keep the model repository read-only to the server, and run Triton 25.07+ (Python-backend shared-memory fixes CVE-2025-23319/23320/23334).',
378
+ evidence: { file },
379
+ });
380
+ }
381
+ const envPath = /key\s*:\s*"EXECUTION_ENV_PATH"[\s\S]{0,200}?string_value\s*:\s*"([^"]+)"/.exec(text);
382
+ if (envPath) {
383
+ out.push({
384
+ ruleId: 'triton.custom_execution_env', cls: 'BAD_DEPENDENCY', severity: 'MEDIUM', cwe: 'CWE-494', anchor: 'EXECUTION_ENV_PATH',
385
+ title: `Triton Python model unpacks a custom environment (${envPath[1]})`,
386
+ detail: `\`EXECUTION_ENV_PATH\` points at ${envPath[1]}, a packed Python environment the backend unpacks and runs the model inside - every package in it is code the server executes.`,
387
+ remediation: 'Build the execution environment from a pinned, scanned lockfile and store it where only the build pipeline can write.',
388
+ evidence: { path: envPath[1], file },
389
+ });
390
+ }
391
+ return out;
392
+ }
393
+
394
+ /* ─── TorchServe config.properties ──────────────────────────────────────── */
395
+
396
+ function propsOf(text ) {
397
+ const out = {};
398
+ for (const line of text.split(/\r?\n/)) {
399
+ const m = /^\s*([\w.]+)\s*[=:]\s*(.*?)\s*$/.exec(line);
400
+ if (m && !line.trim().startsWith('#')) out[m[1]] = m[2];
401
+ }
402
+ return out;
403
+ }
404
+
405
+ function torchserveFindings(text , file ) {
406
+ const out = [];
407
+ const p = propsOf(text);
408
+ const exposed = (addr ) => !!addr && /\/\/(?:0\.0\.0\.0|\[::\]|\*)(?::|$)/.test(addr);
409
+ const mgmtOpen = exposed(p.management_address);
410
+ // a wildcard is one that matches ANY HOST (`.*`, `https?://.*`, `file://.*`); `https://models.acme/.*` is scoped
411
+ const allowedAny = !('allowed_urls' in p) || String(p.allowed_urls).split(/[|,]/).some((e) => /^\s*(?:\.\*|\^?(?:https?|http\(s\)\?|https\?|file|s3|gs)(?::\/\/|:\\\/\\\/)\.\*)\s*$/i.test(e));
412
+ if (mgmtOpen) {
413
+ out.push({
414
+ ruleId: 'torchserve.management_exposed', cls: 'WEAK_AUTH', severity: allowedAny ? 'CRITICAL' : 'HIGH', cwe: 'CWE-306', anchor: 'management_address',
415
+ title: `TorchServe management API listens on every interface${allowedAny ? ' and will register models from any URL' : ''}`,
416
+ detail: `\`management_address=${p.management_address}\`${allowedAny ? ` with ${'allowed_urls' in p ? `allowed_urls=${p.allowed_urls}` : 'no allowed_urls (the default permits any http/file URL)'}` : ''}. Anyone who reaches the port can register a model archive - the ShellTorch chain (CVE-2023-43654, SSRF to RCE).`,
417
+ remediation: 'Bind management_address to 127.0.0.1, set allowed_urls to your own model bucket only, and keep token authorization on.',
418
+ evidence: { managementAddress: p.management_address, allowedUrls: p.allowed_urls ?? null, file },
419
+ });
420
+ } else if ('allowed_urls' in p && allowedAny) {
421
+ out.push({
422
+ ruleId: 'torchserve.allowed_urls_wildcard', cls: 'INSECURE_CONFIG', severity: 'MEDIUM', cwe: 'CWE-918', anchor: 'allowed_urls',
423
+ title: 'TorchServe accepts model archives from any URL',
424
+ detail: `\`allowed_urls=${p.allowed_urls}\` lets the management API fetch a .mar from anywhere - the SSRF half of ShellTorch.`,
425
+ remediation: 'Restrict allowed_urls to the exact bucket/host your models come from.',
426
+ evidence: { allowedUrls: p.allowed_urls, file },
427
+ });
428
+ }
429
+ if (/^true$/i.test(p.disable_token_authorization ?? '')) {
430
+ out.push({
431
+ ruleId: 'torchserve.token_auth_disabled', cls: 'WEAK_AUTH', severity: 'HIGH', cwe: 'CWE-306', anchor: 'disable_token_authorization',
432
+ title: 'TorchServe token authorization is disabled',
433
+ detail: '`disable_token_authorization=true` removes the API key TorchServe requires on the inference and management APIs.',
434
+ remediation: 'Remove the setting so token authorization stays on.',
435
+ evidence: { file },
436
+ });
437
+ }
438
+ if (exposed(p.inference_address) && !mgmtOpen) {
439
+ out.push({
440
+ ruleId: 'torchserve.inference_exposed', cls: 'INSECURE_CONFIG', severity: 'LOW', cwe: 'CWE-668', anchor: 'inference_address',
441
+ title: 'TorchServe inference API listens on every interface',
442
+ detail: `\`inference_address=${p.inference_address}\` - usually intended behind a load balancer; confirm the port is not reachable directly.`,
443
+ remediation: 'Bind to a private interface and front it with an authenticating proxy.',
444
+ evidence: { inferenceAddress: p.inference_address, file },
445
+ });
446
+ }
447
+ return out;
448
+ }
449
+
450
+ /* ─── LiteLLM proxy config.yaml ─────────────────────────────────────────── */
451
+
452
+ const KNOWN_CALLBACKS = /^(?:langfuse|langsmith|lunary|helicone|datadog|prometheus|otel|opentelemetry|s3|gcs_bucket|azure_storage|sentry|slack|traceloop|openmeter|athina|braintrust|logfire|arize|arize_phoenix|galileo|opik|mlflow|wandb|posthog|supabase|dynamodb|generic|custom_callback_api|email|pagerduty|lago|greenscale|literalai|humanloop|argilla|bedrock_guardrails|aporia|lakera|presidio|hide_secrets|llamaguard)$/i;
453
+ const envRef = (v ) => typeof v === 'string' && /^os\.environ\/|^\$\{?[A-Z_][A-Z0-9_]*\}?$|^os\.getenv/.test(v.trim());
454
+
455
+ function litellmFindings(doc , file ) {
456
+ const out = [];
457
+ if (!isObj(doc)) return out;
458
+ const settings = isObj(doc.litellm_settings) ? doc.litellm_settings : {};
459
+ const general = isObj(doc.general_settings) ? doc.general_settings : {};
460
+ const hooks = [];
461
+ for (const key of ['callbacks', 'success_callback', 'failure_callback', 'service_callback', 'post_call_rules', 'pre_call_hooks']) {
462
+ const v = settings[key] ?? general[key];
463
+ for (const c of Array.isArray(v) ? v : typeof v === 'string' ? [v] : []) if (typeof c === 'string' && !KNOWN_CALLBACKS.test(c) && /\./.test(c)) hooks.push(c);
464
+ }
465
+ for (const key of ['custom_auth', 'custom_sso', 'custom_key_generate']) if (typeof general[key] === 'string') hooks.push(general[key]);
466
+ if (hooks.length) {
467
+ const bad = hooks.find((h) => isDangerousCallable(h));
468
+ out.push({
469
+ ruleId: 'litellm.python_hook', cls: 'SANDBOX', severity: bad ? 'CRITICAL' : 'MEDIUM', cwe: 'CWE-94', anchor: (bad ?? hooks[0]).split('.')[0],
470
+ title: bad ? `LiteLLM config wires a host-execution function as a callback (${bad})` : `LiteLLM config imports Python callbacks (${hooks[0]})`,
471
+ detail: `${file} names ${hooks.slice(0, 3).join(', ')} as callbacks/hooks. The proxy imports and runs them on every request; a callback in the CONFIG is also invisible in the admin UI, so it persists after a compromise (CVE-2024-6825: a config callback as RCE).`,
472
+ remediation: bad ? 'Remove it and treat the proxy as compromised.' : 'Confirm each module is your own reviewed code, pinned in the proxy image.',
473
+ evidence: { hooks: hooks.slice(0, 8), file },
474
+ });
475
+ }
476
+ if (typeof general.master_key === 'string' && general.master_key && !envRef(general.master_key)) {
477
+ out.push({
478
+ ruleId: 'litellm.literal_master_key', cls: 'SECRET_EXPOSURE', severity: 'HIGH', cwe: 'CWE-798', anchor: 'master_key',
479
+ title: 'LiteLLM master key is written into the config',
480
+ detail: `\`general_settings.master_key\` is a literal in ${file}. It is the admin credential for the proxy - every virtual key, budget and model route.`,
481
+ remediation: 'Set it as `master_key: os.environ/LITELLM_MASTER_KEY` and rotate the exposed value.',
482
+ evidence: { file },
483
+ });
484
+ }
485
+ const literalKeys = [];
486
+ const plaintext = [];
487
+ for (const m of Array.isArray(doc.model_list) ? doc.model_list.slice(0, 500) : []) {
488
+ const p = isObj(m?.litellm_params) ? m.litellm_params : {};
489
+ for (const [k, v] of Object.entries(p)) {
490
+ if (/(?:^|_)(?:api_key|secret|token|password|aws_secret_access_key)$/.test(k) && typeof v === 'string' && v.length >= 8 && !envRef(v)) literalKeys.push(`${m.model_name ?? '?'}.${k}`);
491
+ }
492
+ const apiBase = typeof p.api_base === 'string' ? p.api_base : '';
493
+ if (/^http:\/\//i.test(apiBase) && !/\/\/(?:localhost|127\.|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|host\.docker\.internal)/.test(apiBase)) plaintext.push(apiBase);
494
+ }
495
+ if (literalKeys.length) {
496
+ out.push({
497
+ ruleId: 'litellm.literal_provider_key', cls: 'SECRET_EXPOSURE', severity: 'HIGH', cwe: 'CWE-798', anchor: literalKeys[0].split('.').pop() ?? null,
498
+ title: `LiteLLM config stores provider credentials as literals (${literalKeys.length})`,
499
+ detail: `${literalKeys.slice(0, 4).join(', ')} are literal values, not \`os.environ/...\` references.`,
500
+ remediation: 'Reference every provider key as os.environ/NAME and rotate the exposed ones.',
501
+ evidence: { fields: literalKeys.slice(0, 10), file },
502
+ });
503
+ }
504
+ if (plaintext.length) {
505
+ out.push({
506
+ ruleId: 'litellm.plaintext_api_base', cls: 'WEAK_AUTH', severity: 'MEDIUM', cwe: 'CWE-319', anchor: plaintext[0],
507
+ title: `LiteLLM routes a model over plaintext HTTP (${plaintext[0]})`,
508
+ detail: 'Prompts, completions and the provider key cross the network unencrypted.',
509
+ remediation: 'Use an https api_base.',
510
+ evidence: { apiBases: plaintext.slice(0, 5), file },
511
+ });
512
+ }
513
+ return out;
514
+ }
515
+
516
+ /* ─── vLLM serve config ─────────────────────────────────────────────────── */
517
+
518
+ function vllmFindings(doc , file ) {
519
+ const out = [];
520
+ if (!isObj(doc)) return out;
521
+ const get = (k ) => doc[k] ?? doc[k.replace(/-/g, '_')];
522
+ if (get('trust-remote-code') === true || /^true$/i.test(String(get('trust-remote-code') ?? ''))) {
523
+ out.push({
524
+ ruleId: 'vllm.trust_remote_code', cls: 'SANDBOX', severity: 'HIGH', cwe: 'CWE-94', anchor: 'trust',
525
+ title: 'vLLM serves with trust-remote-code on',
526
+ detail: `${file} turns on trust-remote-code: the server imports the model repo's Python when it loads (and vLLM before 0.11.1 ran auto_map code even with it off, CVE-2025-66448).`,
527
+ remediation: 'Turn it off unless the model needs it; pin the model to a reviewed revision and run vLLM 0.11.1+.',
528
+ evidence: { file },
529
+ });
530
+ }
531
+ const media = get('allowed-local-media-path');
532
+ if (typeof media === 'string' && /^(?:\/|~|\/home\/[^/]+|\/Users\/[^/]+|[A-Za-z]:\\?)$/.test(media.trim())) {
533
+ out.push({
534
+ ruleId: 'vllm.broad_media_path', cls: 'OVER_PERMISSIONED', severity: 'HIGH', cwe: 'CWE-22', anchor: 'allowed-local-media-path',
535
+ title: `vLLM lets requests read local media from ${media}`,
536
+ detail: '`allowed-local-media-path` lets a request reference `file://` media under that path - set to a root or home directory, any client can read the host\'s files through a multimodal request.',
537
+ remediation: 'Point it at a dedicated media directory, or remove it.',
538
+ evidence: { path: media, file },
539
+ });
540
+ }
541
+ const host = String(get('host') ?? '');
542
+ if (/^(?:0\.0\.0\.0|::)$/.test(host) && !get('api-key')) {
543
+ out.push({
544
+ ruleId: 'vllm.open_bind', cls: 'WEAK_AUTH', severity: 'MEDIUM', cwe: 'CWE-306', anchor: 'host',
545
+ title: 'vLLM listens on every interface without an API key',
546
+ detail: `host ${host} and no api-key: anyone who reaches the port can run inference - and, with LoRA runtime updating enabled, load adapters.`,
547
+ remediation: 'Set api-key (from an environment variable) or bind to 127.0.0.1 behind an authenticating proxy.',
548
+ evidence: { host, file },
549
+ });
550
+ }
551
+ return out;
552
+ }
553
+
554
+ /* ─── BentoML bentofile.yaml ────────────────────────────────────────────── */
555
+
556
+ const SHELL_META_RE = /[;&|`$\n\r]|\$\(/;
557
+
558
+ function bentoFindings(doc , file ) {
559
+ const out = [];
560
+ if (!isObj(doc)) return out;
561
+ const docker = isObj(doc.docker) ? doc.docker : {};
562
+ const pkgs = (Array.isArray(docker.system_packages) ? docker.system_packages : []).filter((p ) => typeof p === 'string' && SHELL_META_RE.test(p));
563
+ const envs = (Array.isArray(doc.envs) ? doc.envs : []).map((e ) => (isObj(e) ? e.name : e)).filter((n ) => typeof n === 'string' && SHELL_META_RE.test(n));
564
+ if (pkgs.length || envs.length) {
565
+ out.push({
566
+ ruleId: 'bentoml.build_injection', cls: 'SANDBOX', severity: 'HIGH', cwe: 'CWE-78', anchor: (pkgs[0] ?? envs[0]).slice(0, 30),
567
+ title: 'BentoML build config injects shell into the generated Dockerfile',
568
+ detail: `${pkgs.length ? `docker.system_packages entry ${JSON.stringify(pkgs[0])}` : `envs name ${JSON.stringify(envs[0])}`} carries shell metacharacters; BentoML writes these into the image's build commands (CVE-2026-33744 / CVE-2026-44346, fixed 1.4.39), so building the bento runs them.`,
569
+ remediation: 'Use plain package/variable names and upgrade BentoML to 1.4.39+.',
570
+ evidence: { systemPackages: pkgs.slice(0, 5), envs: envs.slice(0, 5), file },
571
+ });
572
+ }
573
+ const python = isObj(doc.python) ? doc.python : {};
574
+ const idx = [python.index_url, ...(Array.isArray(python.extra_index_url) ? python.extra_index_url : [python.extra_index_url])].filter((x) => typeof x === 'string');
575
+ const plain = idx.find((u ) => /^http:\/\//i.test(u));
576
+ if (plain || python.extra_index_url) {
577
+ out.push({
578
+ ruleId: 'bentoml.package_index', cls: 'BAD_DEPENDENCY', severity: plain ? 'HIGH' : 'MEDIUM', cwe: plain ? 'CWE-319' : 'CWE-427', anchor: plain ?? 'extra_index_url',
579
+ title: plain ? `BentoML installs Python packages over plaintext HTTP (${plain})` : 'BentoML resolves packages from an extra index (dependency confusion)',
580
+ detail: plain ? 'Every package in the image crosses the network unencrypted.' : 'With two indexes pip takes the highest version from either - a public package can shadow a private name.',
581
+ remediation: plain ? 'Use an https index.' : 'Use a single index that proxies PyPI, or pin with hashes.',
582
+ evidence: { indexes: idx, file },
583
+ });
584
+ }
585
+ if (typeof docker.setup_script === 'string') {
586
+ out.push({
587
+ ruleId: 'bentoml.setup_script', cls: 'SANDBOX', severity: 'LOW', cwe: 'CWE-78', anchor: 'setup_script',
588
+ title: `BentoML runs a setup script while building the image (${docker.setup_script})`,
589
+ detail: 'The script executes as root during the image build; it is part of the model\'s supply chain.',
590
+ remediation: 'Keep the script in the reviewed repository and pin anything it downloads.',
591
+ evidence: { script: docker.setup_script, file },
592
+ });
593
+ }
594
+ return out;
595
+ }
596
+
597
+ /* ─── YAML python tags (any model yaml) ─────────────────────────────────── */
598
+
599
+ function yamlTagFindings(text , file ) {
600
+ const m = /!!python\/(?:object(?:\/apply|\/new)?|name|module)\s*:?\s*([\w.]*)/.exec(text);
601
+ if (!m) return [];
602
+ return [{
603
+ ruleId: 'model_config.yaml_python_tag', cls: 'SANDBOX', severity: 'CRITICAL', cwe: 'CWE-502', anchor: '!!python/',
604
+ title: `Model config carries a Python object tag (${m[0].slice(0, 60)})`,
605
+ detail: `\`${m[0]}\` in ${file} makes an unsafe YAML loader import and call a Python object - loading the config IS running code (the ms-swift --run_config class, CVE-2025-50460).`,
606
+ remediation: 'Remove the tag, and load configs only with yaml.safe_load.',
607
+ evidence: { tag: m[0].slice(0, 80), file },
608
+ }];
609
+ }
610
+
611
+ /* ─── entry point ───────────────────────────────────────────────────────── */
612
+
613
+ function parseDoc(format , path , text ) {
614
+ if (format === 'triton' || format === 'torchserve') return null;
615
+ if (/\.ya?ml$/i.test(path) || format === 'mlflow') return parseYaml(text);
616
+ try {
617
+ return JSON.parse(text);
618
+ } catch {
619
+ return null;
620
+ }
621
+ }
622
+
623
+ /** Every finding a model / serving config earns. `format` is detected from the path + text when omitted. */
624
+ export function gradeModelConfig(path , text , format ) {
625
+ const t = String(text ?? '');
626
+ const fmt = format ?? modelConfigFormat(path, t);
627
+ if (!fmt || !t.trim()) return [];
628
+ const file = base(path) || path;
629
+ const doc = parseDoc(fmt, path, t);
630
+ const out = [];
631
+ switch (fmt) {
632
+ case 'hf-config': out.push(...hfConfigFindings(doc, file)); break;
633
+ case 'tokenizer': out.push(...tokenizerFindings(doc, file), ...targetFindings(doc, file, { inWeightsConfig: true })); break;
634
+ case 'peft-adapter': out.push(...peftFindings(doc, file), ...targetFindings(doc, file, { inWeightsConfig: true })); break;
635
+ case 'diffusers-index': out.push(...diffusersFindings(doc, file)); break;
636
+ case 'st-modules': out.push(...stModulesFindings(doc, file)); break;
637
+ case 'mlflow': out.push(...mlflowFindings(doc, file)); break;
638
+ case 'triton': out.push(...tritonFindings(t, file)); break;
639
+ case 'torchserve': out.push(...torchserveFindings(t, file)); break;
640
+ case 'litellm': out.push(...litellmFindings(doc, file)); break;
641
+ case 'vllm': out.push(...vllmFindings(doc, file)); break;
642
+ case 'bentoml': out.push(...bentoFindings(doc, file)); break;
643
+ case 'hydra-yaml': out.push(...targetFindings(doc, file, { inWeightsConfig: /model_config|hparams|nemo/i.test(path) })); break;
644
+ }
645
+ if (/\.ya?ml$/i.test(path) || fmt === 'mlflow') out.push(...yamlTagFindings(t, file));
646
+ return out;
647
+ }
648
+
649
+ /** True when a config.json carries a signal only this module grades (so the detector claims it as MODEL_CONFIG). */
650
+ export function hasModelConfigSignal(path , text ) {
651
+ const fmt = modelConfigFormat(path, text);
652
+ if (!fmt) return false;
653
+ if (fmt !== 'hf-config' && fmt !== 'tokenizer') return true;
654
+ return gradeModelConfig(path, text, fmt).length > 0;
655
+ }