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
package/src/scoring.js
CHANGED
|
@@ -447,7 +447,16 @@ const REACHABILITY_EXEMPT_TYPES = new Set([
|
|
|
447
447
|
'pypi_malicious_package',
|
|
448
448
|
'ai_config_injection', 'ai_config_injection_compound',
|
|
449
449
|
'detached_credential_exfil', // DPRK/Lazarus: invoked via lifecycle, not require/import
|
|
450
|
-
'native_addon_install' // binding.gyp executes during npm install but isn't require()'d
|
|
450
|
+
'native_addon_install', // binding.gyp executes during npm install but isn't require()'d
|
|
451
|
+
// Staged loader pattern (chai-* / poxios-chain campaign 2026-05): the malicious
|
|
452
|
+
// file is loaded indirectly (transport.js requires caller.js) and reachability
|
|
453
|
+
// resolution can fail, demoting CRITICAL to LOW. These types are unambiguously
|
|
454
|
+
// malicious — no legitimate code shadows process, calls Function.constructor("require"),
|
|
455
|
+
// or self-destructs after running new Function(...).
|
|
456
|
+
'function_constructor_require', // AST-086 — Function.constructor("require", body)
|
|
457
|
+
'process_variable_shadow', // AST-087 — const process = {env:{...}}
|
|
458
|
+
'function_runtime_args', // AST-090 — new Function('require','__dirname',...)
|
|
459
|
+
'self_destruct_eval' // AST-089 — dynamic exec + unlink __filename
|
|
451
460
|
]);
|
|
452
461
|
|
|
453
462
|
// ============================================
|
|
@@ -500,8 +509,12 @@ const SCORING_COMPOUNDS = [
|
|
|
500
509
|
requires: ['lifecycle_script', 'suspicious_dataflow'],
|
|
501
510
|
severity: 'HIGH',
|
|
502
511
|
message: 'Lifecycle hook + suspicious dataflow — install-time credential/data exfiltration pattern (scoring compound).',
|
|
503
|
-
fileFrom: 'suspicious_dataflow'
|
|
512
|
+
fileFrom: 'suspicious_dataflow',
|
|
504
513
|
// No sameFile: lifecycle is package-level, dataflow is file-level
|
|
514
|
+
// C7 : when every component lives only in dist/build/out, the cooccurrence
|
|
515
|
+
// is bundler aggregation (a postinstall mention + a pre-bundled HTTP client
|
|
516
|
+
// with credential fields), not real exfiltration. Skip the compound.
|
|
517
|
+
excludeIfBundled: true
|
|
505
518
|
},
|
|
506
519
|
{
|
|
507
520
|
type: 'lifecycle_dangerous_exec',
|
|
@@ -510,6 +523,8 @@ const SCORING_COMPOUNDS = [
|
|
|
510
523
|
message: 'Lifecycle hook + dangerous shell execution — install-time command injection (scoring compound).',
|
|
511
524
|
fileFrom: 'dangerous_exec'
|
|
512
525
|
// No sameFile: lifecycle is package-level
|
|
526
|
+
// dangerous_exec is in DIST_EXEMPT_TYPES so it is never coincidental in
|
|
527
|
+
// dist/ ; no excludeIfBundled gate added here.
|
|
513
528
|
},
|
|
514
529
|
{
|
|
515
530
|
type: 'obfuscated_lifecycle_env',
|
|
@@ -518,7 +533,12 @@ const SCORING_COMPOUNDS = [
|
|
|
518
533
|
message: 'Obfuscation + credential env access + lifecycle hook — obfuscated install-time credential theft (scoring compound).',
|
|
519
534
|
fileFrom: 'env_access',
|
|
520
535
|
// Only obfuscation_detected + env_access must be in the same file (lifecycle_script is package-level)
|
|
521
|
-
sameFileTypes: ['obfuscation_detected', 'env_access']
|
|
536
|
+
sameFileTypes: ['obfuscation_detected', 'env_access'],
|
|
537
|
+
// C7 : tighter gate - a single MEDIUM env_access alongside generic
|
|
538
|
+
// obfuscation_detected in a bundler is not proof of credential theft.
|
|
539
|
+
// Require at least one component with originalSeverity HIGH+ to fire.
|
|
540
|
+
requireOriginalSeverityHigh: true,
|
|
541
|
+
excludeIfBundled: true
|
|
522
542
|
},
|
|
523
543
|
// v2.10.89: Security review compounds
|
|
524
544
|
{
|
|
@@ -558,6 +578,20 @@ const SCORING_COMPOUNDS = [
|
|
|
558
578
|
message: 'Stub package with external URL dep + known string IOC — chain-attack staging package (scoring compound).',
|
|
559
579
|
fileFrom: 'ioc_string_match'
|
|
560
580
|
},
|
|
581
|
+
// Security review 2026-05-09 — chai-* / poxios-chain / express-guardrail / justenv
|
|
582
|
+
// campaign. Pattern: fork pino + caller.js with `const process = {env: {DEV_API_KEY: <base64>}}`
|
|
583
|
+
// + axios.get(decoded URL) + new Function.constructor("require", body). The package
|
|
584
|
+
// body is otherwise legitimate pino code — only the injected file is malicious.
|
|
585
|
+
// Each individual signal is already CRITICAL/HIGH but reachability/per-file scoring
|
|
586
|
+
// can demote them. The compound recovers the signal when 2+ co-occur in the same file.
|
|
587
|
+
{
|
|
588
|
+
type: 'staged_remote_loader',
|
|
589
|
+
requires: ['function_constructor_require', 'process_variable_shadow'],
|
|
590
|
+
severity: 'CRITICAL',
|
|
591
|
+
message: 'Function.constructor("require", body) + shadowed process env in same file — staged remote loader (chai-* / poxios-chain pattern). Payload fetched at runtime from external paste service.',
|
|
592
|
+
fileFrom: 'function_constructor_require',
|
|
593
|
+
sameFile: true
|
|
594
|
+
},
|
|
561
595
|
];
|
|
562
596
|
|
|
563
597
|
/**
|
|
@@ -591,6 +625,41 @@ function applyCompoundBoosts(threats) {
|
|
|
591
625
|
);
|
|
592
626
|
if (!hasSignificantComponent) continue;
|
|
593
627
|
|
|
628
|
+
// FPR plan Chantier 7 : tighter severity gate. requireOriginalSeverityHigh
|
|
629
|
+
// raises the bar from MEDIUM+ to HIGH+ on at least one component. Compounds
|
|
630
|
+
// whose constituents are mostly heuristic (env_access, obfuscation_detected)
|
|
631
|
+
// need HIGH+ to confirm a real signal versus framework noise.
|
|
632
|
+
if (compound.requireOriginalSeverityHigh) {
|
|
633
|
+
const hasHighOrigin = compound.requires.some(req =>
|
|
634
|
+
threats.some(t => t.type === req && (
|
|
635
|
+
(t.originalSeverity || t.severity) === 'HIGH' ||
|
|
636
|
+
(t.originalSeverity || t.severity) === 'CRITICAL'
|
|
637
|
+
))
|
|
638
|
+
);
|
|
639
|
+
if (!hasHighOrigin) continue;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// FPR plan Chantier 7 : excludeIfBundled. When every required component
|
|
643
|
+
// appears only in bundle files, the co-occurrence is bundler aggregation
|
|
644
|
+
// (e.g. an HTTP client compiled into the same chunk as the credential-
|
|
645
|
+
// handling code), not malice. lifecycle_script is package-level
|
|
646
|
+
// (file = package.json) so it bypasses this gate naturally. Uses the
|
|
647
|
+
// canonical BUNDLE_PATH_RE which covers dist/build/out/.yarn/releases/
|
|
648
|
+
// assets/chunks/_app/fesm*/esm5/esm2015 etc. (vs the narrow DIST_FILE_RE).
|
|
649
|
+
if (compound.excludeIfBundled) {
|
|
650
|
+
const { BUNDLE_PATH_RE } = require('./shared/bundle-detect.js');
|
|
651
|
+
const allComponentsBundled = compound.requires.every(req => {
|
|
652
|
+
const fileBearingThreats = threats.filter(t => t.type === req && t.file && t.file !== 'package.json');
|
|
653
|
+
if (fileBearingThreats.length === 0) return true; // package-level, ignore for bundle test
|
|
654
|
+
return fileBearingThreats.every(t => BUNDLE_PATH_RE.test(t.file));
|
|
655
|
+
});
|
|
656
|
+
// Only skip when there is at least one file-bearing component AND all are bundled
|
|
657
|
+
const anyFileBearing = compound.requires.some(req =>
|
|
658
|
+
threats.some(t => t.type === req && t.file && t.file !== 'package.json')
|
|
659
|
+
);
|
|
660
|
+
if (anyFileBearing && allComponentsBundled) continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
594
663
|
// Same-file constraint: required types must appear in at least one common file.
|
|
595
664
|
// sameFile: true = ALL required types must share a file.
|
|
596
665
|
// sameFileTypes: [...] = only specified types must share a file.
|
|
@@ -651,7 +720,7 @@ const FRAMEWORK_PROTO_RE = new RegExp(
|
|
|
651
720
|
'^(' + FRAMEWORK_PROTOTYPES.join('|') + ')\\.prototype\\.'
|
|
652
721
|
);
|
|
653
722
|
|
|
654
|
-
function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
723
|
+
function applyFPReductions(threats, reachableFiles, packageName, packageDeps, reachableFunctions) {
|
|
655
724
|
// Initialize reductions audit trail on each threat
|
|
656
725
|
// Store original severity before any FP reductions, so compound
|
|
657
726
|
// severity gates can check pre-reduction severity (GAP 4b).
|
|
@@ -894,6 +963,25 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
|
894
963
|
t.severity = 'LOW';
|
|
895
964
|
t.unreachable = true;
|
|
896
965
|
}
|
|
966
|
+
// FPR plan C2 : the file IS reachable but the surrounding function is
|
|
967
|
+
// not. Only fires when reachableFunctions is provided (env-flagged) and
|
|
968
|
+
// the threat carries a t.line (dataflow + anti-forensic emit lines ;
|
|
969
|
+
// most other scanners do not, so this is a no-op for them).
|
|
970
|
+
else if (reachableFunctions && typeof t.line === 'number' && t.line > 0) {
|
|
971
|
+
const fnInfo = reachableFunctions.get(normalizedFile);
|
|
972
|
+
if (fnInfo && !fnInfo.dynamic && Array.isArray(fnInfo.deadRanges)) {
|
|
973
|
+
for (const range of fnInfo.deadRanges) {
|
|
974
|
+
if (t.line >= range.startLine && t.line <= range.endLine) {
|
|
975
|
+
if (t.severity !== 'LOW') {
|
|
976
|
+
t.reductions.push({ rule: 'unreachable_function', from: t.severity, to: 'LOW' });
|
|
977
|
+
t.severity = 'LOW';
|
|
978
|
+
}
|
|
979
|
+
t.unreachableFunction = range.name || true;
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
897
985
|
}
|
|
898
986
|
|
|
899
987
|
// C2: MCP server awareness — legitimate MCP servers write to MCP config files.
|
|
@@ -1338,6 +1426,26 @@ function _factorFromMetadata(meta) {
|
|
|
1338
1426
|
else if (authorPkgs > 50) factor -= 0.1;
|
|
1339
1427
|
signalsApplied++;
|
|
1340
1428
|
}
|
|
1429
|
+
// FPR plan Chantier 4 : advanced registry signals. These complement the
|
|
1430
|
+
// basic AUC-tuned signals above. The first three are *boosts* used as a
|
|
1431
|
+
// safety net for Chantier 5's mature stable cap : an account takeover on a
|
|
1432
|
+
// mature package would otherwise be hidden by a passive multiplier.
|
|
1433
|
+
//
|
|
1434
|
+
// maintainer_change_recent -> +0.20 (Shai-Hulud / Axios pattern in 2026)
|
|
1435
|
+
// publish_cadence_anomaly -> +0.10 (publish frequency suddenly off)
|
|
1436
|
+
// stable_ownership_2y -> -0.15 (suppression douce, only structural)
|
|
1437
|
+
if (meta.maintainer_change_recent === true) {
|
|
1438
|
+
factor += 0.20;
|
|
1439
|
+
signalsApplied++;
|
|
1440
|
+
}
|
|
1441
|
+
if (meta.publish_cadence_anomaly === true) {
|
|
1442
|
+
factor += 0.10;
|
|
1443
|
+
signalsApplied++;
|
|
1444
|
+
}
|
|
1445
|
+
if (meta.stable_ownership_2y === true) {
|
|
1446
|
+
factor -= 0.15;
|
|
1447
|
+
signalsApplied++;
|
|
1448
|
+
}
|
|
1341
1449
|
// If no signals applied (metadata fully absent), return neutral 1.0 rather
|
|
1342
1450
|
// than the default-shaped factor — avoid spurious adjustments on rows where
|
|
1343
1451
|
// the registry data is simply missing.
|
|
@@ -1347,6 +1455,27 @@ function _factorFromMetadata(meta) {
|
|
|
1347
1455
|
|
|
1348
1456
|
function applyReputationFactor(result, metadata) {
|
|
1349
1457
|
if (!result || !result.summary || !metadata) return null;
|
|
1458
|
+
// FPR plan : the reputation factor describes "how trustworthy this package
|
|
1459
|
+
// looks TODAY in the registry". Applying it to a historical / pinned-old /
|
|
1460
|
+
// vendored version with the same name would import today's good reputation
|
|
1461
|
+
// onto a code snapshot that may have been compromised at the time.
|
|
1462
|
+
// When both version fields are present we require them to match. When the
|
|
1463
|
+
// scan version is unknown (CLI scanning a directory without version field),
|
|
1464
|
+
// we fail open : skip the factor entirely rather than apply a multiplier
|
|
1465
|
+
// we cannot situate in time.
|
|
1466
|
+
if (
|
|
1467
|
+
typeof metadata.latest_version === 'string' &&
|
|
1468
|
+
typeof metadata.scan_version === 'string' &&
|
|
1469
|
+
metadata.latest_version !== metadata.scan_version
|
|
1470
|
+
) {
|
|
1471
|
+
return null;
|
|
1472
|
+
}
|
|
1473
|
+
if (
|
|
1474
|
+
typeof metadata.latest_version === 'string' &&
|
|
1475
|
+
typeof metadata.scan_version !== 'string'
|
|
1476
|
+
) {
|
|
1477
|
+
return null;
|
|
1478
|
+
}
|
|
1350
1479
|
const factor = _factorFromMetadata(metadata);
|
|
1351
1480
|
if (factor === 1.0) {
|
|
1352
1481
|
result.summary.reputationFactor = 1.0;
|
|
@@ -1364,6 +1493,119 @@ function applyReputationFactor(result, metadata) {
|
|
|
1364
1493
|
return { factor, oldScore, newScore };
|
|
1365
1494
|
}
|
|
1366
1495
|
|
|
1496
|
+
// ============================================
|
|
1497
|
+
// FPR plan Chantier 5 : MATURE STABLE PACKAGE CAP
|
|
1498
|
+
// ============================================
|
|
1499
|
+
//
|
|
1500
|
+
// Caps the risk score at MEDIUM (25) when a package satisfies *all* of the
|
|
1501
|
+
// following structural conditions, none of which are recoverable by simple
|
|
1502
|
+
// rule heuristics :
|
|
1503
|
+
//
|
|
1504
|
+
// - >= 5 years old
|
|
1505
|
+
// - >= 50 published versions
|
|
1506
|
+
// - >= 10 000 weekly downloads
|
|
1507
|
+
// - stable_ownership_2y (registry signal from Chantier 4)
|
|
1508
|
+
// - no HIGH_CONFIDENCE_MALICE_TYPES present
|
|
1509
|
+
// - no IOC match
|
|
1510
|
+
// - no delta-added threats (skipped while Chantier 3 ships - the cap still
|
|
1511
|
+
// applies because the caller has not provided the field, and we treat
|
|
1512
|
+
// that as "no positive evidence of fresh malicious additions")
|
|
1513
|
+
//
|
|
1514
|
+
// This is the single structural suppression introduced by the FPR plan and
|
|
1515
|
+
// always sits BETWEEN applyContextualFPCaps (which it composes with) and
|
|
1516
|
+
// applySingleFireCriticalFloor (which can override it on hard signals). The
|
|
1517
|
+
// reputation factor downstream multiplies what's left.
|
|
1518
|
+
//
|
|
1519
|
+
// Disabled by default ; gated behind MUADDIB_MATURE_CAP=1 until measured
|
|
1520
|
+
// against the full evaluation corpus.
|
|
1521
|
+
|
|
1522
|
+
const MATURE_CAP_SCORE = 25;
|
|
1523
|
+
const MATURE_MIN_AGE_DAYS = 5 * 365;
|
|
1524
|
+
const MATURE_MIN_VERSION_COUNT = 50;
|
|
1525
|
+
const MATURE_MIN_WEEKLY_DOWNLOADS = 10000;
|
|
1526
|
+
|
|
1527
|
+
const MATURE_CAP_IOC_TYPES = new Set([
|
|
1528
|
+
'ioc_match',
|
|
1529
|
+
'ioc_string_match',
|
|
1530
|
+
'known_malicious_hash',
|
|
1531
|
+
'known_malicious_package',
|
|
1532
|
+
'pypi_malicious_package',
|
|
1533
|
+
'shai_hulud_marker',
|
|
1534
|
+
'dependency_ioc_match'
|
|
1535
|
+
]);
|
|
1536
|
+
|
|
1537
|
+
function applyMatureStableCap(result, registryMeta) {
|
|
1538
|
+
if (!result || !result.summary || !Array.isArray(result.threats)) return null;
|
|
1539
|
+
if (!registryMeta) return null;
|
|
1540
|
+
|
|
1541
|
+
const reasons = {};
|
|
1542
|
+
|
|
1543
|
+
// FPR plan : the cap MUST only fire when scanning the package's current
|
|
1544
|
+
// latest version. Historical / pinned-old / vendored versions of mature
|
|
1545
|
+
// packages bypass the cap so we never mask a static fixture of a real
|
|
1546
|
+
// attack (eslint-scope 3.7.2, chalk 5.6.1, coa 2.0.3, rc, solana-web3js,
|
|
1547
|
+
// ledgerhq-connect-kit etc.). Both fields must be present and equal.
|
|
1548
|
+
if (
|
|
1549
|
+
typeof registryMeta.latest_version !== 'string' ||
|
|
1550
|
+
typeof registryMeta.scan_version !== 'string' ||
|
|
1551
|
+
registryMeta.latest_version !== registryMeta.scan_version
|
|
1552
|
+
) {
|
|
1553
|
+
return null;
|
|
1554
|
+
}
|
|
1555
|
+
reasons.scan_version = registryMeta.scan_version;
|
|
1556
|
+
reasons.latest_version = registryMeta.latest_version;
|
|
1557
|
+
|
|
1558
|
+
if (!_hasNumeric(registryMeta.age_days) || registryMeta.age_days < MATURE_MIN_AGE_DAYS) {
|
|
1559
|
+
return null;
|
|
1560
|
+
}
|
|
1561
|
+
reasons.age_days = registryMeta.age_days;
|
|
1562
|
+
|
|
1563
|
+
if (!_hasNumeric(registryMeta.version_count) || registryMeta.version_count < MATURE_MIN_VERSION_COUNT) {
|
|
1564
|
+
return null;
|
|
1565
|
+
}
|
|
1566
|
+
reasons.version_count = registryMeta.version_count;
|
|
1567
|
+
|
|
1568
|
+
if (!_hasNumeric(registryMeta.weekly_downloads) || registryMeta.weekly_downloads < MATURE_MIN_WEEKLY_DOWNLOADS) {
|
|
1569
|
+
return null;
|
|
1570
|
+
}
|
|
1571
|
+
reasons.weekly_downloads = registryMeta.weekly_downloads;
|
|
1572
|
+
|
|
1573
|
+
if (registryMeta.stable_ownership_2y !== true) {
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
reasons.stable_ownership_2y = true;
|
|
1577
|
+
|
|
1578
|
+
// delta_added_threats is provided by Chantier 3 ; treat undefined as 0 to
|
|
1579
|
+
// ship Chantier 5 standalone. When C3 wires through a real number, the
|
|
1580
|
+
// condition `> 0` correctly disables the cap on suspicious deltas.
|
|
1581
|
+
const deltaAdded = registryMeta.delta_added_threats;
|
|
1582
|
+
if (typeof deltaAdded === 'number' && deltaAdded > 0) {
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
reasons.delta_added_threats = deltaAdded == null ? 0 : deltaAdded;
|
|
1586
|
+
|
|
1587
|
+
for (const t of result.threats) {
|
|
1588
|
+
if (HIGH_CONFIDENCE_MALICE_TYPES.has(t.type)) return null;
|
|
1589
|
+
if (MATURE_CAP_IOC_TYPES.has(t.type)) return null;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
if (result.summary.riskScore <= MATURE_CAP_SCORE) {
|
|
1593
|
+
return null; // already at/below cap, nothing to do
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const oldScore = result.summary.riskScore;
|
|
1597
|
+
result.summary.riskScore = MATURE_CAP_SCORE;
|
|
1598
|
+
result.summary.matureStableCap = true;
|
|
1599
|
+
result.summary.matureStableCapReasons = reasons;
|
|
1600
|
+
const rs = MATURE_CAP_SCORE;
|
|
1601
|
+
result.summary.riskLevel =
|
|
1602
|
+
rs >= _riskThresholds.CRITICAL ? 'CRITICAL'
|
|
1603
|
+
: rs >= _riskThresholds.HIGH ? 'HIGH'
|
|
1604
|
+
: rs >= _riskThresholds.MEDIUM ? 'MEDIUM'
|
|
1605
|
+
: rs > 0 ? 'LOW' : 'SAFE';
|
|
1606
|
+
return { applied: true, oldScore, newScore: MATURE_CAP_SCORE, reasons };
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1367
1609
|
/**
|
|
1368
1610
|
* Hybrid v3 Phase 1: apply CRITICAL floor when a deterministic single-fire
|
|
1369
1611
|
* type is present at HIGH+ severity. Mutates result.summary.riskScore /
|
|
@@ -1396,10 +1638,151 @@ function applySingleFireCriticalFloor(result) {
|
|
|
1396
1638
|
return triggers;
|
|
1397
1639
|
}
|
|
1398
1640
|
|
|
1641
|
+
// ── Sandbox verdict scoring contract ──
|
|
1642
|
+
// Applied AFTER the sandbox completes a run triggered by evaluateSandboxTrigger.
|
|
1643
|
+
// The verdict adjusts the static riskScore based on dynamic confirmation.
|
|
1644
|
+
// Static stays the primary detection layer; sandbox is a borderline tribunal.
|
|
1645
|
+
const SANDBOX_VERDICT_CONFIRMED_FLOOR = 75;
|
|
1646
|
+
const SANDBOX_VERDICT_CHAIN_FLOOR = 60;
|
|
1647
|
+
const SANDBOX_VERDICT_CLEAN_DELTA = 8;
|
|
1648
|
+
|
|
1649
|
+
const _CRITICAL_PRELOAD_TYPES = new Set([
|
|
1650
|
+
'sandbox_network_after_sensitive_read',
|
|
1651
|
+
'sandbox_known_exfil_domain',
|
|
1652
|
+
'sandbox_timer_delay_critical',
|
|
1653
|
+
'canary_exfiltration',
|
|
1654
|
+
'sandbox_honey_read',
|
|
1655
|
+
'sandbox_persistence_write',
|
|
1656
|
+
'sandbox_npm_self_invoke',
|
|
1657
|
+
'sandbox_runtime_deobfuscation_executed'
|
|
1658
|
+
]);
|
|
1659
|
+
|
|
1660
|
+
const _HIGH_COMPOUND_TYPES = new Set([
|
|
1661
|
+
'sandbox_preload_sensitive_read',
|
|
1662
|
+
'sandbox_exec_suspicious',
|
|
1663
|
+
'sandbox_network_outlier',
|
|
1664
|
+
'sandbox_execve_chain_depth',
|
|
1665
|
+
'sandbox_credential_target_read'
|
|
1666
|
+
]);
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* Classify a sandbox run into a verdict and apply the score contract.
|
|
1670
|
+
*
|
|
1671
|
+
* Verdicts:
|
|
1672
|
+
* - MALICIOUS_CONFIRMED: >=1 critical preload signal OR honey read correlated
|
|
1673
|
+
* with non-registry outbound. Floor score at 75.
|
|
1674
|
+
* - MALICIOUS_CHAIN: >=2 high-severity compound signals in the same run.
|
|
1675
|
+
* Floor score at 60.
|
|
1676
|
+
* - CLEAN_HIGH_CONFIDENCE: install completed, no fingerprint detected, no
|
|
1677
|
+
* sandbox findings beyond DNS resolutions/registry traffic. Apply -8 delta.
|
|
1678
|
+
* - INCONCLUSIVE: gVisor unavailable, install crashed, or fingerprint
|
|
1679
|
+
* detected. Score unchanged, warning attached.
|
|
1680
|
+
*
|
|
1681
|
+
* Mutates result.summary.riskScore, riskLevel, and adds result.summary.sandboxVerdict.
|
|
1682
|
+
*
|
|
1683
|
+
* @param {object} result - Scan result with summary.{riskScore, riskLevel, ...}.
|
|
1684
|
+
* @param {object} sandboxResult - Output of runSandbox(): {score, severity, findings, raw_report, suspicious, inconclusive}.
|
|
1685
|
+
* @returns {{verdict:string, oldScore:number, newScore:number, signals:string[]}|null}
|
|
1686
|
+
*/
|
|
1687
|
+
function applySandboxVerdict(result, sandboxResult) {
|
|
1688
|
+
if (!result || !result.summary || !sandboxResult) return null;
|
|
1689
|
+
|
|
1690
|
+
const findings = Array.isArray(sandboxResult.findings) ? sandboxResult.findings : [];
|
|
1691
|
+
const oldScore = result.summary.riskScore || 0;
|
|
1692
|
+
|
|
1693
|
+
// Inconclusive path: explicit flag, or score === -1 sentinel.
|
|
1694
|
+
if (sandboxResult.inconclusive === true || sandboxResult.score === -1) {
|
|
1695
|
+
result.summary.sandboxVerdict = {
|
|
1696
|
+
verdict: 'INCONCLUSIVE',
|
|
1697
|
+
oldScore,
|
|
1698
|
+
newScore: oldScore,
|
|
1699
|
+
signals: findings.filter(f => f && f.type).map(f => f.type)
|
|
1700
|
+
};
|
|
1701
|
+
return result.summary.sandboxVerdict;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
const findingTypes = findings.filter(f => f && f.type).map(f => f.type);
|
|
1705
|
+
const criticalSignals = findingTypes.filter(t => _CRITICAL_PRELOAD_TYPES.has(t));
|
|
1706
|
+
const highSignals = findingTypes.filter(t => _HIGH_COMPOUND_TYPES.has(t));
|
|
1707
|
+
|
|
1708
|
+
// MALICIOUS_CONFIRMED: any single critical preload/honey signal is enough.
|
|
1709
|
+
if (criticalSignals.length >= 1) {
|
|
1710
|
+
const newScore = Math.max(oldScore, SANDBOX_VERDICT_CONFIRMED_FLOOR);
|
|
1711
|
+
result.summary.riskScore = newScore;
|
|
1712
|
+
_refreshRiskLevel(result);
|
|
1713
|
+
result.summary.sandboxVerdict = {
|
|
1714
|
+
verdict: 'MALICIOUS_CONFIRMED',
|
|
1715
|
+
oldScore,
|
|
1716
|
+
newScore,
|
|
1717
|
+
signals: criticalSignals
|
|
1718
|
+
};
|
|
1719
|
+
return result.summary.sandboxVerdict;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// MALICIOUS_CHAIN: 2+ high-severity compound signals from preload analyzer.
|
|
1723
|
+
if (highSignals.length >= 2) {
|
|
1724
|
+
const newScore = Math.max(oldScore, SANDBOX_VERDICT_CHAIN_FLOOR);
|
|
1725
|
+
result.summary.riskScore = newScore;
|
|
1726
|
+
_refreshRiskLevel(result);
|
|
1727
|
+
result.summary.sandboxVerdict = {
|
|
1728
|
+
verdict: 'MALICIOUS_CHAIN',
|
|
1729
|
+
oldScore,
|
|
1730
|
+
newScore,
|
|
1731
|
+
signals: highSignals
|
|
1732
|
+
};
|
|
1733
|
+
return result.summary.sandboxVerdict;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// CLEAN_HIGH_CONFIDENCE: install completed, sandbox score 0, no findings
|
|
1737
|
+
// beyond INFO (DNS resolutions). Anti-fingerprint must NOT have been
|
|
1738
|
+
// detected (fingerprint detection is treated as inconclusive above).
|
|
1739
|
+
const actionableFindings = findings.filter(f => f && f.severity && f.severity !== 'INFO');
|
|
1740
|
+
const installCompleted = sandboxResult.score === 0 && !sandboxResult.inconclusive;
|
|
1741
|
+
if (installCompleted && actionableFindings.length === 0) {
|
|
1742
|
+
const newScore = Math.max(0, oldScore - SANDBOX_VERDICT_CLEAN_DELTA);
|
|
1743
|
+
result.summary.riskScore = newScore;
|
|
1744
|
+
_refreshRiskLevel(result);
|
|
1745
|
+
result.summary.sandboxVerdict = {
|
|
1746
|
+
verdict: 'CLEAN_HIGH_CONFIDENCE',
|
|
1747
|
+
oldScore,
|
|
1748
|
+
newScore,
|
|
1749
|
+
signals: []
|
|
1750
|
+
};
|
|
1751
|
+
return result.summary.sandboxVerdict;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// Default: signals present but below thresholds — record verdict, no change.
|
|
1755
|
+
result.summary.sandboxVerdict = {
|
|
1756
|
+
verdict: 'INCONCLUSIVE',
|
|
1757
|
+
oldScore,
|
|
1758
|
+
newScore: oldScore,
|
|
1759
|
+
signals: findingTypes
|
|
1760
|
+
};
|
|
1761
|
+
return result.summary.sandboxVerdict;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function _refreshRiskLevel(result) {
|
|
1765
|
+
const rs = result.summary.riskScore;
|
|
1766
|
+
result.summary.riskLevel =
|
|
1767
|
+
rs >= _riskThresholds.CRITICAL ? 'CRITICAL'
|
|
1768
|
+
: rs >= _riskThresholds.HIGH ? 'HIGH'
|
|
1769
|
+
: rs >= _riskThresholds.MEDIUM ? 'MEDIUM'
|
|
1770
|
+
: rs > 0 ? 'LOW' : 'SAFE';
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// FPR plan Chantier 3 - delta-aware decay. Re-exported here so the pipeline
|
|
1774
|
+
// can call it after applyFPReductions and before applyCompoundBoosts (matching
|
|
1775
|
+
// the plan : compounds operate on freshly-introduced patterns, never on stable
|
|
1776
|
+
// long-standing ones, so we suppress noise BEFORE compound boosts run).
|
|
1777
|
+
const { applyDeltaMultiplier } = require('./scoring/delta-multiplier.js');
|
|
1778
|
+
|
|
1399
1779
|
module.exports = {
|
|
1400
1780
|
SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, CONFIDENCE_FACTORS,
|
|
1401
1781
|
SINGLE_FIRE_CRITICAL_TYPES, SINGLE_FIRE_CRITICAL_FLOOR, DECAY_ALPHA,
|
|
1402
1782
|
REPUTATION_FACTOR_BOUNDS,
|
|
1783
|
+
MATURE_CAP_SCORE, MATURE_MIN_AGE_DAYS, MATURE_MIN_VERSION_COUNT, MATURE_MIN_WEEKLY_DOWNLOADS,
|
|
1784
|
+
SANDBOX_VERDICT_CONFIRMED_FLOOR, SANDBOX_VERDICT_CHAIN_FLOOR, SANDBOX_VERDICT_CLEAN_DELTA,
|
|
1785
|
+
applyMatureStableCap, applySandboxVerdict, applyDeltaMultiplier,
|
|
1403
1786
|
isPackageLevelThreat, computeGroupScore, computeGroupScoreDecay,
|
|
1404
1787
|
applyFPReductions, applyCompoundBoosts, calculateRiskScore,
|
|
1405
1788
|
applyConfigOverrides, resetConfigOverrides, getSeverityWeights, getRiskThresholds,
|