muaddib-scanner 2.11.68 → 2.11.70
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/{self-scan-v2.11.68.json → self-scan-v2.11.70.json} +1 -1
- package/src/monitor/classify.js +3 -1
- package/src/monitor/deferred-sandbox.js +47 -18
- package/src/monitor/queue.js +23 -5
- package/src/pipeline/processor.js +8 -0
- package/src/response/playbooks.js +4 -0
- package/src/rules/index.js +13 -0
- package/src/scanner/phantom-gyp.js +155 -0
- package/src/scoring.js +8 -2
package/package.json
CHANGED
package/src/monitor/classify.js
CHANGED
|
@@ -75,7 +75,9 @@ const HIGH_CONFIDENCE_MALICE_TYPES = new Set([
|
|
|
75
75
|
'ide_hook_autoexec', // .claude/settings.json SessionStart hook, .vscode/tasks.json folderOpen (Shai-Hulud)
|
|
76
76
|
'workflow_secrets_dump', // toJSON(secrets) in GitHub Actions workflow (Shai-Hulud)
|
|
77
77
|
// Phantom Gyp 2026-06: binding.gyp command-substitution = install-time RCE, quasi-never legit in benign packages
|
|
78
|
-
'gyp_command_exec'
|
|
78
|
+
'gyp_command_exec',
|
|
79
|
+
// Phantom Gyp compound (Phase 1b): configure-time <!(node x.js) × independently-malicious invoked file
|
|
80
|
+
'gyp_phantom_exec'
|
|
79
81
|
]);
|
|
80
82
|
|
|
81
83
|
// Lifecycle compound types that indicate real malicious intent beyond a simple postinstall
|
|
@@ -37,6 +37,25 @@ const DEFERRED_MIN_SCORE = 5;
|
|
|
37
37
|
// (90s) + the sandbox watchdog grace; this AbortController is belt-and-suspenders.
|
|
38
38
|
const DEFERRED_SANDBOX_TIMEOUT_MS = 150_000;
|
|
39
39
|
|
|
40
|
+
// Tier priority for the deferred queue. Phase 3 routes T1a's sandbox here (async)
|
|
41
|
+
// instead of block-waiting a scan worker, so T1a is the highest-confidence tier and
|
|
42
|
+
// must be processed first and evicted last — it must never sit behind a high-score
|
|
43
|
+
// T1b/T2. Minimal blast radius: ONLY T1a is elevated (rank 0); T1b and T2 keep the
|
|
44
|
+
// same rank (1) so their existing riskScore-DESC ordering between them is unchanged.
|
|
45
|
+
// Map (not a plain object) keeps numeric tier 2 and string tiers distinct and avoids
|
|
46
|
+
// an object-injection sink.
|
|
47
|
+
const _TIER_RANK = new Map([['1a', 0], ['1b', 1], [2, 1]]);
|
|
48
|
+
function _tierRank(tier) {
|
|
49
|
+
return _TIER_RANK.has(tier) ? _TIER_RANK.get(tier) : 1;
|
|
50
|
+
}
|
|
51
|
+
function _deferredCompare(a, b) {
|
|
52
|
+
const r = _tierRank(a.tier) - _tierRank(b.tier);
|
|
53
|
+
return r !== 0 ? r : (b.riskScore - a.riskScore);
|
|
54
|
+
}
|
|
55
|
+
function _tierLabel(tier) {
|
|
56
|
+
return tier === '1a' ? 'T1a' : tier === 2 ? 'T2' : 'T1b';
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
// ── Mutable state ──
|
|
41
60
|
const _deferredQueue = [];
|
|
42
61
|
const _deferredSeen = new Set(); // name@version dedup
|
|
@@ -55,8 +74,10 @@ let _deferredSlotBusy = false; // Dedicated slot: true while deferred sandbox
|
|
|
55
74
|
* @returns {boolean} true if enqueued, false if rejected
|
|
56
75
|
*/
|
|
57
76
|
function enqueueDeferred(item) {
|
|
58
|
-
// Guard:
|
|
59
|
-
|
|
77
|
+
// Guard: T1a (Phase 3 async-routed high-confidence tier), T1b and T2 are eligible.
|
|
78
|
+
// T1a was previously block-waited in the scan worker; it now runs on the dedicated
|
|
79
|
+
// deferred slot at top priority (see _deferredCompare).
|
|
80
|
+
if (item.tier !== '1a' && item.tier !== '1b' && item.tier !== 2) {
|
|
60
81
|
console.error(`[DEFERRED] REJECTED: ${item.name}@${item.version} — tier ${item.tier} not eligible`);
|
|
61
82
|
return false;
|
|
62
83
|
}
|
|
@@ -74,9 +95,11 @@ function enqueueDeferred(item) {
|
|
|
74
95
|
// still warrant sandbox verification — an adversary could otherwise
|
|
75
96
|
// tune their malware to fire only LOW-severity TIER1 patterns to
|
|
76
97
|
// bypass sandbox entirely.
|
|
98
|
+
// T1a is high-confidence malice by classification — it always bypasses the
|
|
99
|
+
// min-score floor (it must never be dropped before its sandbox runs).
|
|
77
100
|
const itemThreats = (item.staticResult && item.staticResult.threats) || [];
|
|
78
101
|
const hasTier1Signal = itemThreats.some(t => TIER1_TYPES.has(t.type));
|
|
79
|
-
if ((item.riskScore || 0) < DEFERRED_MIN_SCORE && !hasTier1Signal) {
|
|
102
|
+
if (item.tier !== '1a' && (item.riskScore || 0) < DEFERRED_MIN_SCORE && !hasTier1Signal) {
|
|
80
103
|
console.error(`[DEFERRED] REJECTED: ${item.name}@${item.version} — score=${item.riskScore || 0} below minimum ${DEFERRED_MIN_SCORE}, no TIER1 signal (possible classification regression)`);
|
|
81
104
|
return false;
|
|
82
105
|
}
|
|
@@ -89,16 +112,18 @@ function enqueueDeferred(item) {
|
|
|
89
112
|
return false;
|
|
90
113
|
}
|
|
91
114
|
|
|
92
|
-
// Queue full — evict lowest
|
|
115
|
+
// Queue full — evict the lowest-priority item (by tier then score) if the new
|
|
116
|
+
// item outranks it, else reject. Tier-aware so a T1a can always displace a
|
|
117
|
+
// lower-tier item even when its score is lower.
|
|
93
118
|
if (_deferredQueue.length >= DEFERRED_QUEUE_MAX) {
|
|
94
119
|
const lowest = _deferredQueue[_deferredQueue.length - 1];
|
|
95
|
-
if (item
|
|
120
|
+
if (_deferredCompare(item, lowest) < 0) {
|
|
96
121
|
const evictKey = `${lowest.name}@${lowest.version}`;
|
|
97
122
|
_deferredQueue.pop();
|
|
98
123
|
_deferredSeen.delete(evictKey);
|
|
99
|
-
console.log(`[DEFERRED] EVICTED: ${evictKey} (score=${lowest.riskScore}) to make room for ${key} (score=${item.riskScore})`);
|
|
124
|
+
console.log(`[DEFERRED] EVICTED: ${evictKey} (${_tierLabel(lowest.tier)}, score=${lowest.riskScore}) to make room for ${key} (${_tierLabel(item.tier)}, score=${item.riskScore})`);
|
|
100
125
|
} else {
|
|
101
|
-
console.log(`[DEFERRED] QUEUE FULL: ${key} (score=${item.riskScore}) rejected — all ${DEFERRED_QUEUE_MAX} items
|
|
126
|
+
console.log(`[DEFERRED] QUEUE FULL: ${key} (${_tierLabel(item.tier)}, score=${item.riskScore}) rejected — all ${DEFERRED_QUEUE_MAX} items rank higher`);
|
|
102
127
|
return false;
|
|
103
128
|
}
|
|
104
129
|
}
|
|
@@ -122,9 +147,9 @@ function enqueueDeferred(item) {
|
|
|
122
147
|
};
|
|
123
148
|
}
|
|
124
149
|
delete item.npmRegistryMeta;
|
|
125
|
-
// Sort by riskScore DESC (highest
|
|
126
|
-
_deferredQueue.sort(
|
|
127
|
-
console.log(`[DEFERRED] ENQUEUED: ${key} (tier=${item.tier
|
|
150
|
+
// Sort by tier priority then riskScore DESC (T1a first, then highest score)
|
|
151
|
+
_deferredQueue.sort(_deferredCompare);
|
|
152
|
+
console.log(`[DEFERRED] ENQUEUED: ${key} (tier=${_tierLabel(item.tier)}, score=${item.riskScore}, queue=${_deferredQueue.length})`);
|
|
128
153
|
return true;
|
|
129
154
|
}
|
|
130
155
|
|
|
@@ -133,9 +158,10 @@ function getDeferredQueue() {
|
|
|
133
158
|
}
|
|
134
159
|
|
|
135
160
|
function getDeferredQueueStats() {
|
|
136
|
-
const tierBreakdown = { t1b: 0, t2: 0 };
|
|
161
|
+
const tierBreakdown = { t1a: 0, t1b: 0, t2: 0 };
|
|
137
162
|
for (const item of _deferredQueue) {
|
|
138
|
-
if (item.tier === '
|
|
163
|
+
if (item.tier === '1a') tierBreakdown.t1a++;
|
|
164
|
+
else if (item.tier === '1b') tierBreakdown.t1b++;
|
|
139
165
|
else if (item.tier === 2) tierBreakdown.t2++;
|
|
140
166
|
}
|
|
141
167
|
return {
|
|
@@ -189,7 +215,7 @@ async function processDeferredItem(stats) {
|
|
|
189
215
|
const key = `${item.name}@${item.version}`;
|
|
190
216
|
_deferredSeen.delete(key);
|
|
191
217
|
|
|
192
|
-
console.log(`[DEFERRED] PROCESSING: ${key} (tier=${item.tier
|
|
218
|
+
console.log(`[DEFERRED] PROCESSING: ${key} (tier=${_tierLabel(item.tier)}, score=${item.riskScore}, retries=${item.retries})`);
|
|
193
219
|
|
|
194
220
|
// 4. Run sandbox on dedicated slot (bypasses shared semaphore)
|
|
195
221
|
_deferredSlotBusy = true;
|
|
@@ -198,10 +224,13 @@ async function processDeferredItem(stats) {
|
|
|
198
224
|
const deadline = setTimeout(() => ac.abort(), DEFERRED_SANDBOX_TIMEOUT_MS);
|
|
199
225
|
try {
|
|
200
226
|
const canary = isCanaryEnabled();
|
|
201
|
-
//
|
|
202
|
-
//
|
|
227
|
+
// T1a keeps multi-run time-bomb detection (maxRuns=undefined) — that was its
|
|
228
|
+
// behavior on the old blocking in-worker path, preserved here for detection
|
|
229
|
+
// parity (Phase 3 only moves WHERE it runs, not how thoroughly). T1b/T2 stay
|
|
230
|
+
// single-run (maxRuns=1, ~90s vs ~270s) for fast deferred-queue drain.
|
|
231
|
+
const maxRuns = item.tier === '1a' ? undefined : 1;
|
|
203
232
|
markSandboxed(item.name); // stamp for sandbox-revalidation cadence (matches the synchronous path)
|
|
204
|
-
sandboxResult = await runSandbox(item.name, { canary, skipSemaphore: true, maxRuns
|
|
233
|
+
sandboxResult = await runSandbox(item.name, { canary, skipSemaphore: true, maxRuns, signal: ac.signal });
|
|
205
234
|
console.log(`[DEFERRED] SANDBOX COMPLETE: ${key} -> score=${sandboxResult.score}, severity=${sandboxResult.severity}`);
|
|
206
235
|
} catch (err) {
|
|
207
236
|
console.error(`[DEFERRED] SANDBOX ERROR: ${key} — ${err.message}`);
|
|
@@ -212,7 +241,7 @@ async function processDeferredItem(stats) {
|
|
|
212
241
|
// Re-enqueue for retry
|
|
213
242
|
_deferredQueue.push(item);
|
|
214
243
|
_deferredSeen.add(key);
|
|
215
|
-
_deferredQueue.sort(
|
|
244
|
+
_deferredQueue.sort(_deferredCompare);
|
|
216
245
|
console.log(`[DEFERRED] RE-ENQUEUED: ${key} for retry (attempt ${item.retries + 1}/${DEFERRED_MAX_RETRIES})`);
|
|
217
246
|
}
|
|
218
247
|
return null;
|
|
@@ -414,7 +443,7 @@ function restoreDeferredQueue() {
|
|
|
414
443
|
}
|
|
415
444
|
|
|
416
445
|
// Sort after bulk insert
|
|
417
|
-
_deferredQueue.sort(
|
|
446
|
+
_deferredQueue.sort(_deferredCompare);
|
|
418
447
|
|
|
419
448
|
if (restored > 0) {
|
|
420
449
|
console.log(`[DEFERRED] Restored ${restored} items from disk (saved at ${data.savedAt})`);
|
package/src/monitor/queue.js
CHANGED
|
@@ -950,10 +950,23 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
950
950
|
const maxRuns = tier === '1a' ? undefined : 1;
|
|
951
951
|
|
|
952
952
|
if (tier === '1a') {
|
|
953
|
-
//
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
953
|
+
// Phase 3 (throughput decoupling): T1a no longer block-waits a scan
|
|
954
|
+
// worker. The high-confidence STATIC alert still fires synchronously
|
|
955
|
+
// below (trySendWebhook, with sandboxResult=null — same as the T1b/T2
|
|
956
|
+
// defer paths today); the sandbox runs ASYNC on the dedicated deferred
|
|
957
|
+
// slot at top priority (processed first, never evicted, keeps multi-run
|
|
958
|
+
// time-bomb detection) and sends a follow-up webhook if it confirms.
|
|
959
|
+
// Crash-safe: the deferred queue is persisted across restarts, unlike
|
|
960
|
+
// the old in-worker await which lost the sandbox on an OOM restart.
|
|
961
|
+
console.log(`[MONITOR] SANDBOX DEFER (T1a, async high-priority): ${name}@${version} (score=${riskScore})`);
|
|
962
|
+
enqueueDeferred({
|
|
963
|
+
name, version, ecosystem, tier, riskScore, tarballUrl,
|
|
964
|
+
enqueuedAt: Date.now(),
|
|
965
|
+
staticResult: result,
|
|
966
|
+
npmRegistryMeta,
|
|
967
|
+
retries: 0
|
|
968
|
+
});
|
|
969
|
+
stats.sandboxDeferred = (stats.sandboxDeferred || 0) + 1;
|
|
957
970
|
} else if (tryAcquireSandboxSlot()) {
|
|
958
971
|
// T1b/T2: non-blocking — slot acquired atomically, run with skipSemaphore
|
|
959
972
|
const reason = tier === 2 ? ' (T2, queue low)' : ' (T1b, conditional)';
|
|
@@ -1148,8 +1161,13 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
1148
1161
|
// Safety: never suppress packages with high-confidence threats or positive sandbox
|
|
1149
1162
|
const hasHC = hasHighConfidenceThreat(result);
|
|
1150
1163
|
const hasSandboxEvidence = sandboxResult && sandboxResult.score > 0;
|
|
1164
|
+
// Phase 3: T1a sandboxes are now deferred (async), so sandboxResult is
|
|
1165
|
+
// null here — the sandbox evidence that previously guarded a T1a from
|
|
1166
|
+
// LLM suppression hasn't run yet. Never let the LLM clear a T1a before
|
|
1167
|
+
// its deferred sandbox confirms; T1a is the highest-confidence tier and
|
|
1168
|
+
// MUST get sandbox verification (it can come via the follow-up webhook).
|
|
1151
1169
|
if (llmMode === 'active' && llmResult.verdict === 'benign' && llmResult.confidence > 0.85
|
|
1152
|
-
&& !hasHC && !hasSandboxEvidence) {
|
|
1170
|
+
&& !hasHC && !hasSandboxEvidence && tier !== '1a') {
|
|
1153
1171
|
console.log(`[LLM] SUPPRESS: ${name}@${version} cleared (benign, confidence=${llmResult.confidence})`);
|
|
1154
1172
|
stats.llmSuppressed = (stats.llmSuppressed || 0) + 1;
|
|
1155
1173
|
stats.scanned++;
|
|
@@ -3,6 +3,7 @@ const path = require('path');
|
|
|
3
3
|
const { getRule, getRuleDomain } = require('../rules/index.js');
|
|
4
4
|
const { getPlaybook } = require('../response/playbooks.js');
|
|
5
5
|
const { computeReachableFiles, computeReachableFunctions } = require('../scanner/reachability.js');
|
|
6
|
+
const { correlatePhantomGyp } = require('../scanner/phantom-gyp.js');
|
|
6
7
|
const { applyFPReductions, applyCompoundBoosts, calculateRiskScore, getSeverityWeights, applyContextualFPCaps, applySingleFireCriticalFloor, applyReputationFactor, applyMatureStableCap, applySandboxVerdict, applyDeltaMultiplier } = require('../scoring.js');
|
|
7
8
|
const { loadPriorVersionSignatures, computeSignatures, saveCachedSignatures } = require('../scoring/delta-multiplier.js');
|
|
8
9
|
const { annotateConfidenceTiers } = require('../rules/confidence-tiers.js');
|
|
@@ -442,6 +443,13 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
|
|
|
442
443
|
// that were individually downgraded (count-based, dist, reachability, delta).
|
|
443
444
|
applyCompoundBoosts(deduped, targetPath);
|
|
444
445
|
|
|
446
|
+
// Phase 1b — Phantom-Gyp compound: a configure-time <!(node x.js) sink in binding.gyp
|
|
447
|
+
// × the invoked file's INDEPENDENT malice verdict (from the AST/dataflow/module-graph
|
|
448
|
+
// findings already in `deduped`) → CRITICAL gyp_phantom_exec. Runs here so it sees the
|
|
449
|
+
// full post-scan, post-FP-reduction verdict set. FP≈0 by construction (it only ever
|
|
450
|
+
// ADDS a finding when the invoked file is independently judged malicious).
|
|
451
|
+
correlatePhantomGyp(deduped, targetPath);
|
|
452
|
+
|
|
445
453
|
// Intent coherence analysis: detect source→sink pairs within files
|
|
446
454
|
// Pass targetPath for destination-aware SDK pattern detection
|
|
447
455
|
const intentResult = buildIntentPairs(deduped, targetPath);
|
|
@@ -1005,6 +1005,10 @@ const PLAYBOOKS = {
|
|
|
1005
1005
|
'CRITIQUE: binding.gyp utilise la command-substitution GYP <!(...) / <!@(...) — execution de code a l\'installation via node-gyp, sans script lifecycle (pattern Phantom Gyp). ' +
|
|
1006
1006
|
'Decoder la commande substituee. NE PAS installer : node-gyp l\'execute au build meme avec --ignore-scripts. Verifier la source officielle du package.',
|
|
1007
1007
|
|
|
1008
|
+
gyp_phantom_exec:
|
|
1009
|
+
'CRITIQUE (compound Phase 1b): binding.gyp lance <!(node x.js) / <!(python y.py) au configure-time via node-gyp (aucun script lifecycle requis) ET le fichier invoque est juge malveillant de facon independante par les scanners AST/dataflow/module-graph. ' +
|
|
1010
|
+
'Payload install-time confirme — NE PAS installer. Analyser le fichier invoque (nomme dans le message), c\'est lui qui porte la charge. node-gyp l\'execute meme avec --ignore-scripts.',
|
|
1011
|
+
|
|
1008
1012
|
string_mutation_obfuscation:
|
|
1009
1013
|
'HAUTE: Chaine de .replace() reconstruisant des noms d\'API dangereuses (leet-speak). ' +
|
|
1010
1014
|
'Technique d\'evasion par substitution de caracteres. Decoder la chaine finale. Supprimer si malveillant.',
|
package/src/rules/index.js
CHANGED
|
@@ -796,6 +796,19 @@ const RULES = {
|
|
|
796
796
|
],
|
|
797
797
|
mitre: 'T1082'
|
|
798
798
|
},
|
|
799
|
+
gyp_phantom_exec: {
|
|
800
|
+
id: 'MUADDIB-COMPOUND-017',
|
|
801
|
+
name: 'Phantom Gyp Install-Time Payload',
|
|
802
|
+
severity: 'CRITICAL',
|
|
803
|
+
confidence: 'high',
|
|
804
|
+
domain: 'malware',
|
|
805
|
+
description: 'binding.gyp exécute <!(node x.js) / <!(python y.py) au configure-time via node-gyp (npm le lance à l\'install dès qu\'un binding.gyp est présent, sans script lifecycle) ET le fichier invoqué (x.js) est jugé malveillant de façon indépendante par les scanners AST/dataflow/module-graph (verdict CRITICAL ou HIGH_CONFIDENCE_MALICE non-LOW sur ce fichier exact). Compound Phase 1b qui ferme le gap du speed-bump gyp_command_exec (MUADDIB-PKG-023) : la forme dominante <!(node setup.js) nu, statiquement indistinguable d\'un build-helper bénin, n\'est flaggée QUE quand le script invoqué porte lui-même un verdict de malice → FPR≈0 par construction.',
|
|
806
|
+
references: [
|
|
807
|
+
'https://attack.mitre.org/techniques/T1195/002/',
|
|
808
|
+
'https://attack.mitre.org/techniques/T1059/'
|
|
809
|
+
],
|
|
810
|
+
mitre: 'T1195.002'
|
|
811
|
+
},
|
|
799
812
|
|
|
800
813
|
// Package.json script patterns
|
|
801
814
|
curl_pipe_sh: {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phantom-Gyp compound correlator (Phase 1b — the real fix).
|
|
3
|
+
*
|
|
4
|
+
* The line-by-line `gyp_command_exec` detector (src/scanner/package.js, MUADDIB-PKG-023)
|
|
5
|
+
* is an FP-first SPEED-BUMP: it flags a binding.gyp command-substitution `<!(...)` only
|
|
6
|
+
* when the command line itself carries a malice marker (curl, pipe-to-shell, inline
|
|
7
|
+
* network payload…). The dominant Phantom-Gyp shape — a bare `<!(node setup.js)` whose
|
|
8
|
+
* payload lives in setup.js — is statically INDISTINGUISHABLE from a legit build helper
|
|
9
|
+
* (`<!(node ./util/has_lib.js)`), so the speed-bump deliberately lets it pass to honor
|
|
10
|
+
* "FPR must never increase".
|
|
11
|
+
*
|
|
12
|
+
* This post-processor closes that gap WITHOUT any FP cost by compounding two signals:
|
|
13
|
+
* (sink) a `<!(node x.js)` / `<!(python y.py)` command-substitution in binding.gyp,
|
|
14
|
+
* which node-gyp runs at *configure* time on install — no lifecycle script
|
|
15
|
+
* needed; and
|
|
16
|
+
* (verdict) the invoked file (x.js) being INDEPENDENTLY judged malicious by the proven
|
|
17
|
+
* AST / dataflow / module-graph scanners (a CRITICAL finding, or a non-LOW
|
|
18
|
+
* HIGH_CONFIDENCE_MALICE_TYPES finding) on that exact file.
|
|
19
|
+
* Only when BOTH hold do we emit `gyp_phantom_exec` (CRITICAL). The verdict comes from
|
|
20
|
+
* the existing scanners, so the false-positive rate is bounded by theirs → FP≈0 by
|
|
21
|
+
* construction. A benign build helper invoked the same way produces no malice verdict, so
|
|
22
|
+
* nothing fires and the package gains zero new findings.
|
|
23
|
+
*
|
|
24
|
+
* Runs as a post-processor (it needs the full, post-scan threats array) — it re-reads
|
|
25
|
+
* binding.gyp directly rather than threading a marker threat through FP reductions, so a
|
|
26
|
+
* benign package never carries any intermediate Phantom-Gyp signal.
|
|
27
|
+
*/
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const { HIGH_CONFIDENCE_MALICE_TYPES } = require('../monitor/classify.js');
|
|
33
|
+
|
|
34
|
+
// Command-substitution capture: the required `!` gates command execution. Plain
|
|
35
|
+
// `<(...)` / `<@(...)` (variable expansion, benign) is intentionally NOT matched —
|
|
36
|
+
// flagging it would be a hard false positive. We capture only up to the FIRST closing
|
|
37
|
+
// `)` so each command-sub body is isolated (unlike package.js's danger-marker scan, the
|
|
38
|
+
// script-file extraction needs the exact command, not a 400-char window).
|
|
39
|
+
const GYP_CMDSUB_RE = /<!@?\(([^)\n]{0,400})\)/g;
|
|
40
|
+
// Interpreter at the start of the command body that runs a SCRIPT FILE argument.
|
|
41
|
+
const SCRIPT_INTERP_RE = /^\s*(node|nodejs|python[0-9.]*|ruby|perl)\b(.*)$/i;
|
|
42
|
+
// Recognized script-file extensions (kept tight — a bare token without one is not
|
|
43
|
+
// assumed to be a script, to avoid matching subcommands like "rebuild").
|
|
44
|
+
const SCRIPT_FILE_RE = /\.(?:js|cjs|mjs|py|rb|pl)$/i;
|
|
45
|
+
// Inline-eval flags mean the payload is INLINE (no script file) — that case belongs to
|
|
46
|
+
// the gyp_command_exec speed-bump, not here, so we skip the command-sub entirely.
|
|
47
|
+
const INLINE_EVAL_FLAG_RE = /^--?(?:e|p|c|eval|print)$/i;
|
|
48
|
+
|
|
49
|
+
/** Normalize a relative path for comparison: backslashes→/, strip leading ./ and /. */
|
|
50
|
+
function _normRel(f) {
|
|
51
|
+
return String(f || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extract the script files invoked by `<!(interpreter file …)` command-substitutions in
|
|
56
|
+
* a binding.gyp. Inline-eval forms (`node -e …`) and non-script interpreter queries
|
|
57
|
+
* (`node -p "require('node-addon-api').include"`) yield no file and are skipped.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} gypContent - raw binding.gyp text
|
|
60
|
+
* @returns {Array<{interpreter:string, file:string}>}
|
|
61
|
+
*/
|
|
62
|
+
function extractGypInvokedScripts(gypContent) {
|
|
63
|
+
const out = [];
|
|
64
|
+
if (!gypContent || typeof gypContent !== 'string') return out;
|
|
65
|
+
GYP_CMDSUB_RE.lastIndex = 0;
|
|
66
|
+
let m;
|
|
67
|
+
while ((m = GYP_CMDSUB_RE.exec(gypContent)) !== null) {
|
|
68
|
+
const body = m[1];
|
|
69
|
+
const im = SCRIPT_INTERP_RE.exec(body);
|
|
70
|
+
if (!im) continue;
|
|
71
|
+
const interpreter = im[1].toLowerCase();
|
|
72
|
+
const tokens = (im[2] || '').trim().split(/\s+/).filter(Boolean);
|
|
73
|
+
let scriptFile = null;
|
|
74
|
+
for (const tok of tokens) {
|
|
75
|
+
if (tok.startsWith('-')) {
|
|
76
|
+
// An inline-eval flag means there is no script file in this command-sub.
|
|
77
|
+
if (INLINE_EVAL_FLAG_RE.test(tok)) { scriptFile = null; break; }
|
|
78
|
+
continue; // some other flag — keep scanning for the script argument
|
|
79
|
+
}
|
|
80
|
+
const clean = tok.replace(/^['"]+|['"]+$/g, '');
|
|
81
|
+
if (SCRIPT_FILE_RE.test(clean)) { scriptFile = clean; break; }
|
|
82
|
+
}
|
|
83
|
+
if (scriptFile) out.push({ interpreter, file: scriptFile });
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* True when a threat is a high-confidence malice verdict on its file: a CRITICAL of any
|
|
90
|
+
* type, or a non-LOW finding of a HIGH_CONFIDENCE_MALICE_TYPES type. This reuses the
|
|
91
|
+
* established "quasi-never legit" judgment rather than inventing a new bar.
|
|
92
|
+
*/
|
|
93
|
+
function _isMaliceVerdict(t) {
|
|
94
|
+
if (!t || !t.type) return false;
|
|
95
|
+
if (t.type === 'gyp_phantom_exec') return false; // never self-reference
|
|
96
|
+
if (t.severity === 'CRITICAL') return true;
|
|
97
|
+
if (t.severity !== 'LOW' && HIGH_CONFIDENCE_MALICE_TYPES.has(t.type)) return true;
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Phantom-Gyp compound: for each `<!(node x.js)` in binding.gyp, if x.js is independently
|
|
103
|
+
* judged malicious in the same scan, push one CRITICAL `gyp_phantom_exec` threat. Mutates
|
|
104
|
+
* `threats` in place. Best-effort and side-effect-free on benign packages (no malice
|
|
105
|
+
* verdict on the invoked file ⇒ no push, no marker).
|
|
106
|
+
*
|
|
107
|
+
* @param {Array<object>} threats - the deduplicated, post-scan threats array
|
|
108
|
+
* @param {string} targetPath - scan target directory (where binding.gyp lives)
|
|
109
|
+
* @returns {object|null} the pushed compound threat, or null if nothing fired
|
|
110
|
+
*/
|
|
111
|
+
function correlatePhantomGyp(threats, targetPath) {
|
|
112
|
+
if (!Array.isArray(threats) || !targetPath) return null;
|
|
113
|
+
if (threats.some(t => t && t.type === 'gyp_phantom_exec')) return null; // idempotent
|
|
114
|
+
|
|
115
|
+
let gypContent;
|
|
116
|
+
try {
|
|
117
|
+
const gypPath = path.join(targetPath, 'binding.gyp');
|
|
118
|
+
if (!fs.existsSync(gypPath)) return null;
|
|
119
|
+
gypContent = fs.readFileSync(gypPath, 'utf8');
|
|
120
|
+
} catch { return null; }
|
|
121
|
+
|
|
122
|
+
const scripts = extractGypInvokedScripts(gypContent);
|
|
123
|
+
if (scripts.length === 0) return null;
|
|
124
|
+
|
|
125
|
+
for (const { interpreter, file } of scripts) {
|
|
126
|
+
const norm = _normRel(file);
|
|
127
|
+
const base = norm.split('/').pop();
|
|
128
|
+
const hasDir = norm.includes('/');
|
|
129
|
+
const malice = threats.find(t => {
|
|
130
|
+
if (!t || !t.file || !_isMaliceVerdict(t)) return false;
|
|
131
|
+
const tf = _normRel(t.file);
|
|
132
|
+
if (tf === norm) return true;
|
|
133
|
+
// A bare `<!(node loader.js)` ref (no directory) matches the invoked file by
|
|
134
|
+
// basename — binding.gyp refs resolve relative to the package root, the same
|
|
135
|
+
// base the scanners use for threat.file (path.relative(targetPath, …)).
|
|
136
|
+
if (!hasDir && tf.split('/').pop() === base) return true;
|
|
137
|
+
return false;
|
|
138
|
+
});
|
|
139
|
+
if (malice) {
|
|
140
|
+
const compound = {
|
|
141
|
+
type: 'gyp_phantom_exec',
|
|
142
|
+
severity: 'CRITICAL',
|
|
143
|
+
message: `binding.gyp runs <!(${interpreter} ${file}) at configure-time via node-gyp (no lifecycle script required) and ${file} is independently judged malicious (${malice.type}/${malice.severity}) — Phantom Gyp install-time payload (compound).`,
|
|
144
|
+
file: 'binding.gyp',
|
|
145
|
+
compound: true,
|
|
146
|
+
count: 1
|
|
147
|
+
};
|
|
148
|
+
threats.push(compound);
|
|
149
|
+
return compound; // one compound per package is enough
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { extractGypInvokedScripts, correlatePhantomGyp, _normRel, _isMaliceVerdict };
|
package/src/scoring.js
CHANGED
|
@@ -132,7 +132,9 @@ const PACKAGE_LEVEL_TYPES = new Set([
|
|
|
132
132
|
// audit MR-C1: informational signal that the scan target is a monorepo root (per-workspace scoring TBD)
|
|
133
133
|
'monorepo_detected',
|
|
134
134
|
// Phantom Gyp: binding.gyp command-substitution is a package-level (manifest) finding
|
|
135
|
-
'gyp_command_exec'
|
|
135
|
+
'gyp_command_exec',
|
|
136
|
+
// Phantom Gyp compound (Phase 1b): configure-time <!(node x.js) × malicious invoked file
|
|
137
|
+
'gyp_phantom_exec'
|
|
136
138
|
]);
|
|
137
139
|
|
|
138
140
|
// ============================================
|
|
@@ -160,7 +162,11 @@ const SINGLE_FIRE_CRITICAL_TYPES = new Set([
|
|
|
160
162
|
'known_malicious_package',
|
|
161
163
|
'pypi_malicious_package',
|
|
162
164
|
'shai_hulud_marker',
|
|
163
|
-
'lifecycle_shell_pipe'
|
|
165
|
+
'lifecycle_shell_pipe',
|
|
166
|
+
// Phantom Gyp compound (Phase 1b): only fires when the invoked configure-time script
|
|
167
|
+
// is INDEPENDENTLY judged malicious, so it carries the same unambiguous-malware weight
|
|
168
|
+
// as the IOC/hash matches above — FP≈0 by construction justifies the single-fire floor.
|
|
169
|
+
'gyp_phantom_exec'
|
|
164
170
|
]);
|
|
165
171
|
const SINGLE_FIRE_CRITICAL_FLOOR = 75;
|
|
166
172
|
const SINGLE_FIRE_MIN_SEVERITY_RANK = 2; // HIGH
|