muaddib-scanner 2.11.69 → 2.11.71

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.69",
3
+ "version": "2.11.71",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-06-07T14:26:49.150Z",
3
+ "timestamp": "2026-06-07T16:09:51.503Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -0,0 +1,178 @@
1
+ /**
2
+ * IOC feed-health alarm (Phase 2c, part 1).
3
+ *
4
+ * The audit that started the coverage plan traced the 24.5% operational-coverage
5
+ * collapse to a SILENT ops failure: the OSM feed went dark (returned 0) for weeks, so
6
+ * the IOC store went stale and nothing alarmed. This module closes that blind spot.
7
+ *
8
+ * After every IOC refresh, `checkFeedHealth` compares each feed's returned count against
9
+ * a persisted per-feed baseline. When a feed that has previously shown a healthy count
10
+ * suddenly returns 0, it raises a ONE-SHOT alarm (console + webhook) on the healthy→dark
11
+ * transition — not every cycle — and a recovery notice on dark→alive. Best-effort: a
12
+ * feed-health failure must NEVER break the IOC refresh.
13
+ *
14
+ * The decision core (`evaluateFeedHealth`) is a pure function (counts + prev state →
15
+ * alarms/recoveries/next state) so it is fully unit-testable without I/O or network.
16
+ */
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const FEED_HEALTH_FILE = process.env.MUADDIB_FEED_HEALTH_FILE ||
23
+ path.join(__dirname, '..', '..', 'data', 'feed-health.json');
24
+
25
+ // A feed must have shown at least this many IOCs at least once before a later zero counts
26
+ // as "went dark". Below this a feed is too small/volatile to alarm on (FP guard). Real
27
+ // feeds (GenSecAI/DataDog/OSV/OSM) return hundreds–thousands, so 5 is a safe floor.
28
+ const MIN_HEALTHY_BASELINE = (() => {
29
+ const n = parseInt(process.env.MUADDIB_FEED_HEALTH_MIN, 10);
30
+ return Number.isFinite(n) && n > 0 ? n : 5;
31
+ })();
32
+
33
+ function loadFeedHealth(file = FEED_HEALTH_FILE) {
34
+ try {
35
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
36
+ return (data && typeof data === 'object' && data.feeds && typeof data.feeds === 'object') ? data.feeds : {};
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+
42
+ function saveFeedHealth(state, file = FEED_HEALTH_FILE) {
43
+ try {
44
+ const dir = path.dirname(file);
45
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
46
+ const tmp = file + '.tmp';
47
+ fs.writeFileSync(tmp, JSON.stringify({ updatedAt: new Date().toISOString(), feeds: state }, null, 2));
48
+ fs.renameSync(tmp, file);
49
+ } catch (err) {
50
+ // Best-effort: a read-only / full disk must not break the refresh.
51
+ if (err && ['EROFS', 'EACCES', 'EPERM', 'ENOSPC'].includes(err.code)) return;
52
+ console.warn('[FEED-HEALTH] Failed to persist state: ' + err.message);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * PURE decision core. Given current per-feed counts and the previous state, compute:
58
+ * - alarms: feeds with a healthy baseline that returned 0 this cycle (healthy→dark edge)
59
+ * - recoveries: dark feeds that returned data again (dark→alive edge)
60
+ * - nextState: updated per-feed { lastHealthy, lastHealthyAt, dark }
61
+ * Feeds present in prevState but absent from `counts` keep their baseline (carry-forward).
62
+ *
63
+ * @param {Object<string,number>} counts
64
+ * @param {Object<string,{lastHealthy:number,lastHealthyAt:?string,dark:boolean}>} prevState
65
+ * @param {string} nowIso
66
+ */
67
+ function evaluateFeedHealth(counts, prevState, nowIso) {
68
+ const alarms = [];
69
+ const recoveries = [];
70
+ const nextState = {};
71
+
72
+ for (const feed of Object.keys(counts || {})) {
73
+ const cur = Number(counts[feed]) || 0;
74
+ const prev = (prevState && prevState[feed]) || { lastHealthy: 0, lastHealthyAt: null, dark: false };
75
+ const entry = { lastHealthy: prev.lastHealthy || 0, lastHealthyAt: prev.lastHealthyAt || null, dark: !!prev.dark };
76
+
77
+ if (cur >= MIN_HEALTHY_BASELINE) {
78
+ entry.lastHealthy = cur;
79
+ entry.lastHealthyAt = nowIso;
80
+ }
81
+
82
+ if (cur === 0) {
83
+ if ((prev.lastHealthy || 0) >= MIN_HEALTHY_BASELINE && !prev.dark) {
84
+ alarms.push({ feed, lastHealthy: prev.lastHealthy, lastHealthyAt: prev.lastHealthyAt || null });
85
+ }
86
+ entry.dark = true;
87
+ } else {
88
+ if (prev.dark) recoveries.push({ feed, count: cur });
89
+ entry.dark = false;
90
+ }
91
+ nextState[feed] = entry;
92
+ }
93
+
94
+ // Carry forward feeds not reported this cycle so their baseline is not lost.
95
+ for (const feed of Object.keys(prevState || {})) {
96
+ if (!(feed in nextState)) nextState[feed] = prevState[feed];
97
+ }
98
+
99
+ return { alarms, recoveries, nextState };
100
+ }
101
+
102
+ function buildFeedHealthAlarmEmbed(alarms, recoveries) {
103
+ const fields = [];
104
+ for (const a of alarms) {
105
+ fields.push({
106
+ name: `🔴 ${a.feed} returned 0`,
107
+ value: `Last healthy: ${a.lastHealthy} IOC(s)${a.lastHealthyAt ? ` (${a.lastHealthyAt})` : ''}. ` +
108
+ 'Feed likely down / token expired / endpoint moved — IOC store is going stale. Investigate now.',
109
+ inline: false
110
+ });
111
+ }
112
+ for (const r of (recoveries || [])) {
113
+ fields.push({ name: `🟢 ${r.feed} recovered`, value: `Now returning ${r.count} IOC(s).`, inline: false });
114
+ }
115
+ return {
116
+ embeds: [{
117
+ title: '⚠️ MUAD\'DIB IOC Feed Health',
118
+ color: alarms.length ? 0xe74c3c : 0x2ecc71,
119
+ fields,
120
+ footer: { text: 'MUAD\'DIB IOC feed-health monitor' },
121
+ timestamp: new Date().toISOString()
122
+ }]
123
+ };
124
+ }
125
+
126
+ async function _defaultDispatch(payload) {
127
+ const url = process.env.MUADDIB_WEBHOOK_URL;
128
+ if (!url) return; // no webhook configured — the console alarm already fired
129
+ try {
130
+ const { sendWebhook } = require('../webhook.js');
131
+ await sendWebhook(url, payload, { rawPayload: true });
132
+ } catch (err) {
133
+ console.warn('[FEED-HEALTH] webhook dispatch failed: ' + err.message);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Load → evaluate → persist → dispatch. Best-effort: NEVER throws (a feed-health failure
139
+ * must not break the IOC refresh). Returns { alarms, recoveries }.
140
+ *
141
+ * @param {Object<string,number>} counts - per-feed IOC counts from this refresh
142
+ * @param {Object} [opts]
143
+ * @param {function(object):Promise} [opts.dispatch] - injectable webhook sender (tests)
144
+ * @param {string} [opts.file] - state file override (tests)
145
+ */
146
+ async function checkFeedHealth(counts, opts = {}) {
147
+ try {
148
+ const file = opts.file || FEED_HEALTH_FILE;
149
+ const prev = loadFeedHealth(file);
150
+ const { alarms, recoveries, nextState } = evaluateFeedHealth(counts, prev, new Date().toISOString());
151
+ saveFeedHealth(nextState, file);
152
+
153
+ for (const a of alarms) {
154
+ console.warn(`[FEED-HEALTH] ALARM: feed "${a.feed}" returned 0 (last healthy ${a.lastHealthy}). Stale IOCs degrade coverage — check the source.`);
155
+ }
156
+ for (const r of recoveries) {
157
+ console.log(`[FEED-HEALTH] RECOVERED: feed "${r.feed}" now returns ${r.count}.`);
158
+ }
159
+ if (alarms.length || recoveries.length) {
160
+ const dispatch = opts.dispatch || _defaultDispatch;
161
+ await dispatch(buildFeedHealthAlarmEmbed(alarms, recoveries));
162
+ }
163
+ return { alarms, recoveries };
164
+ } catch (err) {
165
+ console.warn('[FEED-HEALTH] check failed (non-fatal): ' + err.message);
166
+ return { alarms: [], recoveries: [] };
167
+ }
168
+ }
169
+
170
+ module.exports = {
171
+ evaluateFeedHealth,
172
+ checkFeedHealth,
173
+ loadFeedHealth,
174
+ saveFeedHealth,
175
+ buildFeedHealthAlarmEmbed,
176
+ FEED_HEALTH_FILE,
177
+ MIN_HEALTHY_BASELINE
178
+ };
@@ -62,6 +62,28 @@ async function updateIOCs() {
62
62
  mergeIOCs(baseIOCs, githubIOCs);
63
63
  console.log(' +' + shaiHulud.packages.length + ' GenSecAI, +' + datadog.packages.length + ' DataDog, +' + osvApi.length + ' OSV API, +' + osmResult.packages.length + ' OSM npm, +' + (osmResult.pypi_packages || []).length + ' OSM PyPI');
64
64
 
65
+ // Phase 2c (feed health): a feed that previously returned data but now returns 0 is the
66
+ // silent failure mode that froze the OSM feed and collapsed coverage. Raise a one-shot
67
+ // alarm on the healthy→dark transition. Best-effort — never throws, never blocks the refresh.
68
+ // Only feeds that were actually ATTEMPTED are health-checked: OSM is token-gated, so without
69
+ // OSM_API_TOKEN it is SKIPPED (not down) — counting it would raise a false "OSM went dark"
70
+ // alarm in any no-token context (e.g. an ad-hoc `muaddib update`) against the monitor-seeded
71
+ // baseline. OSV-API is public and volatile; its small counts rarely cross MIN_HEALTHY_BASELINE,
72
+ // so the engine naturally never establishes an alarm-able baseline for it.
73
+ try {
74
+ const feedCounts = {
75
+ 'GenSecAI': shaiHulud.packages.length,
76
+ 'DataDog': datadog.packages.length,
77
+ 'OSV-API': osvApi.length
78
+ };
79
+ if (process.env.OSM_API_TOKEN) {
80
+ feedCounts['OSM'] = osmResult.packages.length + (osmResult.pypi_packages || []).length;
81
+ }
82
+ await require('./feed-health.js').checkFeedHealth(feedCounts);
83
+ } catch (e) {
84
+ console.warn('[FEED-HEALTH] skipped: ' + e.message);
85
+ }
86
+
65
87
  // Step 3b: Load existing cache IOCs (from bootstrap download or previous update)
66
88
  if (fs.existsSync(CACHE_IOC_FILE)) {
67
89
  try {
@@ -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
@@ -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.',
@@ -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