@lateos/npm-scan 1.0.0 → 1.1.1

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.
Files changed (129) hide show
  1. package/README.de.md +3 -98
  2. package/README.fr.md +3 -98
  3. package/README.ja.md +3 -98
  4. package/README.md +2 -122
  5. package/README.zh.md +3 -98
  6. package/backend/cra.js +113 -21
  7. package/backend/db.js +18 -10
  8. package/backend/detectors/atk-001-lifecycle.js +5 -5
  9. package/backend/detectors/atk-002-obfusc.js +126 -47
  10. package/backend/detectors/atk-003-creds.js +8 -4
  11. package/backend/detectors/atk-004-persist.js +3 -3
  12. package/backend/detectors/atk-005-exfil.js +8 -4
  13. package/backend/detectors/atk-006-depconf.js +3 -3
  14. package/backend/detectors/atk-007-typosquat.js +64 -10
  15. package/backend/detectors/atk-008-tarball-tamper.js +6 -6
  16. package/backend/detectors/atk-009-dormant-trigger.js +9 -5
  17. package/backend/detectors/atk-010-sandbox-evasion.js +25 -10
  18. package/backend/detectors/atk-011-transitive-prop.js +14 -13
  19. package/backend/detectors/axios-poisoning/d1-version-fingerprint.js +4 -4
  20. package/backend/detectors/axios-poisoning/d2-decoy-dep.js +5 -1
  21. package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +64 -19
  22. package/backend/detectors/axios-poisoning/index.js +77 -60
  23. package/backend/detectors/config/thresholds.js +48 -3
  24. package/backend/detectors/cve-2026-48710-badhost/codePattern.js +26 -9
  25. package/backend/detectors/cve-2026-48710-badhost/findings.js +8 -4
  26. package/backend/detectors/cve-2026-48710-badhost/index.js +1 -1
  27. package/backend/detectors/cve-2026-48710-badhost/manifest.js +127 -39
  28. package/backend/detectors/cve-2026-48710-badhost/transitive.js +87 -28
  29. package/backend/detectors/hf-impersonation/index.js +94 -31
  30. package/backend/detectors/hf-impersonation/jaro-winkler.js +33 -12
  31. package/backend/detectors/hf-impersonation/known-orgs.js +15 -3
  32. package/backend/detectors/hf-impersonation/simhash.js +2 -2
  33. package/backend/detectors/index.js +181 -34
  34. package/backend/detectors/lib/ast-patterns.js +4 -1
  35. package/backend/detectors/lib/entropy-analyzer.js +12 -4
  36. package/backend/detectors/megalodon/d1-workflow-scan.js +40 -16
  37. package/backend/detectors/megalodon/d2-credential-harvest.js +12 -5
  38. package/backend/detectors/megalodon/d3-publish-velocity.js +17 -11
  39. package/backend/detectors/megalodon/d4-publisher-drift.js +48 -16
  40. package/backend/detectors/megalodon/d5-bot-commit-identity.js +1 -1
  41. package/backend/detectors/megalodon/d6-date-anachronism.js +1 -1
  42. package/backend/detectors/megalodon/index.js +35 -25
  43. package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +3 -1
  44. package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +22 -10
  45. package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +30 -10
  46. package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +17 -13
  47. package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +12 -4
  48. package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +6 -2
  49. package/backend/detectors/mini-shai-hulud/index.js +63 -26
  50. package/backend/detectors/msh-supplement/d2-persistence.js +30 -12
  51. package/backend/detectors/msh-supplement/d3-geo-killswitch.js +20 -8
  52. package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +19 -5
  53. package/backend/detectors/msh-supplement/index.js +78 -63
  54. package/backend/detectors/node-ipc-compromise/d1-version-blocklist.js +4 -2
  55. package/backend/detectors/node-ipc-compromise/d10-unauthorized-publisher.js +9 -5
  56. package/backend/detectors/node-ipc-compromise/d11-blast-radius.js +7 -3
  57. package/backend/detectors/node-ipc-compromise/d2-tarball-hash.js +9 -4
  58. package/backend/detectors/node-ipc-compromise/d3-cjs-payload-injection.js +7 -5
  59. package/backend/detectors/node-ipc-compromise/d4-injected-payload-hash.js +4 -2
  60. package/backend/detectors/node-ipc-compromise/d5-dns-c2-pattern.js +13 -10
  61. package/backend/detectors/node-ipc-compromise/d7-dns-txt-exfil.js +3 -1
  62. package/backend/detectors/node-ipc-compromise/d8-runtime-trigger.js +5 -2
  63. package/backend/detectors/node-ipc-compromise/index.js +21 -15
  64. package/backend/detectors/tier1-binary-embed.js +109 -41
  65. package/backend/detectors/tier1-cloud-imds.js +57 -37
  66. package/backend/detectors/tier1-encrypted-c2.js +198 -0
  67. package/backend/detectors/tier1-infostealer.js +121 -68
  68. package/backend/detectors/tier1-lifecycle-hook.js +63 -23
  69. package/backend/detectors/tier1-maintainer-compromise.js +157 -0
  70. package/backend/detectors/tier1-metadata-spoof.js +92 -42
  71. package/backend/detectors/tier1-multistage-postinstall.js +46 -19
  72. package/backend/detectors/tier1-obfuscation-heuristics.js +45 -17
  73. package/backend/detectors/tier1-self-propagation.js +115 -0
  74. package/backend/detectors/tier1-slsa-attestation.js +1 -1
  75. package/backend/detectors/tier1-transitive-deps.js +182 -0
  76. package/backend/detectors/tier1-typosquat.js +129 -50
  77. package/backend/detectors/tier1-version-anomaly.js +77 -41
  78. package/backend/detectors/tier1-version-confusion.js +79 -59
  79. package/backend/detectors/trapdoor/d1-campaign-marker.js +3 -1
  80. package/backend/detectors/trapdoor/d2-payload-fingerprint.js +1 -1
  81. package/backend/detectors/trapdoor/d3-publisher-blocklist.js +4 -3
  82. package/backend/detectors/trapdoor/d4-gists-exfil.js +4 -2
  83. package/backend/detectors/trapdoor/d5-ai-poisoning.js +5 -3
  84. package/backend/detectors/trapdoor/d6-lure-name.js +12 -7
  85. package/backend/detectors/trapdoor/d7-crypto-primitives.js +2 -2
  86. package/backend/detectors/trapdoor/d8-xor-key.js +7 -2
  87. package/backend/detectors/trapdoor/d9-cred-validation.js +4 -5
  88. package/backend/detectors/trapdoor/index.js +19 -14
  89. package/backend/detectors/typosquat-vpmdhaj/d1-maintainer.js +32 -8
  90. package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +5 -3
  91. package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +34 -12
  92. package/backend/detectors/typosquat-vpmdhaj/index.js +78 -59
  93. package/backend/detectors.test.js +78 -19
  94. package/backend/fetch.js +37 -29
  95. package/backend/index.js +1 -1
  96. package/backend/license.js +20 -4
  97. package/backend/lockfile.js +60 -36
  98. package/backend/pdf.js +107 -28
  99. package/backend/policy.js +183 -56
  100. package/backend/provenance.js +28 -3
  101. package/backend/report.js +136 -70
  102. package/backend/sbom.js +33 -27
  103. package/backend/scripts/analyze-false-positives.js +14 -8
  104. package/backend/scripts/analyze-validation.js +27 -21
  105. package/backend/scripts/detect-false-positives.js +20 -10
  106. package/backend/scripts/fetch-top-packages.js +197 -49
  107. package/backend/scripts/validate-d10-d13.js +103 -0
  108. package/backend/scripts/validate-detectors.js +26 -17
  109. package/backend/siem/cef.js +23 -21
  110. package/backend/siem/ecs.js +3 -3
  111. package/backend/siem/index.js +1 -1
  112. package/backend/siem/qradar.js +3 -3
  113. package/backend/siem/sentinel.js +2 -2
  114. package/backend/tests-d5-enhanced.test.js +13 -12
  115. package/backend/tests-d6-version-anomaly.test.js +17 -8
  116. package/backend/tests-d6.test.js +24 -14
  117. package/backend/tests-d6c.test.js +27 -14
  118. package/backend/tests-d7-obfuscation.test.js +9 -12
  119. package/backend/tests.test.js +182 -83
  120. package/backend/vsix-scan/detectors/activation-event-risk.js +36 -19
  121. package/backend/vsix-scan/detectors/burst-publish.js +14 -7
  122. package/backend/vsix-scan/detectors/exfil-pattern.js +7 -3
  123. package/backend/vsix-scan/detectors/known-ioc.js +23 -8
  124. package/backend/vsix-scan/detectors/orphan-commit-fetch.js +11 -7
  125. package/backend/vsix-scan/detectors/publisher-anomaly.js +24 -10
  126. package/backend/vsix-scan/index.js +97 -41
  127. package/backend/vsix-scan/marketplace-client.js +29 -13
  128. package/cli/cli.js +154 -64
  129. package/package.json +12 -3
