muaddib-scanner 2.11.4 → 2.11.7
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/README.md +10 -7
- package/package.json +3 -1
- package/src/integrations/registry-signals.js +216 -0
- package/src/pipeline/processor.js +190 -13
- package/src/response/playbooks.js +34 -0
- package/src/rules/confidence-tiers.js +187 -0
- package/src/rules/index.js +89 -2
- package/src/runtime/monitor-feed.js +241 -0
- package/src/runtime/serve.js +59 -2
- package/src/sandbox/compound-triggers.js +232 -0
- package/src/scanner/ast-detectors/handle-assignment-expression.js +7 -2
- package/src/scanner/ast.js +18 -0
- package/src/scanner/npm-registry.js +31 -1
- package/src/scanner/reachability.js +603 -1
- package/src/scanner/typosquat.js +6 -2
- package/src/scoring/delta-multiplier.js +294 -0
- package/src/scoring.js +387 -4
|
@@ -326,4 +326,606 @@ function computeReachableFiles(packagePath) {
|
|
|
326
326
|
return { reachableFiles: reachable, entryPoints, skipped: false };
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
|
|
329
|
+
// =============================================================================
|
|
330
|
+
// FPR plan Chantier 2 - intra-file function-level reachability
|
|
331
|
+
// =============================================================================
|
|
332
|
+
//
|
|
333
|
+
// File-level reachability above answers "is this file ever loaded by entry?".
|
|
334
|
+
// Function-level reachability answers "is this code in a function that's ever
|
|
335
|
+
// called?". A reachable file (e.g. lib/utils.js) often ships dozens of helper
|
|
336
|
+
// functions where only a handful are actually called from exports - the rest
|
|
337
|
+
// are dead code that nevertheless contributes to the FPR baseline (lodash
|
|
338
|
+
// legacy modules, moment locales, framework polyfills).
|
|
339
|
+
//
|
|
340
|
+
// Bounded by design (CLAUDE.md "bounded resources") :
|
|
341
|
+
// - MAX_FN_REACH_FILES files processed per package
|
|
342
|
+
// - MAX_FN_REACH_BYTES per file
|
|
343
|
+
// - Skips immediately on dynamic resolution (eval / Function / dynamic
|
|
344
|
+
// require / globalThis[computed]) - fail-open to avoid TPR regression
|
|
345
|
+
// - Only intra-file edges (inter-file already approximated by file-level
|
|
346
|
+
// reachability above + treating all exports as seeds)
|
|
347
|
+
//
|
|
348
|
+
// Output : Map<relFile, { dynamic, deadRanges }> where deadRanges is an
|
|
349
|
+
// array of { startLine, endLine }. Threats with t.line falling inside any
|
|
350
|
+
// dead range are eligible for downgrade in src/scoring.js applyFPReductions.
|
|
351
|
+
|
|
352
|
+
const MAX_FN_REACH_FILES = 250;
|
|
353
|
+
const MAX_FN_REACH_BYTES = 1024 * 1024;
|
|
354
|
+
|
|
355
|
+
let _acornLazy = null;
|
|
356
|
+
function _acorn() {
|
|
357
|
+
if (_acornLazy) return _acornLazy;
|
|
358
|
+
_acornLazy = require('acorn');
|
|
359
|
+
return _acornLazy;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const FN_REACH_ACORN_OPTIONS = {
|
|
363
|
+
ecmaVersion: 2024,
|
|
364
|
+
sourceType: 'module',
|
|
365
|
+
allowReturnOutsideFunction: true,
|
|
366
|
+
allowImportExportEverywhere: true,
|
|
367
|
+
allowHashBang: true,
|
|
368
|
+
locations: true
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
function _parseWithLocations(content) {
|
|
372
|
+
const acorn = _acorn();
|
|
373
|
+
try {
|
|
374
|
+
return acorn.parse(content, FN_REACH_ACORN_OPTIONS);
|
|
375
|
+
} catch {
|
|
376
|
+
try {
|
|
377
|
+
return acorn.parse(content, { ...FN_REACH_ACORN_OPTIONS, sourceType: 'script' });
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function _isFunctionNode(node) {
|
|
385
|
+
return node && (
|
|
386
|
+
node.type === 'FunctionDeclaration' ||
|
|
387
|
+
node.type === 'FunctionExpression' ||
|
|
388
|
+
node.type === 'ArrowFunctionExpression'
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Walks an AST node tree, calling visitor(node) for every typed node.
|
|
394
|
+
* Mirrors module-graph.walkAST but local to this file to keep imports tight.
|
|
395
|
+
*/
|
|
396
|
+
function _walk(node, visitor) {
|
|
397
|
+
if (!node || typeof node !== 'object') return;
|
|
398
|
+
if (node.type) visitor(node);
|
|
399
|
+
for (const key of Object.keys(node)) {
|
|
400
|
+
if (key === 'type' || key === 'loc' || key === 'start' || key === 'end') continue;
|
|
401
|
+
const child = node[key];
|
|
402
|
+
if (Array.isArray(child)) {
|
|
403
|
+
for (const item of child) {
|
|
404
|
+
if (item && typeof item === 'object' && item.type) _walk(item, visitor);
|
|
405
|
+
}
|
|
406
|
+
} else if (child && typeof child === 'object' && child.type) {
|
|
407
|
+
_walk(child, visitor);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Returns true when a single AST node is one of the dynamic-resolution
|
|
414
|
+
* operations that defeat static call-graph analysis :
|
|
415
|
+
*
|
|
416
|
+
* - eval(...) / Function(...) / new Function(...)
|
|
417
|
+
* - require(<non-literal>) / require(<template-with-expr>)
|
|
418
|
+
* - globalThis[<computed>] / global[<computed>] / window[<computed>]
|
|
419
|
+
*
|
|
420
|
+
* Single-node check ; combined with _walkBody to scope detection to a function
|
|
421
|
+
* body or to the top-level program body.
|
|
422
|
+
*/
|
|
423
|
+
function _isDynamicNode(node) {
|
|
424
|
+
if (!node || typeof node !== 'object') return false;
|
|
425
|
+
if (node.type === 'CallExpression') {
|
|
426
|
+
const callee = node.callee;
|
|
427
|
+
if (callee && callee.type === 'Identifier') {
|
|
428
|
+
if (callee.name === 'eval' || callee.name === 'Function') return true;
|
|
429
|
+
if (callee.name === 'require' && node.arguments.length >= 1) {
|
|
430
|
+
const arg0 = node.arguments[0];
|
|
431
|
+
const literalString = arg0 && arg0.type === 'Literal' && typeof arg0.value === 'string';
|
|
432
|
+
const simpleTemplate = arg0 && arg0.type === 'TemplateLiteral' &&
|
|
433
|
+
arg0.expressions.length === 0;
|
|
434
|
+
if (!literalString && !simpleTemplate) return true;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (node.type === 'NewExpression') {
|
|
439
|
+
const callee = node.callee;
|
|
440
|
+
if (callee && callee.type === 'Identifier' && callee.name === 'Function') return true;
|
|
441
|
+
}
|
|
442
|
+
if (node.type === 'MemberExpression' && node.computed) {
|
|
443
|
+
const obj = node.object;
|
|
444
|
+
if (obj && obj.type === 'Identifier' &&
|
|
445
|
+
(obj.name === 'globalThis' || obj.name === 'global' || obj.name === 'window')) {
|
|
446
|
+
if (node.property && node.property.type !== 'Literal') return true;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Walk a node and its descendants, calling visitor on each, but DO NOT
|
|
454
|
+
* descend into nested function bodies or class bodies. Used both for
|
|
455
|
+
* name-collection and dynamic-resolution detection scoped to one body.
|
|
456
|
+
*/
|
|
457
|
+
function _walkBody(node, visitor) {
|
|
458
|
+
if (!node || typeof node !== 'object') return;
|
|
459
|
+
if (node.type) visitor(node);
|
|
460
|
+
if (_isFunctionNode(node) && node !== _walkBody._entry) return;
|
|
461
|
+
if (node.type === 'ClassBody' && node !== _walkBody._entry) return;
|
|
462
|
+
for (const key of Object.keys(node)) {
|
|
463
|
+
if (key === 'type' || key === 'loc' || key === 'start' || key === 'end') continue;
|
|
464
|
+
const child = node[key];
|
|
465
|
+
if (Array.isArray(child)) {
|
|
466
|
+
for (const it of child) if (it && typeof it === 'object' && it.type) _walkBody(it, visitor);
|
|
467
|
+
} else if (child && typeof child === 'object' && child.type) {
|
|
468
|
+
_walkBody(child, visitor);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Returns true if the body contains a dynamic-resolution op directly
|
|
475
|
+
* (excluding nested function bodies, which count toward their own owner).
|
|
476
|
+
*/
|
|
477
|
+
function _bodyHasDynamic(bodyNode) {
|
|
478
|
+
let found = false;
|
|
479
|
+
_walkBody._entry = bodyNode;
|
|
480
|
+
try {
|
|
481
|
+
_walkBody(bodyNode, (n) => {
|
|
482
|
+
if (found) return;
|
|
483
|
+
if (_isDynamicNode(n)) found = true;
|
|
484
|
+
});
|
|
485
|
+
} finally {
|
|
486
|
+
_walkBody._entry = null;
|
|
487
|
+
}
|
|
488
|
+
return found;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Walk Program body and check whether any top-level (script-scope) statement
|
|
493
|
+
* contains a dynamic-resolution op. Top-level eval can rebind globals so a
|
|
494
|
+
* single hit forces full-file fail-open. Function bodies (declared at top
|
|
495
|
+
* level but not invoked) are excluded from this check - they are evaluated
|
|
496
|
+
* separately per-function.
|
|
497
|
+
*/
|
|
498
|
+
function _findTopLevelDynamic(ast) {
|
|
499
|
+
for (const stmt of ast.body) {
|
|
500
|
+
if (!stmt || !stmt.type) continue;
|
|
501
|
+
// Pure declarations don't execute their bodies at top level.
|
|
502
|
+
if (stmt.type === 'FunctionDeclaration') continue;
|
|
503
|
+
if (stmt.type === 'ClassDeclaration') continue;
|
|
504
|
+
if (stmt.type === 'ImportDeclaration') continue;
|
|
505
|
+
if (stmt.type === 'ExportNamedDeclaration' &&
|
|
506
|
+
stmt.declaration && (
|
|
507
|
+
stmt.declaration.type === 'FunctionDeclaration' ||
|
|
508
|
+
stmt.declaration.type === 'ClassDeclaration'
|
|
509
|
+
)) continue;
|
|
510
|
+
if (stmt.type === 'ExportDefaultDeclaration' && stmt.declaration && (
|
|
511
|
+
stmt.declaration.type === 'FunctionDeclaration' ||
|
|
512
|
+
stmt.declaration.type === 'ClassDeclaration'
|
|
513
|
+
)) continue;
|
|
514
|
+
// For variable declarators, only the init expression executes - a literal
|
|
515
|
+
// function expression does not run unless invoked.
|
|
516
|
+
if (stmt.type === 'VariableDeclaration') {
|
|
517
|
+
for (const decl of stmt.declarations) {
|
|
518
|
+
if (!decl.init) continue;
|
|
519
|
+
if (_isFunctionNode(decl.init)) continue; // declared, not invoked
|
|
520
|
+
if (_bodyHasDynamic(decl.init)) return true;
|
|
521
|
+
}
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (_bodyHasDynamic(stmt)) return true;
|
|
525
|
+
}
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Backwards-compatible wrapper kept for the unit tests that exercise the
|
|
531
|
+
* coarse "any-dynamic-anywhere" check. New code path uses _findTopLevelDynamic
|
|
532
|
+
* + per-function detection inside _analyzeFunctionReachability.
|
|
533
|
+
*/
|
|
534
|
+
function _hasDynamicResolution(ast) {
|
|
535
|
+
let dynamic = false;
|
|
536
|
+
_walk(ast, (node) => {
|
|
537
|
+
if (dynamic) return;
|
|
538
|
+
if (_isDynamicNode(node)) dynamic = true;
|
|
539
|
+
});
|
|
540
|
+
return dynamic;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Collects every locally-named function declaration in the file.
|
|
545
|
+
* - function Foo() {} -> name='Foo'
|
|
546
|
+
* - const Foo = function() {} / const Foo = () => {} -> name='Foo'
|
|
547
|
+
* - class Foo { method() {} } -> all methods named '<class>.<method>'
|
|
548
|
+
*
|
|
549
|
+
* Returns Array<{ name, startLine, endLine, bodyNode }>. The bodyNode lets
|
|
550
|
+
* the call-graph step walk the function body without re-finding it.
|
|
551
|
+
*/
|
|
552
|
+
function _collectNamedFunctions(ast) {
|
|
553
|
+
const out = [];
|
|
554
|
+
const seenStarts = new Set();
|
|
555
|
+
_walk(ast, (node) => {
|
|
556
|
+
if (node.type === 'FunctionDeclaration' && node.id && node.id.name && node.loc) {
|
|
557
|
+
if (seenStarts.has(node.start)) return;
|
|
558
|
+
seenStarts.add(node.start);
|
|
559
|
+
out.push({
|
|
560
|
+
name: node.id.name,
|
|
561
|
+
startLine: node.loc.start.line,
|
|
562
|
+
endLine: node.loc.end.line,
|
|
563
|
+
bodyNode: node.body,
|
|
564
|
+
kind: 'function_decl'
|
|
565
|
+
});
|
|
566
|
+
} else if (node.type === 'VariableDeclarator' && node.id && node.id.type === 'Identifier' &&
|
|
567
|
+
node.init && _isFunctionNode(node.init) && node.init.loc) {
|
|
568
|
+
if (seenStarts.has(node.init.start)) return;
|
|
569
|
+
seenStarts.add(node.init.start);
|
|
570
|
+
out.push({
|
|
571
|
+
name: node.id.name,
|
|
572
|
+
startLine: node.init.loc.start.line,
|
|
573
|
+
endLine: node.init.loc.end.line,
|
|
574
|
+
bodyNode: node.init.body,
|
|
575
|
+
kind: 'fn_const'
|
|
576
|
+
});
|
|
577
|
+
} else if (node.type === 'ClassDeclaration' && node.id && node.id.name &&
|
|
578
|
+
node.body && node.body.type === 'ClassBody') {
|
|
579
|
+
for (const member of node.body.body) {
|
|
580
|
+
if ((member.type === 'MethodDefinition' || member.type === 'PropertyDefinition') &&
|
|
581
|
+
member.value && _isFunctionNode(member.value) && member.value.loc) {
|
|
582
|
+
if (seenStarts.has(member.value.start)) continue;
|
|
583
|
+
seenStarts.add(member.value.start);
|
|
584
|
+
const methodName = member.key && (member.key.name || member.key.value);
|
|
585
|
+
out.push({
|
|
586
|
+
name: `${node.id.name}.${methodName || '<anon>'}`,
|
|
587
|
+
startLine: member.value.loc.start.line,
|
|
588
|
+
endLine: member.value.loc.end.line,
|
|
589
|
+
bodyNode: member.value.body,
|
|
590
|
+
kind: 'class_method',
|
|
591
|
+
className: node.id.name
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Collect every name-Identifier reference inside a node (excluding nested
|
|
602
|
+
* function bodies - those are walked separately via the call-graph queue).
|
|
603
|
+
* Captures three patterns equally :
|
|
604
|
+
*
|
|
605
|
+
* foo() // direct call
|
|
606
|
+
* arr.map(foo) // callback reference
|
|
607
|
+
* process.on('exit', cleanup) // event handler reference
|
|
608
|
+
* const x = bar // alias creation
|
|
609
|
+
*
|
|
610
|
+
* Plus method names from non-computed MemberExpression callees (`obj.method`)
|
|
611
|
+
* to keep class methods live behind imprecise dispatch. False-live is the
|
|
612
|
+
* safe direction here ; the goal is to avoid TPR regression on dead-code
|
|
613
|
+
* downgrade, not to be precise on which name is actually executed.
|
|
614
|
+
*
|
|
615
|
+
* Skips :
|
|
616
|
+
* - nested function bodies (handled via BFS queue)
|
|
617
|
+
* - declarators (a.id is not "referenced" by `function a() {}`)
|
|
618
|
+
* - non-computed MemberExpression .property side (only the .object Identifier)
|
|
619
|
+
*/
|
|
620
|
+
function _calleeIdentifierNames(node) {
|
|
621
|
+
const names = new Set();
|
|
622
|
+
function walkLight(n) {
|
|
623
|
+
if (!n || typeof n !== 'object') return;
|
|
624
|
+
|
|
625
|
+
// Track non-computed member callees so `obj.method()` keeps `method` live.
|
|
626
|
+
if ((n.type === 'CallExpression' || n.type === 'NewExpression') && n.callee &&
|
|
627
|
+
n.callee.type === 'MemberExpression' && !n.callee.computed &&
|
|
628
|
+
n.callee.property && n.callee.property.type === 'Identifier') {
|
|
629
|
+
names.add(n.callee.property.name);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (n.type === 'Identifier') {
|
|
633
|
+
names.add(n.name);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Don't descend into NAMED nested functions - they're addressable via
|
|
638
|
+
// collectNamedFunctions and BFS reaches them on their own. But anonymous
|
|
639
|
+
// FunctionExpression / ArrowFunctionExpression (callbacks, object-method
|
|
640
|
+
// shorthand, IIFE bodies) execute as part of the lexical parent and
|
|
641
|
+
// their references count toward the parent's reachable name set.
|
|
642
|
+
if (_isFunctionNode(n) && n.id && n.id.name) return;
|
|
643
|
+
if (n.type === 'ClassBody') return; // descended via collectNamedFunctions
|
|
644
|
+
|
|
645
|
+
// For MemberExpression, only walk the object side ; the property name is
|
|
646
|
+
// already captured above for callees, and we never want to mark a name
|
|
647
|
+
// live just because it appears as a property name (e.g. `pkg.foo` shouldn't
|
|
648
|
+
// mark a local `foo` live unless `pkg` is the local's container - which
|
|
649
|
+
// we cannot resolve statically here).
|
|
650
|
+
if (n.type === 'MemberExpression' && !n.computed) {
|
|
651
|
+
if (n.object) walkLight(n.object);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Skip declarator id positions so a function's own name in
|
|
656
|
+
// `function foo() {}` doesn't mark foo as referenced.
|
|
657
|
+
if (n.type === 'VariableDeclarator') {
|
|
658
|
+
if (n.init) walkLight(n.init);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (n.type === 'FunctionDeclaration' || n.type === 'ClassDeclaration') {
|
|
662
|
+
// The declaration itself doesn't reference its own name.
|
|
663
|
+
if (n.body) walkLight(n.body);
|
|
664
|
+
if (n.superClass) walkLight(n.superClass);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
for (const key of Object.keys(n)) {
|
|
669
|
+
if (key === 'type' || key === 'loc' || key === 'start' || key === 'end') continue;
|
|
670
|
+
const child = n[key];
|
|
671
|
+
if (Array.isArray(child)) {
|
|
672
|
+
for (const it of child) if (it && typeof it === 'object' && it.type) walkLight(it);
|
|
673
|
+
} else if (child && typeof child === 'object' && child.type) walkLight(child);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
walkLight(node);
|
|
677
|
+
return names;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Identify the seeds: names known to be live because they are exported, called
|
|
682
|
+
* at top-level, or installed as event handlers / module entry points.
|
|
683
|
+
*
|
|
684
|
+
* - module.exports = X / module.exports.Y = X / exports.Y = X => X live
|
|
685
|
+
* - export function Foo => Foo live
|
|
686
|
+
* - export default <Identifier|Function> => name live
|
|
687
|
+
* - export { Foo, Bar } => Foo, Bar live
|
|
688
|
+
* - top-level CallExpression (Identifier callee) => name live
|
|
689
|
+
* - class Foo { ... } when Foo is exported => all methods live
|
|
690
|
+
*
|
|
691
|
+
* For each seed name, returns a Set<string>. Names that don't match a known
|
|
692
|
+
* named function are simply ignored downstream.
|
|
693
|
+
*/
|
|
694
|
+
function _collectSeedNames(ast) {
|
|
695
|
+
const seeds = new Set();
|
|
696
|
+
const exportedClassNames = new Set();
|
|
697
|
+
|
|
698
|
+
for (const stmt of ast.body) {
|
|
699
|
+
if (!stmt || !stmt.type) continue;
|
|
700
|
+
|
|
701
|
+
// Top-level: export named/default/declaration
|
|
702
|
+
if (stmt.type === 'ExportNamedDeclaration') {
|
|
703
|
+
if (stmt.declaration) {
|
|
704
|
+
if (stmt.declaration.type === 'FunctionDeclaration' && stmt.declaration.id) {
|
|
705
|
+
seeds.add(stmt.declaration.id.name);
|
|
706
|
+
} else if (stmt.declaration.type === 'ClassDeclaration' && stmt.declaration.id) {
|
|
707
|
+
seeds.add(stmt.declaration.id.name);
|
|
708
|
+
exportedClassNames.add(stmt.declaration.id.name);
|
|
709
|
+
} else if (stmt.declaration.type === 'VariableDeclaration') {
|
|
710
|
+
for (const d of stmt.declaration.declarations) {
|
|
711
|
+
if (d.id && d.id.type === 'Identifier') seeds.add(d.id.name);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (stmt.specifiers) {
|
|
716
|
+
for (const spec of stmt.specifiers) {
|
|
717
|
+
if (spec.local && spec.local.type === 'Identifier') seeds.add(spec.local.name);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
} else if (stmt.type === 'ExportDefaultDeclaration') {
|
|
721
|
+
const decl = stmt.declaration;
|
|
722
|
+
if (decl) {
|
|
723
|
+
if (decl.type === 'Identifier') seeds.add(decl.name);
|
|
724
|
+
else if (decl.type === 'FunctionDeclaration' && decl.id) seeds.add(decl.id.name);
|
|
725
|
+
else if (decl.type === 'ClassDeclaration' && decl.id) {
|
|
726
|
+
seeds.add(decl.id.name);
|
|
727
|
+
exportedClassNames.add(decl.id.name);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Top-level: module.exports / exports / module.exports.X assignments
|
|
733
|
+
if (stmt.type === 'ExpressionStatement' && stmt.expression &&
|
|
734
|
+
stmt.expression.type === 'AssignmentExpression' && stmt.expression.operator === '=') {
|
|
735
|
+
const left = stmt.expression.left;
|
|
736
|
+
const right = stmt.expression.right;
|
|
737
|
+
const isExportsAssign = left && left.type === 'MemberExpression' && (
|
|
738
|
+
(left.object.type === 'Identifier' && left.object.name === 'exports') ||
|
|
739
|
+
(left.object.type === 'Identifier' && left.object.name === 'module' &&
|
|
740
|
+
left.property && left.property.name === 'exports') ||
|
|
741
|
+
(left.object.type === 'MemberExpression' && left.object.object &&
|
|
742
|
+
left.object.object.type === 'Identifier' && left.object.object.name === 'module' &&
|
|
743
|
+
left.object.property && left.object.property.name === 'exports')
|
|
744
|
+
);
|
|
745
|
+
if (isExportsAssign && right) {
|
|
746
|
+
if (right.type === 'Identifier') {
|
|
747
|
+
seeds.add(right.name);
|
|
748
|
+
// The exported identifier may resolve to a class - mark it as a
|
|
749
|
+
// candidate so class_method entries with that className become
|
|
750
|
+
// seeds. Non-class names are silently ignored downstream.
|
|
751
|
+
exportedClassNames.add(right.name);
|
|
752
|
+
}
|
|
753
|
+
if (right.type === 'ObjectExpression') {
|
|
754
|
+
for (const prop of right.properties) {
|
|
755
|
+
if (prop.value && prop.value.type === 'Identifier') {
|
|
756
|
+
seeds.add(prop.value.name);
|
|
757
|
+
exportedClassNames.add(prop.value.name);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Top-level: any CallExpression (statement-position or buried in expression)
|
|
765
|
+
// adds its callee Identifier(s) to the seed set.
|
|
766
|
+
const seedNames = _calleeIdentifierNames(stmt);
|
|
767
|
+
for (const n of seedNames) seeds.add(n);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return { seeds, exportedClassNames };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Build the dead-range table for a single AST. Returns null when the file is
|
|
775
|
+
* dynamic (fail-open) ; otherwise returns { dynamic: false, deadRanges } where
|
|
776
|
+
* deadRanges is an array of { startLine, endLine, name }.
|
|
777
|
+
*/
|
|
778
|
+
function _analyzeFunctionReachability(ast) {
|
|
779
|
+
// Top-level dynamic ops (eval at script-level, dynamic require at script-
|
|
780
|
+
// level) can rebind globals - we cannot reason about the call graph, so
|
|
781
|
+
// fail-open the whole file. Same as the v1 behaviour.
|
|
782
|
+
if (_findTopLevelDynamic(ast)) {
|
|
783
|
+
return { dynamic: true, deadRanges: [] };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const namedFns = _collectNamedFunctions(ast);
|
|
787
|
+
if (namedFns.length === 0) {
|
|
788
|
+
return { dynamic: false, deadRanges: [] };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Detect functions whose body contains dynamic ops. These functions stay
|
|
792
|
+
// analyzable for reachability (a webpack chunk loader that's never called
|
|
793
|
+
// from any export remains dead), but if any of them becomes live during
|
|
794
|
+
// BFS we fail-open the rest of the file (we cannot follow into the eval).
|
|
795
|
+
const dynamicFnSet = new Set();
|
|
796
|
+
for (const fn of namedFns) {
|
|
797
|
+
if (_bodyHasDynamic(fn.bodyNode)) dynamicFnSet.add(fn);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Build name -> list of fn entries (a name can be redeclared, e.g. function
|
|
801
|
+
// overloads in different scopes ; treating any match as live keeps fail-open).
|
|
802
|
+
const byName = new Map();
|
|
803
|
+
for (const fn of namedFns) {
|
|
804
|
+
if (!byName.has(fn.name)) byName.set(fn.name, []);
|
|
805
|
+
byName.get(fn.name).push(fn);
|
|
806
|
+
}
|
|
807
|
+
// Also expose class methods by their bare method name to cover obj.method()
|
|
808
|
+
// calls without precise type tracking.
|
|
809
|
+
for (const fn of namedFns) {
|
|
810
|
+
if (fn.kind === 'class_method') {
|
|
811
|
+
const dot = fn.name.lastIndexOf('.');
|
|
812
|
+
const bare = dot >= 0 ? fn.name.slice(dot + 1) : fn.name;
|
|
813
|
+
if (!byName.has(bare)) byName.set(bare, []);
|
|
814
|
+
if (!byName.get(bare).includes(fn)) byName.get(bare).push(fn);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const { seeds, exportedClassNames } = _collectSeedNames(ast);
|
|
819
|
+
|
|
820
|
+
// Expand exported classes -> all their methods are live seeds
|
|
821
|
+
for (const fn of namedFns) {
|
|
822
|
+
if (fn.kind === 'class_method' && exportedClassNames.has(fn.className)) {
|
|
823
|
+
seeds.add(fn.name);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const live = new Set();
|
|
828
|
+
const queue = [];
|
|
829
|
+
let dynamicFailOpen = false; // a live function uses dynamic ops -> fail-open
|
|
830
|
+
|
|
831
|
+
function activate(name) {
|
|
832
|
+
const candidates = byName.get(name);
|
|
833
|
+
if (!candidates) return;
|
|
834
|
+
for (const fn of candidates) {
|
|
835
|
+
if (live.has(fn)) continue;
|
|
836
|
+
live.add(fn);
|
|
837
|
+
queue.push(fn);
|
|
838
|
+
// First time a dynamic-internal function becomes live : we can no
|
|
839
|
+
// longer reason about which other locals it may invoke. Mark every
|
|
840
|
+
// remaining named function live so deadRanges stays empty for them.
|
|
841
|
+
if (dynamicFnSet.has(fn) && !dynamicFailOpen) {
|
|
842
|
+
dynamicFailOpen = true;
|
|
843
|
+
for (const other of namedFns) {
|
|
844
|
+
if (live.has(other)) continue;
|
|
845
|
+
live.add(other);
|
|
846
|
+
queue.push(other);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
for (const seed of seeds) activate(seed);
|
|
853
|
+
|
|
854
|
+
let safety = 0;
|
|
855
|
+
while (queue.length > 0 && safety++ < 5000) {
|
|
856
|
+
const fn = queue.shift();
|
|
857
|
+
const calleeNames = _calleeIdentifierNames(fn.bodyNode);
|
|
858
|
+
for (const name of calleeNames) activate(name);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const deadRanges = [];
|
|
862
|
+
for (const fn of namedFns) {
|
|
863
|
+
if (live.has(fn)) continue;
|
|
864
|
+
deadRanges.push({
|
|
865
|
+
startLine: fn.startLine,
|
|
866
|
+
endLine: fn.endLine,
|
|
867
|
+
name: fn.name
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
return {
|
|
871
|
+
dynamic: false,
|
|
872
|
+
dynamicFailOpen,
|
|
873
|
+
dynamicFnCount: dynamicFnSet.size,
|
|
874
|
+
deadRanges
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Compute function-level reachability for every reachable file in a package.
|
|
880
|
+
* Returns Map<relFile, { dynamic, deadRanges }>. Files are skipped silently
|
|
881
|
+
* (parse failure, IO error, byte cap, file count cap, non-JS extension) so
|
|
882
|
+
* the caller can treat absence as "no info, do nothing".
|
|
883
|
+
*
|
|
884
|
+
* Bounded by MAX_FN_REACH_FILES and MAX_FN_REACH_BYTES per CLAUDE.md.
|
|
885
|
+
*/
|
|
886
|
+
function computeReachableFunctions(packagePath, reachableFiles) {
|
|
887
|
+
const out = new Map();
|
|
888
|
+
if (!reachableFiles || typeof reachableFiles[Symbol.iterator] !== 'function') return out;
|
|
889
|
+
|
|
890
|
+
let processed = 0;
|
|
891
|
+
for (const relFile of reachableFiles) {
|
|
892
|
+
if (processed >= MAX_FN_REACH_FILES) break;
|
|
893
|
+
if (typeof relFile !== 'string') continue;
|
|
894
|
+
if (!/\.(js|mjs|cjs)$/i.test(relFile)) continue;
|
|
895
|
+
|
|
896
|
+
const absFile = path.resolve(packagePath, relFile);
|
|
897
|
+
let content;
|
|
898
|
+
try {
|
|
899
|
+
const stat = fs.statSync(absFile);
|
|
900
|
+
if (!stat.isFile() || stat.size > MAX_FN_REACH_BYTES) continue;
|
|
901
|
+
content = fs.readFileSync(absFile, 'utf8');
|
|
902
|
+
} catch { continue; }
|
|
903
|
+
|
|
904
|
+
const ast = _parseWithLocations(content);
|
|
905
|
+
if (!ast) continue;
|
|
906
|
+
processed++;
|
|
907
|
+
|
|
908
|
+
const result = _analyzeFunctionReachability(ast);
|
|
909
|
+
if (result) out.set(relFile, result);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return out;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
module.exports = {
|
|
916
|
+
computeReachableFiles,
|
|
917
|
+
computeReachableFunctions,
|
|
918
|
+
getEntryPoints,
|
|
919
|
+
extractExportsPaths,
|
|
920
|
+
extractScriptJsFiles,
|
|
921
|
+
// Exported for tests
|
|
922
|
+
_internals: {
|
|
923
|
+
_hasDynamicResolution,
|
|
924
|
+
_collectNamedFunctions,
|
|
925
|
+
_collectSeedNames,
|
|
926
|
+
_analyzeFunctionReachability,
|
|
927
|
+
_parseWithLocations,
|
|
928
|
+
MAX_FN_REACH_FILES,
|
|
929
|
+
MAX_FN_REACH_BYTES
|
|
930
|
+
}
|
|
931
|
+
};
|
package/src/scanner/typosquat.js
CHANGED
|
@@ -116,7 +116,10 @@ const WHITELIST = new Set([
|
|
|
116
116
|
|
|
117
117
|
// Audit v3 B3: well-established packages flagged as typosquat of multiple popular packages
|
|
118
118
|
'color', // resembles colors (18M dl/week, 5385d old)
|
|
119
|
-
'ttypescript'
|
|
119
|
+
'ttypescript', // resembles typescript (70K dl/week, 3198d old)
|
|
120
|
+
// FPR plan : established alternative utility libraries falsely matched
|
|
121
|
+
// against lodash/express by the wrong_char + similarity heuristic.
|
|
122
|
+
'radash' // utility library, ~1.5M dl/week — wrong_char vs lodash
|
|
120
123
|
]);
|
|
121
124
|
|
|
122
125
|
|
|
@@ -131,7 +134,8 @@ const WHITELIST_PAIRS = new Map([
|
|
|
131
134
|
['cypress', 'express'], ['colord', 'colors'], ['read', 'react'],
|
|
132
135
|
['ulid', 'uuid'], ['tslint', 'eslint'], ['jison', 'sinon'],
|
|
133
136
|
['reds', 'redis'], ['docdash', 'lodash'], ['yarpm', 'yargs'],
|
|
134
|
-
['canvg', 'canvas'], ['mocks', 'mocha'], ['reactor', 'react']
|
|
137
|
+
['canvg', 'canvas'], ['mocks', 'mocha'], ['reactor', 'react'],
|
|
138
|
+
['radash', 'lodash']
|
|
135
139
|
]);
|
|
136
140
|
|
|
137
141
|
// Pre-computed lowercase versions for performance
|