muaddib-scanner 2.11.16 → 2.11.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/pipeline/executor.js +26 -8
- package/src/response/playbooks.js +18 -0
- package/src/rules/index.js +54 -0
- package/src/scanner/dataflow.js +77 -8
- package/src/scanner/module-graph/build-graph.js +14 -1
- package/src/scanner/module-graph/constants.js +1 -1
- package/src/scanner/monorepo.js +104 -0
- package/src/scanner/reachability.js +14 -0
- package/src/scanner/typosquat.js +165 -3
- package/src/scoring.js +19 -2
package/package.json
CHANGED
package/src/pipeline/executor.js
CHANGED
|
@@ -9,6 +9,7 @@ const { scanHashes } = require('../scanner/hash.js');
|
|
|
9
9
|
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
|
+
const { scanMonorepo } = require('../scanner/monorepo.js');
|
|
12
13
|
const { analyzeDataFlow } = require('../scanner/dataflow.js');
|
|
13
14
|
const { scanTyposquatting, findPyPITyposquatMatch } = require('../scanner/typosquat.js');
|
|
14
15
|
const { scanGitHubActions } = require('../scanner/github-actions.js');
|
|
@@ -127,12 +128,25 @@ async function execute(targetPath, options, pythonDeps, warnings) {
|
|
|
127
128
|
// Bounded: 5s timeout to prevent DoS on large/adversarial packages
|
|
128
129
|
const MODULE_GRAPH_TIMEOUT_MS = 5000;
|
|
129
130
|
let crossFileFlows = [];
|
|
131
|
+
// Threats ABOUT the module graph (audit DF-C1): truncation when the package
|
|
132
|
+
// exceeds MAX_GRAPH_NODES. Separate from crossFileFlows because the latter
|
|
133
|
+
// gets filtered/reshaped (line ~316 requires sourceFile && sinkFile).
|
|
134
|
+
const moduleGraphThreats = [];
|
|
130
135
|
if (!options.noModuleGraph) {
|
|
131
136
|
const moduleGraphWork = async () => {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
warnings.push(
|
|
137
|
+
const graphMeta = {};
|
|
138
|
+
const graph = await yieldThen(() => buildModuleGraph(targetPath, graphMeta));
|
|
139
|
+
if (graphMeta.truncated) {
|
|
140
|
+
warnings.push(`Module graph skipped: ${graphMeta.fileCount} files exceeds MAX_GRAPH_NODES (${graphMeta.maxNodes})`);
|
|
141
|
+
moduleGraphThreats.push({
|
|
142
|
+
type: 'large_package_graph_truncated',
|
|
143
|
+
severity: 'MEDIUM',
|
|
144
|
+
message: `Cross-file analysis désactivée : ${graphMeta.fileCount} fichiers dépassent la limite (${graphMeta.maxNodes}). Risque de blind spot sur monorepo / large package — auditer les sous-modules manuellement.`,
|
|
145
|
+
file: 'package.json',
|
|
146
|
+
line: 0,
|
|
147
|
+
fileCount: graphMeta.fileCount,
|
|
148
|
+
maxNodes: graphMeta.maxNodes
|
|
149
|
+
});
|
|
136
150
|
}
|
|
137
151
|
const tainted = await yieldThen(() => annotateTaintedExports(graph, targetPath));
|
|
138
152
|
const sinkAnnotations = await yieldThen(() => annotateSinkExports(graph, targetPath));
|
|
@@ -187,7 +201,7 @@ async function execute(targetPath, options, pythonDeps, warnings) {
|
|
|
187
201
|
'scanDependencies', 'scanHashes', 'analyzeDataFlow', 'scanTyposquatting',
|
|
188
202
|
'scanGitHubActions', 'matchPythonIOCs', 'checkPyPITyposquatting',
|
|
189
203
|
'scanEntropy', 'scanAIConfig', 'scanIocStrings', 'scanAntiForensic',
|
|
190
|
-
'scanStubPackage'
|
|
204
|
+
'scanStubPackage', 'scanMonorepo'
|
|
191
205
|
];
|
|
192
206
|
|
|
193
207
|
const settledResults = await Promise.allSettled([
|
|
@@ -206,7 +220,8 @@ async function execute(targetPath, options, pythonDeps, warnings) {
|
|
|
206
220
|
yieldThen(() => scanAIConfig(targetPath)),
|
|
207
221
|
yieldThen(() => scanIocStrings(targetPath)),
|
|
208
222
|
withTimeout(() => scanAntiForensic(targetPath), 'scanAntiForensic'),
|
|
209
|
-
yieldThen(() => scanStubPackage(targetPath))
|
|
223
|
+
yieldThen(() => scanStubPackage(targetPath)),
|
|
224
|
+
yieldThen(() => scanMonorepo(targetPath))
|
|
210
225
|
]);
|
|
211
226
|
|
|
212
227
|
// Extract results: use empty array for rejected scanners, log errors
|
|
@@ -234,7 +249,8 @@ async function execute(targetPath, options, pythonDeps, warnings) {
|
|
|
234
249
|
aiConfigThreats,
|
|
235
250
|
iocStringThreats,
|
|
236
251
|
antiForensicThreats,
|
|
237
|
-
stubPackageThreats
|
|
252
|
+
stubPackageThreats,
|
|
253
|
+
monorepoThreats
|
|
238
254
|
] = scanResult;
|
|
239
255
|
|
|
240
256
|
// Emit warning if file count cap was hit + quick-scan overflow files
|
|
@@ -313,12 +329,14 @@ async function execute(targetPath, options, pythonDeps, warnings) {
|
|
|
313
329
|
...iocStringThreats,
|
|
314
330
|
...antiForensicThreats,
|
|
315
331
|
...stubPackageThreats,
|
|
332
|
+
...monorepoThreats,
|
|
316
333
|
...crossFileFlows.filter(f => f && f.sourceFile && f.sinkFile).map(f => ({
|
|
317
334
|
type: f.type,
|
|
318
335
|
severity: f.severity,
|
|
319
336
|
message: `Cross-file dataflow: ${f.source} in ${f.sourceFile} → ${f.sink} in ${f.sinkFile}`,
|
|
320
337
|
file: f.sinkFile
|
|
321
|
-
}))
|
|
338
|
+
})),
|
|
339
|
+
...moduleGraphThreats
|
|
322
340
|
];
|
|
323
341
|
|
|
324
342
|
// Paranoid mode
|
|
@@ -161,6 +161,14 @@ const PLAYBOOKS = {
|
|
|
161
161
|
typosquat_detected:
|
|
162
162
|
'ATTENTION: Ce package a un nom tres similaire a un package populaire. Verifier que c\'est bien le bon package. Si erreur de frappe, corriger immediatement.',
|
|
163
163
|
|
|
164
|
+
// RT-C1: dependency boundary-squat (Axios UNC1069 March 2026)
|
|
165
|
+
dependency_typosquat:
|
|
166
|
+
'Une dependance declaree ressemble a un package populaire avec un prefixe/suffixe suspect. Verifier le nom exact dans package.json et confirmer avec npm view <package>. Si erreur de frappe, corriger immediatement.',
|
|
167
|
+
dependency_typosquat_used:
|
|
168
|
+
'Le code charge cette dep typosquattee via require/import. Si ce n\'est pas intentionnel, supprimer la dep et la reference, puis reinstaller avec --ignore-scripts.',
|
|
169
|
+
dependency_typosquat_require:
|
|
170
|
+
'CRITIQUE — pattern Axios UNC1069 detecte: dep typosquattee declaree ET chargee dans le code. Le wrapper apparent est probablement legitime mais sa dep contient le payload. Bloquer l\'install (--ignore-scripts), supprimer la dep, auditer le history de modifications.',
|
|
171
|
+
|
|
164
172
|
dangerous_call_function:
|
|
165
173
|
'Appel new Function() detecte. Equivalent a eval(). Verifier la source des donnees.',
|
|
166
174
|
|
|
@@ -675,6 +683,16 @@ const PLAYBOOKS = {
|
|
|
675
683
|
'Verifier si des donnees sensibles sont envoyees via ce canal. ' +
|
|
676
684
|
'Les proxies HTTP classiques ne filtrent pas ce trafic.',
|
|
677
685
|
|
|
686
|
+
large_package_graph_truncated:
|
|
687
|
+
'Package volumineux (> MAX_GRAPH_NODES fichiers). Cross-file dataflow non analyse. ' +
|
|
688
|
+
'Auditer les sous-modules manuellement ou scanner par sous-paquet. ' +
|
|
689
|
+
'Sur un monorepo, scanner chaque workspace independamment.',
|
|
690
|
+
|
|
691
|
+
monorepo_detected:
|
|
692
|
+
'Monorepo detecte — scanner chaque workspace individuellement pour un verdict per-package. ' +
|
|
693
|
+
'Le score global reflete un perimetre agrege ; muaddib ne supporte pas encore le scoring per-workspace. ' +
|
|
694
|
+
'Ignorer les FP structurels (.yarn/, packages/*/test/, fixtures/) sera ajoute en v2.12.',
|
|
695
|
+
|
|
678
696
|
bin_field_hijack:
|
|
679
697
|
'CRITIQUE: Le champ "bin" de package.json shadow une commande systeme (node, npm, git, bash, etc.). ' +
|
|
680
698
|
'A l\'installation, npm cree un symlink dans node_modules/.bin/ qui intercepte la commande reelle. ' +
|
package/src/rules/index.js
CHANGED
|
@@ -94,6 +94,17 @@ const RULES = {
|
|
|
94
94
|
],
|
|
95
95
|
mitre: 'T1195.002'
|
|
96
96
|
},
|
|
97
|
+
monorepo_detected: {
|
|
98
|
+
id: 'MUADDIB-PKG-021',
|
|
99
|
+
name: 'Monorepo Detected',
|
|
100
|
+
severity: 'MEDIUM',
|
|
101
|
+
confidence: 'high',
|
|
102
|
+
description: 'Workspace monorepo detecte (yarn/pnpm/lerna/turbo). Le perimetre du scan depasse un seul package — auditer chaque workspace separement pour un scoring per-package.',
|
|
103
|
+
references: [
|
|
104
|
+
'https://docs.npmjs.com/cli/v10/using-npm/workspaces'
|
|
105
|
+
],
|
|
106
|
+
mitre: 'T1195.002'
|
|
107
|
+
},
|
|
97
108
|
|
|
98
109
|
// Obfuscation detections
|
|
99
110
|
obfuscation_detected: {
|
|
@@ -335,6 +346,38 @@ const RULES = {
|
|
|
335
346
|
mitre: 'T1195.002'
|
|
336
347
|
},
|
|
337
348
|
|
|
349
|
+
// RT-C1: Dependency boundary-squat (Axios UNC1069 March 2026)
|
|
350
|
+
dependency_typosquat: {
|
|
351
|
+
id: 'MUADDIB-TYPO-002',
|
|
352
|
+
name: 'Dependency Boundary-Squat',
|
|
353
|
+
severity: 'HIGH',
|
|
354
|
+
confidence: 'high',
|
|
355
|
+
description: 'Une dependance declaree porte le nom d\'un package populaire prefixe/suffixe d\'un token suspect (Axios UNC1069, mars 2026). Le wrapper innocent declare un sub-dep malveillant.',
|
|
356
|
+
references: [
|
|
357
|
+
'https://snyk.io/blog/typosquatting-attacks/',
|
|
358
|
+
'https://attack.mitre.org/techniques/T1195/002/'
|
|
359
|
+
],
|
|
360
|
+
mitre: 'T1195.002'
|
|
361
|
+
},
|
|
362
|
+
dependency_typosquat_used: {
|
|
363
|
+
id: 'MUADDIB-TYPO-003',
|
|
364
|
+
name: 'Boundary-Squat Dependency Used in Code',
|
|
365
|
+
severity: 'MEDIUM',
|
|
366
|
+
confidence: 'high',
|
|
367
|
+
description: 'Le code du package require/import un nom de dependance identifie comme boundary-squat. Signal fort que la dep typosquattee est intentionnellement chargee.',
|
|
368
|
+
references: ['https://attack.mitre.org/techniques/T1195/002/'],
|
|
369
|
+
mitre: 'T1195.002'
|
|
370
|
+
},
|
|
371
|
+
dependency_typosquat_require: {
|
|
372
|
+
id: 'MUADDIB-COMPOUND-013',
|
|
373
|
+
name: 'Boundary-Squat Dep Required at Runtime',
|
|
374
|
+
severity: 'CRITICAL',
|
|
375
|
+
confidence: 'high',
|
|
376
|
+
description: 'Dependance boundary-squat declaree ET chargee via require/import dans le code: pattern Axios UNC1069 (sub-dep injection avec wrapper innocent).',
|
|
377
|
+
references: ['https://attack.mitre.org/techniques/T1195/002/'],
|
|
378
|
+
mitre: 'T1195.002'
|
|
379
|
+
},
|
|
380
|
+
|
|
338
381
|
// Package.json script patterns
|
|
339
382
|
curl_pipe_sh: {
|
|
340
383
|
id: 'MUADDIB-PKG-002',
|
|
@@ -2078,6 +2121,17 @@ const RULES = {
|
|
|
2078
2121
|
],
|
|
2079
2122
|
mitre: 'T1071'
|
|
2080
2123
|
},
|
|
2124
|
+
large_package_graph_truncated: {
|
|
2125
|
+
id: 'MUADDIB-FLOW-006',
|
|
2126
|
+
name: 'Large Package Graph Truncated',
|
|
2127
|
+
severity: 'MEDIUM',
|
|
2128
|
+
confidence: 'medium',
|
|
2129
|
+
description: 'Le graphe de modules depasse la limite (MAX_GRAPH_NODES). Cross-file dataflow non analyse — risque de blind spot sur monorepo ou large package. Auditer les sous-modules manuellement.',
|
|
2130
|
+
references: [
|
|
2131
|
+
'https://attack.mitre.org/techniques/T1195/002/'
|
|
2132
|
+
],
|
|
2133
|
+
mitre: 'T1195.002'
|
|
2134
|
+
},
|
|
2081
2135
|
|
|
2082
2136
|
// Audit v3 Bypass Detections (AST-062 to AST-069)
|
|
2083
2137
|
reflect_apply_require: {
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -24,12 +24,13 @@ const MODULE_SOURCE_METHODS = {
|
|
|
24
24
|
},
|
|
25
25
|
child_process: {
|
|
26
26
|
exec: 'command_output', execSync: 'command_output',
|
|
27
|
-
spawn: 'command_output', spawnSync: 'command_output'
|
|
27
|
+
spawn: 'command_output', spawnSync: 'command_output',
|
|
28
|
+
execFile: 'command_output', execFileSync: 'command_output', fork: 'command_output'
|
|
28
29
|
}
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
const MODULE_SINK_METHODS = {
|
|
32
|
-
child_process: { exec: 'exec_sink', execSync: 'exec_sink', spawn: 'exec_sink' },
|
|
33
|
+
child_process: { exec: 'exec_sink', execSync: 'exec_sink', spawn: 'exec_sink', execFile: 'exec_sink', execFileSync: 'exec_sink', fork: 'exec_sink' },
|
|
33
34
|
http: { request: 'network_send', get: 'network_send' },
|
|
34
35
|
https: { request: 'network_send', get: 'network_send' },
|
|
35
36
|
net: { connect: 'network_send', createConnection: 'network_send' },
|
|
@@ -39,17 +40,43 @@ const MODULE_SINK_METHODS = {
|
|
|
39
40
|
ws: { send: 'network_send', write: 'network_send' },
|
|
40
41
|
mqtt: { publish: 'network_send', send: 'network_send' },
|
|
41
42
|
'socket.io-client': { emit: 'network_send', send: 'network_send' },
|
|
42
|
-
'socket.io': { emit: 'network_send', send: 'network_send' }
|
|
43
|
+
'socket.io': { emit: 'network_send', send: 'network_send' },
|
|
44
|
+
// audit DF-H1 v2.11.15: 2026 sinks with clean direct call patterns
|
|
45
|
+
undici: { request: 'network_send', fetch: 'network_send', stream: 'network_send' },
|
|
46
|
+
'graphql-request': { request: 'network_send', gql: 'network_send' },
|
|
47
|
+
'@apollo/client': { query: 'network_send', mutate: 'network_send' },
|
|
48
|
+
'@grpc/grpc-js': {
|
|
49
|
+
makeUnaryRequest: 'network_send',
|
|
50
|
+
makeClientStreamRequest: 'network_send',
|
|
51
|
+
makeServerStreamRequest: 'network_send',
|
|
52
|
+
makeBidiStreamRequest: 'network_send'
|
|
53
|
+
}
|
|
43
54
|
};
|
|
44
55
|
|
|
45
|
-
//
|
|
56
|
+
// audit DF-H1 v2.11.15: 2026 exfil-prone modules. When imported, ANY call with a
|
|
57
|
+
// credential/env source in the same file → suspicious_module_sink MEDIUM with
|
|
58
|
+
// module attribution. Catches chained access (bot.telegram.sendMessage), dynamic
|
|
59
|
+
// methods (actor.exfil), and SDK fluent APIs that direct method matching misses.
|
|
60
|
+
const EXFIL_PRONE_MODULES = new Set([
|
|
61
|
+
'telegraf', 'node-telegram-bot-api',
|
|
62
|
+
'discord.js',
|
|
63
|
+
'@dfinity/agent',
|
|
64
|
+
'undici',
|
|
65
|
+
'@grpc/grpc-js',
|
|
66
|
+
'@apollo/client', 'graphql-request'
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// All tracked module names (for filtering in buildTaintMap). EXFIL_PRONE_MODULES
|
|
70
|
+
// must be tracked even when not in MODULE_SINK_METHODS so buildTaintMap registers
|
|
71
|
+
// them (e.g. require('telegraf') populates taintMap, enabling the heuristic).
|
|
46
72
|
const TRACKED_MODULES = new Set([
|
|
47
73
|
...Object.keys(MODULE_SOURCE_METHODS),
|
|
48
|
-
...Object.keys(MODULE_SINK_METHODS)
|
|
74
|
+
...Object.keys(MODULE_SINK_METHODS),
|
|
75
|
+
...EXFIL_PRONE_MODULES
|
|
49
76
|
]);
|
|
50
77
|
|
|
51
78
|
// Methods that execute commands — used for exec result capture detection
|
|
52
|
-
const EXEC_METHODS = new Set(['exec', 'execSync', 'spawn', 'spawnSync']);
|
|
79
|
+
const EXEC_METHODS = new Set(['exec', 'execSync', 'spawn', 'spawnSync', 'execFile', 'execFileSync', 'fork']);
|
|
53
80
|
|
|
54
81
|
/**
|
|
55
82
|
* Pre-pass: builds a taint map from require() assignments.
|
|
@@ -60,6 +87,24 @@ function buildTaintMap(ast) {
|
|
|
60
87
|
const taintMap = new Map();
|
|
61
88
|
|
|
62
89
|
walk.simple(ast, {
|
|
90
|
+
// ESM: import fs from 'fs' / import * as fs from 'fs' / import { exec } from 'child_process'
|
|
91
|
+
// Mirrors module-graph/annotate-tainted.js so ESM and CJS produce symmetric taint maps.
|
|
92
|
+
ImportDeclaration(node) {
|
|
93
|
+
if (!node.source || typeof node.source.value !== 'string') return;
|
|
94
|
+
const modName = node.source.value;
|
|
95
|
+
if (!TRACKED_MODULES.has(modName)) return;
|
|
96
|
+
for (const spec of node.specifiers) {
|
|
97
|
+
if (!spec.local || spec.local.type !== 'Identifier') continue;
|
|
98
|
+
if (spec.type === 'ImportDefaultSpecifier' || spec.type === 'ImportNamespaceSpecifier') {
|
|
99
|
+
taintMap.set(spec.local.name, { source: modName, detail: modName });
|
|
100
|
+
} else if (spec.type === 'ImportSpecifier') {
|
|
101
|
+
const imported = spec.imported && spec.imported.type === 'Identifier'
|
|
102
|
+
? spec.imported.name
|
|
103
|
+
: (spec.imported && spec.imported.value ? spec.imported.value : spec.local.name);
|
|
104
|
+
taintMap.set(spec.local.name, { source: modName, detail: `${modName}.${imported}` });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
63
108
|
VariableDeclarator(node) {
|
|
64
109
|
if (!node.init) return;
|
|
65
110
|
let init = node.init;
|
|
@@ -471,7 +516,9 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
471
516
|
|
|
472
517
|
}
|
|
473
518
|
|
|
474
|
-
|
|
519
|
+
// DF-H2: extend exec_network classification to all EXEC_METHODS
|
|
520
|
+
// (execFile/execFileSync/fork were previously missed — trivial evasion vector).
|
|
521
|
+
if (EXEC_METHODS.has(callName)) {
|
|
475
522
|
const arg = node.arguments[0];
|
|
476
523
|
if (arg && arg.type === 'Literal' && typeof arg.value === 'string') {
|
|
477
524
|
if (arg.value.includes('curl') || arg.value.includes('wget')) {
|
|
@@ -480,7 +527,6 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
480
527
|
name: callName,
|
|
481
528
|
line: node.loc?.start?.line
|
|
482
529
|
});
|
|
483
|
-
|
|
484
530
|
}
|
|
485
531
|
}
|
|
486
532
|
}
|
|
@@ -891,6 +937,29 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
891
937
|
});
|
|
892
938
|
}
|
|
893
939
|
|
|
940
|
+
// audit DF-H1 v2.11.15: 2026 exfil-prone module heuristic.
|
|
941
|
+
// If any EXFIL_PRONE_MODULES (telegraf, discord.js, @dfinity/agent, undici, gRPC,
|
|
942
|
+
// GraphQL clients) is imported AND a credential/env_read source is present in
|
|
943
|
+
// the same file, emit suspicious_module_sink with module attribution. Catches
|
|
944
|
+
// chained access (bot.telegram.sendMessage) and dynamic methods (actor.exfil)
|
|
945
|
+
// that direct MODULE_SINK_METHODS matching cannot reach.
|
|
946
|
+
const exfilProneInScope = [];
|
|
947
|
+
for (const taint of taintMap.values()) {
|
|
948
|
+
if (taint && EXFIL_PRONE_MODULES.has(taint.source)) exfilProneInScope.push(taint.source);
|
|
949
|
+
}
|
|
950
|
+
if (exfilProneInScope.length > 0 &&
|
|
951
|
+
sources.some(s => s.type === 'env_read' || s.type === 'credential_read')) {
|
|
952
|
+
const moduleList = [...new Set(exfilProneInScope)].join(', ');
|
|
953
|
+
const firstSourceLine = sources.find(s => s.line)?.line || 0;
|
|
954
|
+
threats.push({
|
|
955
|
+
type: 'suspicious_module_sink',
|
|
956
|
+
severity: 'MEDIUM',
|
|
957
|
+
message: `Module exfil-prone 2026 (${moduleList}) avec credential/env source dans le meme fichier — canal d'exfiltration potentiel.`,
|
|
958
|
+
file: path.relative(basePath, filePath),
|
|
959
|
+
line: firstSourceLine
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
|
|
894
963
|
// Detect staged payload: network fetch + eval in same file (no credential source needed)
|
|
895
964
|
const hasNetworkSink = sinks.some(s => s.type === 'network_send' || s.type === 'exec_network');
|
|
896
965
|
const hasEvalSink = sinks.some(s => s.type === 'eval_exec');
|
|
@@ -8,17 +8,30 @@ const { parseFile, isLocalImport, resolveLocal, toRel } = require('./parse-utils
|
|
|
8
8
|
/**
|
|
9
9
|
* Build a dependency graph of local modules within a package.
|
|
10
10
|
* Only tracks local imports (./ ../) — node_modules are ignored.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} packagePath
|
|
13
|
+
* @param {Object} [meta] - Optional out-param: mutated with { fileCount, truncated, maxNodes }
|
|
14
|
+
* so the caller can emit a `large_package_graph_truncated` threat
|
|
15
|
+
* when the package exceeds MAX_GRAPH_NODES (audit DF-C1).
|
|
11
16
|
*/
|
|
12
|
-
function buildModuleGraph(packagePath) {
|
|
17
|
+
function buildModuleGraph(packagePath, meta = {}) {
|
|
13
18
|
const graph = {};
|
|
19
|
+
// maxFiles: 0 (unlimited) — we need the true count to detect monorepo / large package
|
|
20
|
+
// truncation. MAX_GRAPH_NODES below caps the AST work; MODULE_GRAPH_TIMEOUT_MS in
|
|
21
|
+
// executor.js bounds the wall-time (audit DF-C1).
|
|
14
22
|
const files = findFiles(packagePath, {
|
|
15
23
|
extensions: ['.js', '.mjs', '.cjs'],
|
|
16
24
|
excludedDirs: EXCLUDED_DIRS,
|
|
25
|
+
maxFiles: 0,
|
|
17
26
|
});
|
|
18
27
|
|
|
28
|
+
meta.fileCount = files.length;
|
|
29
|
+
meta.maxNodes = MAX_GRAPH_NODES;
|
|
30
|
+
|
|
19
31
|
// Bounded path: skip module graph for very large packages
|
|
20
32
|
if (files.length > MAX_GRAPH_NODES) {
|
|
21
33
|
debugLog(`[MODULE-GRAPH] Skipping: ${files.length} files exceeds MAX_GRAPH_NODES (${MAX_GRAPH_NODES})`);
|
|
34
|
+
meta.truncated = true;
|
|
22
35
|
return graph;
|
|
23
36
|
}
|
|
24
37
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const { ACORN_OPTIONS: BASE_ACORN_OPTIONS } = require('../../shared/constants.js');
|
|
4
4
|
|
|
5
5
|
// --- Bounded path limits ---
|
|
6
|
-
const MAX_GRAPH_NODES =
|
|
6
|
+
const MAX_GRAPH_NODES = 5000; // Max files in dependency graph (covers ~99.5% of npm packages — audit DF-C1 v2.11.15)
|
|
7
7
|
const MAX_GRAPH_EDGES = 400; // Max total import edges
|
|
8
8
|
const MAX_FLOWS = 20; // Max cross-file flow findings per package
|
|
9
9
|
const MAX_TAINT_DEPTH = 50; // Max AST recursion depth (DoS guard)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Monorepo Detection Scanner (audit 2026-05 MR-C1)
|
|
5
|
+
*
|
|
6
|
+
* Detects when the scan target is a monorepo root (Yarn/npm workspaces, pnpm,
|
|
7
|
+
* Lerna, Turbo). Emits ONE informational MEDIUM threat `monorepo_detected`
|
|
8
|
+
* so the user knows the score reflects an aggregated perimeter rather than a
|
|
9
|
+
* single package, and that per-workspace scanning is the correct strategy
|
|
10
|
+
* until full workspace-aware scoring lands (backlog v2.13).
|
|
11
|
+
*
|
|
12
|
+
* Detection precedence (manager priority on first match):
|
|
13
|
+
* 1. pnpm-workspace.yaml → manager='pnpm'
|
|
14
|
+
* 2. lerna.json → manager='lerna'
|
|
15
|
+
* 3. turbo.json + pkg.workspaces → manager='turbo'
|
|
16
|
+
* 4. pkg.workspaces (array or {packages: [...]}) → manager='yarn' (also npm 8+)
|
|
17
|
+
*
|
|
18
|
+
* @param {string} targetPath
|
|
19
|
+
* @returns {Array} threats — empty if not a monorepo, one entry otherwise.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
|
|
25
|
+
function readJsonSafe(filePath) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function countYamlListEntries(filePath) {
|
|
34
|
+
try {
|
|
35
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
36
|
+
const matches = content.match(/^\s*-\s+\S/gm);
|
|
37
|
+
return matches ? matches.length : 0;
|
|
38
|
+
} catch {
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function workspacesCount(workspaces) {
|
|
44
|
+
if (Array.isArray(workspaces)) return workspaces.length;
|
|
45
|
+
if (workspaces && typeof workspaces === 'object' && Array.isArray(workspaces.packages)) {
|
|
46
|
+
return workspaces.packages.length;
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function scanMonorepo(targetPath) {
|
|
52
|
+
const threats = [];
|
|
53
|
+
|
|
54
|
+
const pnpmWs = path.join(targetPath, 'pnpm-workspace.yaml');
|
|
55
|
+
const lernaJson = path.join(targetPath, 'lerna.json');
|
|
56
|
+
const turboJson = path.join(targetPath, 'turbo.json');
|
|
57
|
+
const pkgJson = path.join(targetPath, 'package.json');
|
|
58
|
+
|
|
59
|
+
let manager = null;
|
|
60
|
+
let manifest = 'package.json';
|
|
61
|
+
let workspaceCount = 0;
|
|
62
|
+
|
|
63
|
+
if (fs.existsSync(pnpmWs)) {
|
|
64
|
+
manager = 'pnpm';
|
|
65
|
+
manifest = 'pnpm-workspace.yaml';
|
|
66
|
+
workspaceCount = countYamlListEntries(pnpmWs);
|
|
67
|
+
} else if (fs.existsSync(lernaJson)) {
|
|
68
|
+
manager = 'lerna';
|
|
69
|
+
manifest = 'lerna.json';
|
|
70
|
+
const lerna = readJsonSafe(lernaJson);
|
|
71
|
+
workspaceCount = lerna && Array.isArray(lerna.packages) ? lerna.packages.length : 0;
|
|
72
|
+
if (workspaceCount === 0 && lerna && lerna.workspaces) {
|
|
73
|
+
workspaceCount = workspacesCount(lerna.workspaces);
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
const pkg = fs.existsSync(pkgJson) ? readJsonSafe(pkgJson) : null;
|
|
77
|
+
const wsCount = pkg && pkg.workspaces ? workspacesCount(pkg.workspaces) : 0;
|
|
78
|
+
if (fs.existsSync(turboJson) && wsCount > 0) {
|
|
79
|
+
manager = 'turbo';
|
|
80
|
+
manifest = 'turbo.json';
|
|
81
|
+
workspaceCount = wsCount;
|
|
82
|
+
} else if (wsCount > 0) {
|
|
83
|
+
manager = 'yarn';
|
|
84
|
+
manifest = 'package.json';
|
|
85
|
+
workspaceCount = wsCount;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!manager) return threats;
|
|
90
|
+
|
|
91
|
+
threats.push({
|
|
92
|
+
type: 'monorepo_detected',
|
|
93
|
+
severity: 'MEDIUM',
|
|
94
|
+
message: `Monorepo ${manager} detecte (${workspaceCount} workspace${workspaceCount > 1 ? 's' : ''}). Perimetre elargi — scanner chaque package independamment pour un verdict per-workspace.`,
|
|
95
|
+
file: manifest,
|
|
96
|
+
line: 0,
|
|
97
|
+
manager,
|
|
98
|
+
workspaceCount
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return threats;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { scanMonorepo };
|
|
@@ -214,6 +214,20 @@ function walkForSpawnTargets(node, fileDir, packagePath, targets) {
|
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// RC-C3: new Worker('./worker.js') / new w.Worker(...) — worker_threads spawn.
|
|
218
|
+
// Stable since Node 12 (2019). Resolves only when first arg points to a real .js/.mjs/.cjs.
|
|
219
|
+
if (node.type === 'NewExpression' && node.callee && node.arguments && node.arguments.length >= 1) {
|
|
220
|
+
const ctorName = node.callee.type === 'Identifier'
|
|
221
|
+
? node.callee.name
|
|
222
|
+
: (node.callee.type === 'MemberExpression' && node.callee.property
|
|
223
|
+
? (node.callee.property.name || node.callee.property.value || '')
|
|
224
|
+
: '');
|
|
225
|
+
if (ctorName === 'Worker') {
|
|
226
|
+
const target = resolvePathArg(node.arguments[0], fileDir, packagePath);
|
|
227
|
+
if (target) targets.push(target);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
217
231
|
for (const key of Object.keys(node)) {
|
|
218
232
|
if (key === 'type') continue;
|
|
219
233
|
const child = node[key];
|
package/src/scanner/typosquat.js
CHANGED
|
@@ -24,9 +24,30 @@ const POPULAR_PACKAGES = [
|
|
|
24
24
|
'mobx', 'redux', 'zustand', 'formik', 'yup', 'ajv', 'validator',
|
|
25
25
|
'date-fns', 'dayjs', 'luxon', 'numeral', 'accounting', 'currency.js',
|
|
26
26
|
'lodash-es', 'core-js', 'regenerator-runtime', 'tslib', 'classnames',
|
|
27
|
-
'prop-types', 'cross-env', 'node-fetch', 'got'
|
|
27
|
+
'prop-types', 'cross-env', 'node-fetch', 'got',
|
|
28
|
+
// RT-C1 (Axios UNC1069 March 2026): crypto-js missing — wrapper packages declared
|
|
29
|
+
// `plain-crypto-js` as sub-dep. Added so dependency boundary-squat catches it.
|
|
30
|
+
'crypto-js'
|
|
28
31
|
];
|
|
29
32
|
|
|
33
|
+
// RT-C1: Hyphen tokens that legitimately PREFIX or SUFFIX popular package names.
|
|
34
|
+
// `<token>-<popular>` or `<popular>-<token>` is considered benign when the extra
|
|
35
|
+
// token is in this set (ecosystem qualifiers, framework prefixes, official scopes).
|
|
36
|
+
const LEGIT_BOUNDARY_TOKENS = new Set([
|
|
37
|
+
// Frameworks / build tools (also common official sub-packages)
|
|
38
|
+
'react', 'vue', 'angular', 'svelte', 'next', 'nuxt', 'gatsby', 'expo',
|
|
39
|
+
'eslint', 'babel', 'webpack', 'rollup', 'vite', 'parcel', 'esbuild',
|
|
40
|
+
'jest', 'mocha', 'vitest', 'karma', 'cypress', 'playwright',
|
|
41
|
+
'typescript', 'ts', 'tsdx', 'koa', 'fastify', 'express', 'nest',
|
|
42
|
+
'redux', 'mobx', 'apollo', 'graphql', 'rxjs',
|
|
43
|
+
// Build / runtime variants
|
|
44
|
+
'cli', 'core', 'utils', 'plugin', 'loader', 'preset', 'config',
|
|
45
|
+
'common', 'browser', 'node', 'native', 'web', 'mobile',
|
|
46
|
+
'esm', 'cjs', 'umd', 'es', 'types', 'typings',
|
|
47
|
+
// Versions / channels
|
|
48
|
+
'v2', 'v3', 'v4', 'next', 'latest', 'stable', 'lts', 'legacy', 'beta', 'alpha'
|
|
49
|
+
]);
|
|
50
|
+
|
|
30
51
|
// Packages legitimes courts ou qui ressemblent a des populaires
|
|
31
52
|
const WHITELIST = new Set([
|
|
32
53
|
// Packages tres courts legitimes
|
|
@@ -275,8 +296,6 @@ async function scanTyposquatting(targetPath) {
|
|
|
275
296
|
}
|
|
276
297
|
}
|
|
277
298
|
|
|
278
|
-
if (candidates.length === 0) return threats;
|
|
279
|
-
|
|
280
299
|
// Phase 2: API enrichment (batched to avoid socket exhaustion)
|
|
281
300
|
const BATCH_SIZE = 10;
|
|
282
301
|
const metadataResults = [];
|
|
@@ -333,9 +352,152 @@ async function scanTyposquatting(targetPath) {
|
|
|
333
352
|
});
|
|
334
353
|
}
|
|
335
354
|
|
|
355
|
+
// ============================================
|
|
356
|
+
// RT-C1: dependency boundary-squat detection (Axios UNC1069 March 2026)
|
|
357
|
+
// ============================================
|
|
358
|
+
// Runs on deps that did NOT match Levenshtein (length filter excludes them).
|
|
359
|
+
// Catches `<prefix>-<popular>` / `<popular>-<suffix>` injections in package.json
|
|
360
|
+
// deps, plus require/import usage cross-check inside the package source.
|
|
361
|
+
const levenshteinMatches = new Set(candidates.map(c => c.depName));
|
|
362
|
+
const RT_C1_MAX_DEPS = 50;
|
|
363
|
+
let depsEvaluated = 0;
|
|
364
|
+
for (const depName of Object.keys(dependencies)) {
|
|
365
|
+
if (depsEvaluated >= RT_C1_MAX_DEPS) break;
|
|
366
|
+
if (levenshteinMatches.has(depName)) continue; // already flagged by Levenshtein path
|
|
367
|
+
const bMatch = findDependencyBoundarySquat(depName);
|
|
368
|
+
if (!bMatch) continue;
|
|
369
|
+
depsEvaluated++;
|
|
370
|
+
|
|
371
|
+
const declMsg = 'Dependency "' + depName + '" looks like a boundary-squat of "'
|
|
372
|
+
+ bMatch.original + '" (extra token: "' + bMatch.extra + '"). Axios UNC1069 pattern.';
|
|
373
|
+
threats.push({
|
|
374
|
+
type: 'dependency_typosquat',
|
|
375
|
+
severity: 'HIGH',
|
|
376
|
+
message: declMsg,
|
|
377
|
+
file: 'package.json',
|
|
378
|
+
details: {
|
|
379
|
+
suspicious: depName,
|
|
380
|
+
legitimate: bMatch.original,
|
|
381
|
+
technique: bMatch.type,
|
|
382
|
+
extra: bMatch.extra,
|
|
383
|
+
distance: bMatch.distance
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Cross-check: scan package source for require/import of this dep name
|
|
388
|
+
const usages = findDependencyUsages(targetPath, depName);
|
|
389
|
+
for (const u of usages) {
|
|
390
|
+
threats.push({
|
|
391
|
+
type: 'dependency_typosquat_used',
|
|
392
|
+
severity: 'MEDIUM',
|
|
393
|
+
message: 'Boundary-squat dep "' + depName + '" is require()/import()d in source code',
|
|
394
|
+
file: u.file,
|
|
395
|
+
line: u.line,
|
|
396
|
+
details: {
|
|
397
|
+
suspicious: depName,
|
|
398
|
+
legitimate: bMatch.original
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
336
404
|
return threats;
|
|
337
405
|
}
|
|
338
406
|
|
|
407
|
+
/**
|
|
408
|
+
* RT-C1: Detects "boundary squat" patterns: <prefix>-<popular> or <popular>-<suffix>
|
|
409
|
+
* where the hyphenated tokens fully contain a popular package name and the extra
|
|
410
|
+
* material is NOT in LEGIT_BOUNDARY_TOKENS. This catches Axios UNC1069-style
|
|
411
|
+
* sub-dependency injection like `plain-crypto-js` (resembles `crypto-js`) that
|
|
412
|
+
* the Levenshtein matcher misses because length-diff is too large.
|
|
413
|
+
*/
|
|
414
|
+
function findDependencyBoundarySquat(name) {
|
|
415
|
+
const lower = name.toLowerCase();
|
|
416
|
+
if (!lower || lower.startsWith('@')) return null; // skip scoped
|
|
417
|
+
if (lower.length < MIN_PACKAGE_LENGTH) return null;
|
|
418
|
+
if (WHITELIST.has(lower)) return null;
|
|
419
|
+
if (isLegitimateVariant(lower)) return null;
|
|
420
|
+
if (!lower.includes('-')) return null; // need a boundary
|
|
421
|
+
// If it's an exact match to a popular package, not a squat
|
|
422
|
+
if (POPULAR_PACKAGES_LOWER.indexOf(lower) !== -1) return null;
|
|
423
|
+
|
|
424
|
+
for (let i = 0; i < POPULAR_PACKAGES.length; i++) {
|
|
425
|
+
const popular = POPULAR_PACKAGES_LOWER[i];
|
|
426
|
+
if (popular.length < MIN_PACKAGE_LENGTH) continue;
|
|
427
|
+
if (lower === popular) continue;
|
|
428
|
+
|
|
429
|
+
if (popular.includes('-')) {
|
|
430
|
+
// Multi-token popular (e.g. crypto-js): match prefix or suffix at hyphen boundary
|
|
431
|
+
let extra = null;
|
|
432
|
+
if (lower.endsWith('-' + popular)) {
|
|
433
|
+
extra = lower.slice(0, lower.length - popular.length - 1);
|
|
434
|
+
} else if (lower.startsWith(popular + '-')) {
|
|
435
|
+
extra = lower.slice(popular.length + 1);
|
|
436
|
+
}
|
|
437
|
+
if (extra === null || extra.length === 0) continue;
|
|
438
|
+
// Reject if extra is a legit boundary token (single token only)
|
|
439
|
+
if (!extra.includes('-') && LEGIT_BOUNDARY_TOKENS.has(extra)) continue;
|
|
440
|
+
return { original: POPULAR_PACKAGES[i], type: 'boundary_squat', distance: extra.length, extra };
|
|
441
|
+
} else {
|
|
442
|
+
// Single-token popular: must appear as a full hyphen-bounded token in name
|
|
443
|
+
const tokens = lower.split('-');
|
|
444
|
+
const idx = tokens.indexOf(popular);
|
|
445
|
+
if (idx === -1) continue;
|
|
446
|
+
if (tokens.length === 1) continue;
|
|
447
|
+
const siblings = tokens.filter((_, j) => j !== idx);
|
|
448
|
+
// If all siblings are legit boundary tokens → benign variant (e.g. react-router)
|
|
449
|
+
if (siblings.every(t => LEGIT_BOUNDARY_TOKENS.has(t) || isLegitimateVariant(t))) continue;
|
|
450
|
+
const extra = siblings.join('-');
|
|
451
|
+
return { original: POPULAR_PACKAGES[i], type: 'boundary_squat', distance: extra.length, extra };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// RT-C1: Bounded scan of the package source for require/import of a given dep name.
|
|
458
|
+
// Returns array of { file, line } usage sites. Bounds: 200 files, 256KB per file.
|
|
459
|
+
const _DEP_USE_MAX_FILES = 200;
|
|
460
|
+
const _DEP_USE_MAX_FILE_BYTES = 256 * 1024;
|
|
461
|
+
function findDependencyUsages(targetPath, depName) {
|
|
462
|
+
const out = [];
|
|
463
|
+
if (!depName) return out;
|
|
464
|
+
|
|
465
|
+
let files;
|
|
466
|
+
try {
|
|
467
|
+
// Use a local require to avoid a circular import surface — utils.js is stable.
|
|
468
|
+
const { findFiles } = require('../utils.js');
|
|
469
|
+
files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], maxFiles: _DEP_USE_MAX_FILES });
|
|
470
|
+
} catch {
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
if (!files || files.length === 0) return out;
|
|
474
|
+
|
|
475
|
+
// Pre-build matchers — escape regex metacharacters in dep name
|
|
476
|
+
const escaped = depName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
477
|
+
const reRequire = new RegExp(`require\\s*\\(\\s*['\"]${escaped}['\"]\\s*\\)`);
|
|
478
|
+
const reFrom = new RegExp(`from\\s+['\"]${escaped}['\"]`);
|
|
479
|
+
const reDynamic = new RegExp(`import\\s*\\(\\s*['\"]${escaped}['\"]\\s*\\)`);
|
|
480
|
+
|
|
481
|
+
for (const abs of files) {
|
|
482
|
+
let stat;
|
|
483
|
+
try { stat = fs.statSync(abs); } catch { continue; }
|
|
484
|
+
if (!stat.isFile() || stat.size > _DEP_USE_MAX_FILE_BYTES) continue;
|
|
485
|
+
let content;
|
|
486
|
+
try { content = fs.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
487
|
+
// Fast-path early bail
|
|
488
|
+
if (!content.includes(depName)) continue;
|
|
489
|
+
const lines = content.split(/\r?\n/);
|
|
490
|
+
for (let i = 0; i < lines.length; i++) {
|
|
491
|
+
const ln = lines[i];
|
|
492
|
+
if (reRequire.test(ln) || reFrom.test(ln) || reDynamic.test(ln)) {
|
|
493
|
+
out.push({ file: path.relative(targetPath, abs), line: i + 1 });
|
|
494
|
+
break; // one match per file is enough — keeps signal density honest
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
|
|
339
501
|
function findTyposquatMatch(name) {
|
|
340
502
|
const nameLower = name.toLowerCase();
|
|
341
503
|
|
package/src/scoring.js
CHANGED
|
@@ -112,6 +112,8 @@ const PACKAGE_LEVEL_TYPES = new Set([
|
|
|
112
112
|
// Compound scoring rules — package-level co-occurrences
|
|
113
113
|
'lifecycle_typosquat', 'lifecycle_inline_exec', 'lifecycle_remote_require',
|
|
114
114
|
'lifecycle_dataflow', 'lifecycle_dangerous_exec', 'obfuscated_lifecycle_env',
|
|
115
|
+
// RT-C1: dependency boundary-squat family (Axios UNC1069 March 2026)
|
|
116
|
+
'dependency_typosquat', 'dependency_typosquat_require',
|
|
115
117
|
// Blue Team v8: package-level boost signals
|
|
116
118
|
'isolated_suspicious_file', 'deep_suspicious_file',
|
|
117
119
|
// Blue Team v8b: phantom lifecycle scripts
|
|
@@ -124,7 +126,11 @@ const PACKAGE_LEVEL_TYPES = new Set([
|
|
|
124
126
|
// intel-triage P1.3: stub-package detector closes ltidi gap (memory project_detection_gap_ltidi_chain)
|
|
125
127
|
'stub_package_external_payload', 'stub_package_external_dep',
|
|
126
128
|
// intel-triage P3.1 family compounds
|
|
127
|
-
'axios_family', 'stub_with_string_ioc'
|
|
129
|
+
'axios_family', 'stub_with_string_ioc',
|
|
130
|
+
// audit DF-C1: emitted when MAX_GRAPH_NODES exceeded so cross-file blind spot is visible in scoring
|
|
131
|
+
'large_package_graph_truncated',
|
|
132
|
+
// audit MR-C1: informational signal that the scan target is a monorepo root (per-workspace scoring TBD)
|
|
133
|
+
'monorepo_detected'
|
|
128
134
|
]);
|
|
129
135
|
|
|
130
136
|
// ============================================
|
|
@@ -380,7 +386,9 @@ const DIST_EXEMPT_TYPES = new Set([
|
|
|
380
386
|
'crypto_staged_payload', 'lifecycle_typosquat',
|
|
381
387
|
'lifecycle_inline_exec', 'lifecycle_remote_require',
|
|
382
388
|
'lifecycle_file_exec', // B6: lifecycle → malicious file compound
|
|
383
|
-
'lifecycle_dataflow', 'lifecycle_dangerous_exec', 'obfuscated_lifecycle_env'
|
|
389
|
+
'lifecycle_dataflow', 'lifecycle_dangerous_exec', 'obfuscated_lifecycle_env',
|
|
390
|
+
// RT-C1: Boundary-squat compound is never coincidental (dep declared AND require()d)
|
|
391
|
+
'dependency_typosquat_require'
|
|
384
392
|
// P6: remote_code_load and proxy_data_intercept removed — in bundled dist/ files,
|
|
385
393
|
// fetch + eval co-occurrence is coincidental (bundler combines HTTP client + template compilation).
|
|
386
394
|
// fetch_decrypt_exec (fetch+decrypt+eval triple) remains exempt — never coincidental.
|
|
@@ -488,6 +496,15 @@ const SCORING_COMPOUNDS = [
|
|
|
488
496
|
message: 'Lifecycle hook on typosquat package — dependency confusion attack vector (scoring compound).',
|
|
489
497
|
fileFrom: 'typosquat_detected'
|
|
490
498
|
},
|
|
499
|
+
{
|
|
500
|
+
// RT-C1: Boundary-squat dep declared AND require()d in code → CRITICAL.
|
|
501
|
+
// Pattern Axios UNC1069 (March 2026): wrapper looks benign, payload is in the dep.
|
|
502
|
+
type: 'dependency_typosquat_require',
|
|
503
|
+
requires: ['dependency_typosquat', 'dependency_typosquat_used'],
|
|
504
|
+
severity: 'CRITICAL',
|
|
505
|
+
message: 'Boundary-squat dependency declared AND require()d in code — Axios UNC1069 pattern (scoring compound).',
|
|
506
|
+
fileFrom: 'dependency_typosquat_used'
|
|
507
|
+
},
|
|
491
508
|
{
|
|
492
509
|
type: 'lifecycle_inline_exec',
|
|
493
510
|
requires: ['lifecycle_script', 'node_inline_exec'],
|