@@ -7,47 +7,58 @@ const RULE_SEVERITY = { D1: 'critical', D2: 'critical', D3: 'critical' };
7
7
  const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info', 'none'];
8
8
 
9
9
  function highestSeverity(severities) {
10
- for (const s of SEVERITY_ORDER) if (severities.includes(s)) return s;
10
+ for (const s of SEVERITY_ORDER) {
11
+ if (severities.includes(s)) {
12
+ return s;
13
+ }
14
+ }
11
15
  return 'none';
12
16
  }
13
17
 
14
- export async function scan(pkgJson, files = [], registryMeta = null, allFiles = null) {
18
+ export async function scan(pkgJson, files = [], _registryMeta = null, allFiles = null) {
15
19
  const pkgName = pkgJson?.name || 'unknown';
16
20
  const pkgVersion = pkgJson?.version || '0.0.0';
17
21
  const fileList = allFiles || files || [];
18
22
 
19
23
  const d1Result = scanVersionBlocklist(pkgJson);
20
24
  if (d1Result.stopCondition) {
21
- const evidence = attachProvenance({
22
- rule: 'AXS-VER-001',
23
- campaign: 'AXIOS_POISONING',
24
- triggeredChecks: ['D1'],
25
- matchedVersion: d1Result.matchedVersion,
26
- action: 'BLOCK_IMMEDIATELY',
27
- remediation: `Upgrade to axios@1.14.2 or later, or use pinned safe version`,
28
- }, {
29
- ruleId: 'AXS-VER-001',
30
- ruleName: 'Compromised Axios Version Fingerprinting',
31
- severity: 'CRITICAL',
32
- campaignName: 'Axios Registry Poisoning',
33
- pkgName,
34
- pkgVersion,
35
- triggered: true,
36
- severity: 'critical',
37
- indicators: [{ type: 'known_malicious_version', value: `${pkgName}@${pkgVersion}` }],
38
- ruleProvenanceUrl: 'https://github.com/lateos/npm-scan/blob/main/backend/detectors/axios-poisoning/d1-version-fingerprint.js',
39
- campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
40
- });
25
+ const evidence = attachProvenance(
26
+ {
27
+ rule: 'AXS-VER-001',
28
+ campaign: 'AXIOS_POISONING',
29
+ triggeredChecks: ['D1'],
30
+ matchedVersion: d1Result.matchedVersion,
31
+ action: 'BLOCK_IMMEDIATELY',
32
+ remediation: `Upgrade to axios@1.14.2 or later, or use pinned safe version`,
33
+ },
34
+ {
35
+ ruleId: 'AXS-VER-001',
36
+ ruleName: 'Compromised Axios Version Fingerprinting',
37
+ campaignName: 'Axios Registry Poisoning',
38
+ pkgName,
39
+ pkgVersion,
40
+ triggered: true,
41
+ severity: 'critical',
42
+ indicators: [{ type: 'known_malicious_version', value: `${pkgName}@${pkgVersion}` }],
43
+ ruleProvenanceUrl:
44
+ 'https://github.com/lateos/npm-scan/blob/main/backend/detectors/axios-poisoning/d1-version-fingerprint.js',
45
+ campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
46
+ }
47
+ );
41
48
 
42
- return [{
43
- id: 'AXIOS_POISONING',
44
- severity: 'critical',
45
- title: 'Axios Registry Poisoning campaign',
46
- description: `HALT: ${pkgName}@${pkgVersion} is a known compromised version in the Axios registry poisoning campaign. Block install immediately.`,
47
- evidence: JSON.stringify(evidence),
48
- mitigation: d1Result.reason ? `BLOCK IMMEDIATELY. ${d1Result.reason}. Upgrade to axios@1.14.2 or later, or use pinned safe version.` : 'BLOCK IMMEDIATELY. Upgrade to a safe version.',
49
- stopCondition: true,
50
- }];
49
+ return [
50
+ {
51
+ id: 'AXIOS_POISONING',
52
+ severity: 'critical',
53
+ title: 'Axios Registry Poisoning campaign',
54
+ description: `HALT: ${pkgName}@${pkgVersion} is a known compromised version in the Axios registry poisoning campaign. Block install immediately.`,
55
+ evidence: JSON.stringify(evidence),
56
+ mitigation: d1Result.reason
57
+ ? `BLOCK IMMEDIATELY. ${d1Result.reason}. Upgrade to axios@1.14.2 or later, or use pinned safe version.`
58
+ : 'BLOCK IMMEDIATELY. Upgrade to a safe version.',
59
+ stopCondition: true,
60
+ },
61
+ ];
51
62
  }
52
63
 
53
64
  const d2Result = scanDecoyDependency(pkgJson);
@@ -59,36 +70,42 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
59
70
  .filter(([_, r]) => r.triggered)
60
71
  .map(([id]) => id);
61
72
 
62
- if (triggered.length === 0) return [];
73
+ if (triggered.length === 0) {
74
+ return [];
75
+ }
63
76
 
64
- const severity = highestSeverity(triggered.map(id => RULE_SEVERITY[id]));
77
+ const severity = highestSeverity(triggered.map((id) => RULE_SEVERITY[id]));
65
78
 
66
- const evidence = attachProvenance({
67
- campaign: 'AXIOS_POISONING',
68
- triggeredChecks: triggered,
69
- details: Object.fromEntries(
70
- Object.entries(results).filter(([_, r]) => r.triggered)
71
- ),
72
- }, {
73
- ruleId: 'AXIOS_POISONING',
74
- ruleName: 'Axios Registry Poisoning Detection',
75
- severity: severity.toUpperCase(),
76
- campaignName: 'Axios Registry Poisoning',
77
- pkgName,
78
- pkgVersion,
79
- triggered: true,
80
- severity,
81
- indicators: triggered.map(id => ({ type: `rule_${id}`, value: RULE_SEVERITY[id] })),
82
- ruleProvenanceUrl: 'https://github.com/lateos/npm-scan/blob/main/backend/detectors/axios-poisoning/',
83
- campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
84
- });
79
+ const evidence = attachProvenance(
80
+ {
81
+ campaign: 'AXIOS_POISONING',
82
+ triggeredChecks: triggered,
83
+ details: Object.fromEntries(Object.entries(results).filter(([_, r]) => r.triggered)),
84
+ },
85
+ {
86
+ ruleId: 'AXIOS_POISONING',
87
+ ruleName: 'Axios Registry Poisoning Detection',
88
+ campaignName: 'Axios Registry Poisoning',
89
+ pkgName,
90
+ pkgVersion,
91
+ triggered: true,
92
+ severity,
93
+ indicators: triggered.map((id) => ({ type: `rule_${id}`, value: RULE_SEVERITY[id] })),
94
+ ruleProvenanceUrl:
95
+ 'https://github.com/lateos/npm-scan/blob/main/backend/detectors/axios-poisoning/',
96
+ campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
97
+ }
98
+ );
85
99
 
86
- return [{
87
- id: 'AXIOS_POISONING',
88
- severity,
89
- title: 'Axios Registry Poisoning campaign',
90
- description: `${triggered.length} signal(s): ${triggered.join(', ')}`,
91
- evidence: JSON.stringify(evidence),
92
- mitigation: 'If decoy dependency detected: verify all axios dependencies are legitimate. If RAT payload detected: run full malware scan on the system, rotate all credentials, check for unauthorized network connections. Upgrade to axios@1.14.2+ or pin to a known safe version.',
93
- }];
100
+ return [
101
+ {
102
+ id: 'AXIOS_POISONING',
103
+ severity,
104
+ title: 'Axios Registry Poisoning campaign',
105
+ description: `${triggered.length} signal(s): ${triggered.join(', ')}`,
106
+ evidence: JSON.stringify(evidence),
107
+ mitigation:
108
+ 'If decoy dependency detected: verify all axios dependencies are legitimate. If RAT payload detected: run full malware scan on the system, rotate all credentials, check for unauthorized network connections. Upgrade to axios@1.14.2+ or pin to a known safe version.',
109
+ },
110
+ ];
94
111
  }
@@ -11,7 +11,8 @@ export default {
11
11
  'TIER1-VERSION-ANOMALY': {
12
12
  flag_threshold: 72,
13
13
  warn_threshold: 60,
14
- notes: 'Sentinel patterns (99.99.99/11.11.11/10.10.10) always flag at 92 regardless of threshold',
14
+ notes:
15
+ 'Sentinel patterns (99.99.99/11.11.11/10.10.10) always flag at 92 regardless of threshold',
15
16
  },
16
17
  'TIER1-OBFUSCATION-HEURISTICS': {
17
18
  flag_threshold: 75,
@@ -21,7 +22,8 @@ export default {
21
22
  'TIER1-BINARY-EMBED': {
22
23
  flag_threshold: 80,
23
24
  warn_threshold: 65,
24
- notes: 'High threshold justified; platform-specific binary sets are rare in legitimate packages',
25
+ notes:
26
+ 'High threshold justified; platform-specific binary sets are rare in legitimate packages',
25
27
  },
26
28
  'TIER1-LIFECYCLE-HOOK': {
27
29
  flag_threshold: 65,
@@ -36,7 +38,8 @@ export default {
36
38
  'TIER1-TYPOSQUAT': {
37
39
  flag_threshold: 85,
38
40
  warn_threshold: 70,
39
- notes: 'Calibrated to 85 post-FP analysis on top 1,000 packages; 46 edit-distance=1 FPs eliminated at this threshold',
41
+ notes:
42
+ 'Calibrated to 85 post-FP analysis on top 1,000 packages; 46 edit-distance=1 FPs eliminated at this threshold',
40
43
  },
41
44
  'TIER1-METADATA-SPOOF': {
42
45
  flag_threshold: 70,
@@ -63,4 +66,46 @@ export default {
63
66
  warn_threshold: 70,
64
67
  notes: 'Placeholder; threshold TBD when API stabilizes',
65
68
  },
69
+ 'TIER1-SELF-PROPAGATION': {
70
+ flag_threshold: 75,
71
+ warn_threshold: 60,
72
+ burst_window_minutes: 60,
73
+ min_packages_burst: 3,
74
+ identical_payload_weight: 40,
75
+ notes: 'D10: Detects burst republish patterns (Miasma campaign)',
76
+ },
77
+ 'TIER1-ENCRYPTED-C2': {
78
+ flag_threshold: 70,
79
+ warn_threshold: 50,
80
+ known_c2_endpoints: [
81
+ 'filev2.getsession.org',
82
+ 'api.signal.org',
83
+ '*.briarproject.org',
84
+ 'api.ricochet.im',
85
+ ],
86
+ onion_pattern_weight: 30,
87
+ encoded_url_weight: 35,
88
+ env_var_c2_weight: 40,
89
+ notes: 'D11: Detects Session/Oxen, Signal, Briar, Tor C2 channels',
90
+ },
91
+ 'TIER1-TRANSITIVE-DEPS': {
92
+ flag_threshold: 80,
93
+ warn_threshold: 50,
94
+ new_package_days: 7,
95
+ unknown_depth_weight: 45,
96
+ typosquat_depth_weight: 50,
97
+ different_maintainer_weight: 35,
98
+ notes: 'D12: Deep dependency tree analysis for injection attacks',
99
+ },
100
+ 'TIER1-MAINTAINER-COMPROMISE': {
101
+ flag_threshold: 75,
102
+ warn_threshold: 60,
103
+ velocity_burst_multiplier: 5,
104
+ burst_window_hours: 24,
105
+ min_velocity_baseline: 0.5,
106
+ duplicate_version_weight: 40,
107
+ unusual_timing_weight: 25,
108
+ cross_package_burst_weight: 50,
109
+ notes: 'D13: Version velocity anomaly and maintainer compromise detection',
110
+ },
66
111
  };
@@ -11,11 +11,12 @@ const AUTH_CONTEXT_PATHS = [
11
11
  ];
12
12
 
13
13
  const URL_PATH_PATTERN = /request\.url\.path|req\.url\.path|self\.request\.url\.path/g;
14
- const SCOPE_PATH_PATTERN = /request\.scope\s*\[\s*["']path["']\s*\]|request\.scope\.get\s*\(\s*["']path["']\s*\)/g;
14
+ const SCOPE_PATH_PATTERN =
15
+ /request\.scope\s*\[\s*["']path["']\s*\]|request\.scope\.get\s*\(\s*["']path["']\s*\)/g;
15
16
 
16
17
  function hasAuthContext(filePath) {
17
18
  const lower = filePath.toLowerCase();
18
- return AUTH_CONTEXT_PATHS.some(ctx => lower.includes(ctx));
19
+ return AUTH_CONTEXT_PATHS.some((ctx) => lower.includes(ctx));
19
20
  }
20
21
 
21
22
  function findFunctionBoundaries(lines) {
@@ -34,7 +35,13 @@ function findFunctionBoundaries(lines) {
34
35
  currentFn = defMatch[1];
35
36
  fnBodyStart = i;
36
37
  indent = line.length - line.trimStart().length;
37
- } else if (currentFn && line.trim() && line.length - line.trimStart().length <= indent && !line.trim().startsWith('#') && !line.trim().startsWith('@')) {
38
+ } else if (
39
+ currentFn &&
40
+ line.trim() &&
41
+ line.length - line.trimStart().length <= indent &&
42
+ !line.trim().startsWith('#') &&
43
+ !line.trim().startsWith('@')
44
+ ) {
38
45
  functions.push({ name: currentFn, startLine: fnBodyStart, endLine: i - 1 });
39
46
  currentFn = null;
40
47
  }
@@ -48,7 +55,9 @@ function findFunctionBoundaries(lines) {
48
55
 
49
56
  function hasScopePathInFunction(lines, fnStart, fnEnd) {
50
57
  for (let i = fnStart; i <= fnEnd && i < lines.length; i++) {
51
- if (SCOPE_PATH_PATTERN.test(lines[i])) return true;
58
+ if (SCOPE_PATH_PATTERN.test(lines[i])) {
59
+ return true;
60
+ }
52
61
  }
53
62
  return false;
54
63
  }
@@ -56,11 +65,15 @@ function hasScopePathInFunction(lines, fnStart, fnEnd) {
56
65
  export function scanCodePatterns(allFiles) {
57
66
  const findings = [];
58
67
 
59
- for (const file of (allFiles || [])) {
68
+ for (const file of allFiles || []) {
60
69
  const content = typeof file.content === 'string' ? file.content : '';
61
- if (!content) continue;
70
+ if (!content) {
71
+ continue;
72
+ }
62
73
  const path = file.path || '';
63
- if (!path.endsWith('.py')) continue;
74
+ if (!path.endsWith('.py')) {
75
+ continue;
76
+ }
64
77
 
65
78
  const lines = content.split('\n');
66
79
  const isAuthContext = hasAuthContext(path);
@@ -82,14 +95,18 @@ export function scanCodePatterns(allFiles) {
82
95
  let m;
83
96
  while ((m = URL_PATH_PATTERN.exec(content)) !== null) {
84
97
  const lineNumber = content.slice(0, m.index).split('\n').length;
85
- if (suppressedLines.has(lineNumber)) continue;
98
+ if (suppressedLines.has(lineNumber)) {
99
+ continue;
100
+ }
86
101
  findings.push(codePatternAuthFinding(path, lineNumber));
87
102
  }
88
103
  } else {
89
104
  let m;
90
105
  while ((m = URL_PATH_PATTERN.exec(content)) !== null) {
91
106
  const lineNumber = content.slice(0, m.index).split('\n').length;
92
- if (suppressedLines.has(lineNumber)) continue;
107
+ if (suppressedLines.has(lineNumber)) {
108
+ continue;
109
+ }
93
110
  findings.push(codePatternInfoFinding(path, lineNumber));
94
111
  }
95
112
  }
@@ -8,11 +8,14 @@ const REFERENCES = [
8
8
  'https://osv.dev/vulnerability/PYSEC-2026-161',
9
9
  ];
10
10
 
11
- const MITIGATION_NOTE = 'Partial mitigation: Cloudflare and AWS ALB reject malformed Host headers for properly proxied deployments. Direct uvicorn/hypercorn/daphne/granian exposure with no reverse proxy in front is highest risk.';
11
+ const MITIGATION_NOTE =
12
+ 'Partial mitigation: Cloudflare and AWS ALB reject malformed Host headers for properly proxied deployments. Direct uvicorn/hypercorn/daphne/granian exposure with no reverse proxy in front is highest risk.';
12
13
 
13
- const DEPENDENCY_REMEDIATION = 'Upgrade starlette to >= 1.0.1. If starlette is inherited transitively through fastapi, vllm, litellm, or an MCP server package, upgrade the top-level package to a version that pins starlette >= 1.0.1. Verify with: pip show starlette.';
14
+ const DEPENDENCY_REMEDIATION =
15
+ 'Upgrade starlette to >= 1.0.1. If starlette is inherited transitively through fastapi, vllm, litellm, or an MCP server package, upgrade the top-level package to a version that pins starlette >= 1.0.1. Verify with: pip show starlette.';
14
16
 
15
- const CODE_REMEDIATION = 'Replace request.url.path with request.scope["path"] for all security-sensitive decisions (auth checks, path allowlists, rate limiting gates). The reconstructed request.url is influenced by the unvalidated Host header in Starlette < 1.0.1.';
17
+ const CODE_REMEDIATION =
18
+ 'Replace request.url.path with request.scope["path"] for all security-sensitive decisions (auth checks, path allowlists, rate limiting gates). The reconstructed request.url is influenced by the unvalidated Host header in Starlette < 1.0.1.';
16
19
 
17
20
  function makeFinding(overrides = {}) {
18
21
  return {
@@ -52,7 +55,8 @@ export function directDependencyUnpinnedFinding() {
52
55
  confidence: 'HIGH',
53
56
  source: 'direct-dependency-unpinned',
54
57
  title: `${NICKNAME}: Starlette unpinned`,
55
- description: 'Starlette is declared with no version constraint — assume vulnerable to CVE-2026-48710 (BadHost) until pinned to >= 1.0.1.',
58
+ description:
59
+ 'Starlette is declared with no version constraint — assume vulnerable to CVE-2026-48710 (BadHost) until pinned to >= 1.0.1.',
56
60
  remediation: `${DEPENDENCY_REMEDIATION} ${MITIGATION_NOTE}`,
57
61
  file: null,
58
62
  line: null,
@@ -2,7 +2,7 @@ import { scanFiles } from './manifest.js';
2
2
  import { scanTransitive } from './transitive.js';
3
3
  import { scanCodePatterns } from './codePattern.js';
4
4
 
5
- export async function scan(pkgJson, files = [], registryMeta = null, allFiles = null) {
5
+ export async function scan(pkgJson, files = [], _registryMeta = null, allFiles = null) {
6
6
  const targetFiles = allFiles || files;
7
7
 
8
8
  const manifestFindings = scanFiles(targetFiles);
@@ -2,10 +2,14 @@ import { directDependencyFinding, directDependencyUnpinnedFinding } from './find
2
2
 
3
3
  function parseReqTxtLine(line) {
4
4
  const trimmed = line.trim();
5
- if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) return null;
5
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) {
6
+ return null;
7
+ }
6
8
  const idx = trimmed.indexOf('#');
7
9
  const spec = idx >= 0 ? trimmed.slice(0, idx).trim() : trimmed;
8
- if (!spec || !spec.startsWith('starlette')) return null;
10
+ if (!spec || !spec.startsWith('starlette')) {
11
+ return null;
12
+ }
9
13
 
10
14
  const eqIdx = spec.indexOf('==');
11
15
  const geIdx = spec.indexOf('>=');
@@ -22,7 +26,9 @@ function parseReqTxtLine(line) {
22
26
  const lower = parts[0]?.trim();
23
27
  const upper = parts[1]?.trim();
24
28
  let specStr = `>=${lower}`;
25
- if (upper && upper.startsWith('<')) specStr += `,${upper}`;
29
+ if (upper && upper.startsWith('<')) {
30
+ specStr += `,${upper}`;
31
+ }
26
32
  return { name: 'starlette', version: lower, specifier: specStr };
27
33
  }
28
34
  if (tildeIdx >= 0) {
@@ -36,9 +42,17 @@ function parseReqTxtLine(line) {
36
42
  }
37
43
 
38
44
  const rest = spec.slice('starlette'.length).trim();
39
- if (!rest) return { name: 'starlette', version: null, specifier: null };
45
+ if (!rest) {
46
+ return { name: 'starlette', version: null, specifier: null };
47
+ }
40
48
 
41
- if (!rest.includes('=') && !rest.includes('<') && !rest.includes('>') && !rest.includes('~') && !rest.includes('!')) {
49
+ if (
50
+ !rest.includes('=') &&
51
+ !rest.includes('<') &&
52
+ !rest.includes('>') &&
53
+ !rest.includes('~') &&
54
+ !rest.includes('!')
55
+ ) {
42
56
  return { name: 'starlette', version: rest, specifier: rest };
43
57
  }
44
58
 
@@ -49,13 +63,17 @@ export function parseRequirementsTxt(content) {
49
63
  const lines = content.split('\n');
50
64
  for (const line of lines) {
51
65
  const result = parseReqTxtLine(line);
52
- if (result) return result;
66
+ if (result) {
67
+ return result;
68
+ }
53
69
  }
54
70
  return null;
55
71
  }
56
72
 
57
73
  function parsePEP440(versionStr) {
58
- if (!versionStr) return null;
74
+ if (!versionStr) {
75
+ return null;
76
+ }
59
77
  const clean = versionStr.trim().replace(/^v/, '');
60
78
  const parts = clean.split('.');
61
79
  return {
@@ -66,24 +84,38 @@ function parsePEP440(versionStr) {
66
84
  }
67
85
 
68
86
  function compareVersions(a, b) {
69
- if (!a) return 1;
70
- if (!b) return -1;
71
- if (a.major !== b.major) return a.major - b.major;
72
- if (a.minor !== b.minor) return a.minor - b.minor;
87
+ if (!a) {
88
+ return 1;
89
+ }
90
+ if (!b) {
91
+ return -1;
92
+ }
93
+ if (a.major !== b.major) {
94
+ return a.major - b.major;
95
+ }
96
+ if (a.minor !== b.minor) {
97
+ return a.minor - b.minor;
98
+ }
73
99
  return a.patch - b.patch;
74
100
  }
75
101
 
76
102
  const STARLETTE_SAFE = parsePEP440('1.0.1');
77
103
 
78
104
  function isVulnerable(version) {
79
- if (!version) return true;
105
+ if (!version) {
106
+ return true;
107
+ }
80
108
  const parsed = parsePEP440(version);
81
- if (!parsed) return true;
109
+ if (!parsed) {
110
+ return true;
111
+ }
82
112
  return compareVersions(parsed, STARLETTE_SAFE) < 0;
83
113
  }
84
114
 
85
115
  function findStarletteInTOML(obj) {
86
- if (!obj || typeof obj !== 'object') return null;
116
+ if (!obj || typeof obj !== 'object') {
117
+ return null;
118
+ }
87
119
 
88
120
  const sectionPaths = ['dependencies', 'project.dependencies', 'tool.poetry.dependencies'];
89
121
  for (const path of sectionPaths) {
@@ -91,13 +123,18 @@ function findStarletteInTOML(obj) {
91
123
  let ptr = obj;
92
124
  let found = true;
93
125
  for (const p of parts) {
94
- if (!ptr || typeof ptr !== 'object') { found = false; break; }
126
+ if (!ptr || typeof ptr !== 'object') {
127
+ found = false;
128
+ break;
129
+ }
95
130
  ptr = ptr[p];
96
131
  }
97
- if (!found || !ptr || typeof ptr !== 'object') continue;
132
+ if (!found || !ptr || typeof ptr !== 'object') {
133
+ continue;
134
+ }
98
135
  for (const [key, val] of Object.entries(ptr)) {
99
136
  if (key === 'starlette' || key === '"starlette"') {
100
- const version = typeof val === 'string' ? val : (val?.version || null);
137
+ const version = typeof val === 'string' ? val : val?.version || null;
101
138
  const specifier = typeof val === 'string' ? val : null;
102
139
  return { name: 'starlette', version, specifier };
103
140
  }
@@ -112,28 +149,41 @@ function parseTomlSimple(content) {
112
149
 
113
150
  for (const line of content.split('\n')) {
114
151
  const trimmed = line.trim();
115
- if (!trimmed || trimmed.startsWith('#')) continue;
152
+ if (!trimmed || trimmed.startsWith('#')) {
153
+ continue;
154
+ }
116
155
  const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
117
156
  if (sectionMatch) {
118
157
  const parts = sectionMatch[1].split('.');
119
158
  let ptr = result;
120
159
  for (const p of parts) {
121
160
  const key = p.replace(/^"(.*)"$/, '$1').trim();
122
- if (!ptr[key]) ptr[key] = {};
161
+ if (!ptr[key]) {
162
+ ptr[key] = {};
163
+ }
123
164
  ptr = ptr[key];
124
165
  }
125
166
  currentSection = ptr;
126
167
  continue;
127
168
  }
128
169
  const kvMatch = trimmed.match(/^([^=]+)=\s*(.+)$/);
129
- if (!kvMatch) continue;
170
+ if (!kvMatch) {
171
+ continue;
172
+ }
130
173
  const key = kvMatch[1].trim().replace(/^"(.*)"$/, '$1');
131
174
  let val = kvMatch[2].trim();
132
175
  if (val.startsWith('"') && val.endsWith('"')) {
133
176
  val = val.slice(1, -1);
134
177
  } else if (val.startsWith("'") && val.endsWith("'")) {
135
178
  val = val.slice(1, -1);
136
- } else if (val.startsWith('^') || val.startsWith('~') || val.startsWith('>') || val.startsWith('<') || val.startsWith('=') || val.startsWith('!')) {
179
+ } else if (
180
+ val.startsWith('^') ||
181
+ val.startsWith('~') ||
182
+ val.startsWith('>') ||
183
+ val.startsWith('<') ||
184
+ val.startsWith('=') ||
185
+ val.startsWith('!')
186
+ ) {
137
187
  val = val.replace(/"/g, '');
138
188
  }
139
189
  currentSection[key] = val;
@@ -166,7 +216,9 @@ function parsePoetryLockEntry(content) {
166
216
  }
167
217
  if (inStarlette && trimmed.startsWith('version = ')) {
168
218
  const match = trimmed.match(/version\s*=\s*["'](.+?)["']/);
169
- if (match) version = match[1];
219
+ if (match) {
220
+ version = match[1];
221
+ }
170
222
  }
171
223
  if (inStarlette && trimmed.startsWith('[[package]]') && trimmed !== '[[package]]') {
172
224
  break;
@@ -204,12 +256,16 @@ export function parsePipfile(content) {
204
256
 
205
257
  function parseSetupPyContent(content) {
206
258
  const match = content.match(/install_requires\s*=\s*\[([^\]]+)\]/s);
207
- if (!match) return null;
259
+ if (!match) {
260
+ return null;
261
+ }
208
262
  const block = match[1];
209
- const lines = block.split(',').map(l => l.trim().replace(/["']/g, ''));
263
+ const lines = block.split(',').map((l) => l.trim().replace(/["']/g, ''));
210
264
  for (const line of lines) {
211
265
  const clean = line.trim();
212
- if (!clean) continue;
266
+ if (!clean) {
267
+ continue;
268
+ }
213
269
  if (clean.startsWith('starlette')) {
214
270
  const eqIdx = clean.indexOf('==');
215
271
  const geIdx = clean.indexOf('>=');
@@ -217,11 +273,24 @@ function parseSetupPyContent(content) {
217
273
  const ltIdx = clean.indexOf('<');
218
274
  let version = null;
219
275
  let specifier = null;
220
- if (eqIdx >= 0) { version = clean.slice(eqIdx + 2).trim(); specifier = `==${version}`; }
221
- else if (geIdx >= 0) { version = clean.slice(geIdx + 2).split(',')[0].trim(); specifier = `>=${version}`; }
222
- else if (tildeIdx >= 0) { version = clean.slice(tildeIdx + 2).trim(); specifier = `~=${version}`; }
223
- else if (ltIdx >= 0) { version = clean.slice(ltIdx + 1).trim(); specifier = `<${version}`; }
224
- else if (clean === 'starlette') { return { name: 'starlette', version: null, specifier: null }; }
276
+ if (eqIdx >= 0) {
277
+ version = clean.slice(eqIdx + 2).trim();
278
+ specifier = `==${version}`;
279
+ } else if (geIdx >= 0) {
280
+ version = clean
281
+ .slice(geIdx + 2)
282
+ .split(',')[0]
283
+ .trim();
284
+ specifier = `>=${version}`;
285
+ } else if (tildeIdx >= 0) {
286
+ version = clean.slice(tildeIdx + 2).trim();
287
+ specifier = `~=${version}`;
288
+ } else if (ltIdx >= 0) {
289
+ version = clean.slice(ltIdx + 1).trim();
290
+ specifier = `<${version}`;
291
+ } else if (clean === 'starlette') {
292
+ return { name: 'starlette', version: null, specifier: null };
293
+ }
225
294
  return { name: 'starlette', version, specifier };
226
295
  }
227
296
  }
@@ -242,7 +311,9 @@ function parseSetupCfgContent(content) {
242
311
  continue;
243
312
  }
244
313
  if (inInstallRequires) {
245
- if (trimmed.startsWith('[')) break;
314
+ if (trimmed.startsWith('[')) {
315
+ break;
316
+ }
246
317
  if (trimmed.startsWith('starlette')) {
247
318
  const eqIdx = trimmed.indexOf('==');
248
319
  const geIdx = trimmed.indexOf('>=');
@@ -250,11 +321,24 @@ function parseSetupCfgContent(content) {
250
321
  const ltIdx = trimmed.indexOf('<');
251
322
  let version = null;
252
323
  let specifier = null;
253
- if (eqIdx >= 0) { version = trimmed.slice(eqIdx + 2).trim(); specifier = `==${version}`; }
254
- else if (geIdx >= 0) { version = trimmed.slice(geIdx + 2).split(',')[0].trim(); specifier = `>=${version}`; }
255
- else if (tildeIdx >= 0) { version = trimmed.slice(tildeIdx + 2).trim(); specifier = `~=${version}`; }
256
- else if (ltIdx >= 0) { version = trimmed.slice(ltIdx + 1).trim(); specifier = `<${version}`; }
257
- else if (trimmed === 'starlette') { return { name: 'starlette', version: null, specifier: null }; }
324
+ if (eqIdx >= 0) {
325
+ version = trimmed.slice(eqIdx + 2).trim();
326
+ specifier = `==${version}`;
327
+ } else if (geIdx >= 0) {
328
+ version = trimmed
329
+ .slice(geIdx + 2)
330
+ .split(',')[0]
331
+ .trim();
332
+ specifier = `>=${version}`;
333
+ } else if (tildeIdx >= 0) {
334
+ version = trimmed.slice(tildeIdx + 2).trim();
335
+ specifier = `~=${version}`;
336
+ } else if (ltIdx >= 0) {
337
+ version = trimmed.slice(ltIdx + 1).trim();
338
+ specifier = `<${version}`;
339
+ } else if (trimmed === 'starlette') {
340
+ return { name: 'starlette', version: null, specifier: null };
341
+ }
258
342
  if (trimmed.startsWith('starlette')) {
259
343
  return { name: 'starlette', version, specifier };
260
344
  }
@@ -271,9 +355,11 @@ export function parseSetupCfg(content) {
271
355
  export function scanFiles(allFiles) {
272
356
  const findings = [];
273
357
 
274
- for (const file of (allFiles || [])) {
358
+ for (const file of allFiles || []) {
275
359
  const content = typeof file.content === 'string' ? file.content : '';
276
- if (!content) continue;
360
+ if (!content) {
361
+ continue;
362
+ }
277
363
  const path = file.path || '';
278
364
 
279
365
  let result = null;
@@ -292,7 +378,9 @@ export function scanFiles(allFiles) {
292
378
  result = parseSetupCfg(content);
293
379
  }
294
380
 
295
- if (!result) continue;
381
+ if (!result) {
382
+ continue;
383
+ }
296
384
 
297
385
  if (result.version === null && result.specifier === null) {
298
386
  findings.push(directDependencyUnpinnedFinding());