@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.
- package/README.de.md +3 -98
- package/README.fr.md +3 -98
- package/README.ja.md +3 -98
- package/README.md +2 -122
- package/README.zh.md +3 -98
- package/backend/cra.js +113 -21
- package/backend/db.js +18 -10
- package/backend/detectors/atk-001-lifecycle.js +5 -5
- package/backend/detectors/atk-002-obfusc.js +126 -47
- package/backend/detectors/atk-003-creds.js +8 -4
- package/backend/detectors/atk-004-persist.js +3 -3
- package/backend/detectors/atk-005-exfil.js +8 -4
- package/backend/detectors/atk-006-depconf.js +3 -3
- package/backend/detectors/atk-007-typosquat.js +64 -10
- package/backend/detectors/atk-008-tarball-tamper.js +6 -6
- package/backend/detectors/atk-009-dormant-trigger.js +9 -5
- package/backend/detectors/atk-010-sandbox-evasion.js +25 -10
- package/backend/detectors/atk-011-transitive-prop.js +14 -13
- package/backend/detectors/axios-poisoning/d1-version-fingerprint.js +4 -4
- package/backend/detectors/axios-poisoning/d2-decoy-dep.js +5 -1
- package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +64 -19
- package/backend/detectors/axios-poisoning/index.js +77 -60
- package/backend/detectors/config/thresholds.js +48 -3
- package/backend/detectors/cve-2026-48710-badhost/codePattern.js +26 -9
- package/backend/detectors/cve-2026-48710-badhost/findings.js +8 -4
- package/backend/detectors/cve-2026-48710-badhost/index.js +1 -1
- package/backend/detectors/cve-2026-48710-badhost/manifest.js +127 -39
- package/backend/detectors/cve-2026-48710-badhost/transitive.js +87 -28
- package/backend/detectors/hf-impersonation/index.js +94 -31
- package/backend/detectors/hf-impersonation/jaro-winkler.js +33 -12
- package/backend/detectors/hf-impersonation/known-orgs.js +15 -3
- package/backend/detectors/hf-impersonation/simhash.js +2 -2
- package/backend/detectors/index.js +181 -34
- package/backend/detectors/lib/ast-patterns.js +4 -1
- package/backend/detectors/lib/entropy-analyzer.js +12 -4
- package/backend/detectors/megalodon/d1-workflow-scan.js +40 -16
- package/backend/detectors/megalodon/d2-credential-harvest.js +12 -5
- package/backend/detectors/megalodon/d3-publish-velocity.js +17 -11
- package/backend/detectors/megalodon/d4-publisher-drift.js +48 -16
- package/backend/detectors/megalodon/d5-bot-commit-identity.js +1 -1
- package/backend/detectors/megalodon/d6-date-anachronism.js +1 -1
- package/backend/detectors/megalodon/index.js +35 -25
- package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +3 -1
- package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +22 -10
- package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +30 -10
- package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +17 -13
- package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +12 -4
- package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +6 -2
- package/backend/detectors/mini-shai-hulud/index.js +63 -26
- package/backend/detectors/msh-supplement/d2-persistence.js +30 -12
- package/backend/detectors/msh-supplement/d3-geo-killswitch.js +20 -8
- package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +19 -5
- package/backend/detectors/msh-supplement/index.js +78 -63
- package/backend/detectors/node-ipc-compromise/d1-version-blocklist.js +4 -2
- package/backend/detectors/node-ipc-compromise/d10-unauthorized-publisher.js +9 -5
- package/backend/detectors/node-ipc-compromise/d11-blast-radius.js +7 -3
- package/backend/detectors/node-ipc-compromise/d2-tarball-hash.js +9 -4
- package/backend/detectors/node-ipc-compromise/d3-cjs-payload-injection.js +7 -5
- package/backend/detectors/node-ipc-compromise/d4-injected-payload-hash.js +4 -2
- package/backend/detectors/node-ipc-compromise/d5-dns-c2-pattern.js +13 -10
- package/backend/detectors/node-ipc-compromise/d7-dns-txt-exfil.js +3 -1
- package/backend/detectors/node-ipc-compromise/d8-runtime-trigger.js +5 -2
- package/backend/detectors/node-ipc-compromise/index.js +21 -15
- package/backend/detectors/tier1-binary-embed.js +109 -41
- package/backend/detectors/tier1-cloud-imds.js +57 -37
- package/backend/detectors/tier1-encrypted-c2.js +198 -0
- package/backend/detectors/tier1-infostealer.js +121 -68
- package/backend/detectors/tier1-lifecycle-hook.js +63 -23
- package/backend/detectors/tier1-maintainer-compromise.js +157 -0
- package/backend/detectors/tier1-metadata-spoof.js +92 -42
- package/backend/detectors/tier1-multistage-postinstall.js +46 -19
- package/backend/detectors/tier1-obfuscation-heuristics.js +45 -17
- package/backend/detectors/tier1-self-propagation.js +115 -0
- package/backend/detectors/tier1-slsa-attestation.js +1 -1
- package/backend/detectors/tier1-transitive-deps.js +182 -0
- package/backend/detectors/tier1-typosquat.js +129 -50
- package/backend/detectors/tier1-version-anomaly.js +77 -41
- package/backend/detectors/tier1-version-confusion.js +79 -59
- package/backend/detectors/trapdoor/d1-campaign-marker.js +3 -1
- package/backend/detectors/trapdoor/d2-payload-fingerprint.js +1 -1
- package/backend/detectors/trapdoor/d3-publisher-blocklist.js +4 -3
- package/backend/detectors/trapdoor/d4-gists-exfil.js +4 -2
- package/backend/detectors/trapdoor/d5-ai-poisoning.js +5 -3
- package/backend/detectors/trapdoor/d6-lure-name.js +12 -7
- package/backend/detectors/trapdoor/d7-crypto-primitives.js +2 -2
- package/backend/detectors/trapdoor/d8-xor-key.js +7 -2
- package/backend/detectors/trapdoor/d9-cred-validation.js +4 -5
- package/backend/detectors/trapdoor/index.js +19 -14
- package/backend/detectors/typosquat-vpmdhaj/d1-maintainer.js +32 -8
- package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +5 -3
- package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +34 -12
- package/backend/detectors/typosquat-vpmdhaj/index.js +78 -59
- package/backend/detectors.test.js +78 -19
- package/backend/fetch.js +37 -29
- package/backend/index.js +1 -1
- package/backend/license.js +20 -4
- package/backend/lockfile.js +60 -36
- package/backend/pdf.js +107 -28
- package/backend/policy.js +183 -56
- package/backend/provenance.js +28 -3
- package/backend/report.js +136 -70
- package/backend/sbom.js +33 -27
- package/backend/scripts/analyze-false-positives.js +14 -8
- package/backend/scripts/analyze-validation.js +27 -21
- package/backend/scripts/detect-false-positives.js +20 -10
- package/backend/scripts/fetch-top-packages.js +197 -49
- package/backend/scripts/validate-d10-d13.js +103 -0
- package/backend/scripts/validate-detectors.js +26 -17
- package/backend/siem/cef.js +23 -21
- package/backend/siem/ecs.js +3 -3
- package/backend/siem/index.js +1 -1
- package/backend/siem/qradar.js +3 -3
- package/backend/siem/sentinel.js +2 -2
- package/backend/tests-d5-enhanced.test.js +13 -12
- package/backend/tests-d6-version-anomaly.test.js +17 -8
- package/backend/tests-d6.test.js +24 -14
- package/backend/tests-d6c.test.js +27 -14
- package/backend/tests-d7-obfuscation.test.js +9 -12
- package/backend/tests.test.js +182 -83
- package/backend/vsix-scan/detectors/activation-event-risk.js +36 -19
- package/backend/vsix-scan/detectors/burst-publish.js +14 -7
- package/backend/vsix-scan/detectors/exfil-pattern.js +7 -3
- package/backend/vsix-scan/detectors/known-ioc.js +23 -8
- package/backend/vsix-scan/detectors/orphan-commit-fetch.js +11 -7
- package/backend/vsix-scan/detectors/publisher-anomaly.js +24 -10
- package/backend/vsix-scan/index.js +97 -41
- package/backend/vsix-scan/marketplace-client.js +29 -13
- package/cli/cli.js +154 -64
- 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)
|
|
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 = [],
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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)
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
Object.entries(results).filter(([_, r]) => r.triggered)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
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 (
|
|
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]))
|
|
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
|
|
68
|
+
for (const file of allFiles || []) {
|
|
60
69
|
const content = typeof file.content === 'string' ? file.content : '';
|
|
61
|
-
if (!content)
|
|
70
|
+
if (!content) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
62
73
|
const path = file.path || '';
|
|
63
|
-
if (!path.endsWith('.py'))
|
|
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))
|
|
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))
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
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 = [],
|
|
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('-'))
|
|
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'))
|
|
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('<'))
|
|
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)
|
|
45
|
+
if (!rest) {
|
|
46
|
+
return { name: 'starlette', version: null, specifier: null };
|
|
47
|
+
}
|
|
40
48
|
|
|
41
|
-
if (
|
|
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)
|
|
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)
|
|
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)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (
|
|
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)
|
|
105
|
+
if (!version) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
80
108
|
const parsed = parsePEP440(version);
|
|
81
|
-
if (!parsed)
|
|
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')
|
|
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') {
|
|
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')
|
|
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 :
|
|
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('#'))
|
|
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])
|
|
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)
|
|
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 (
|
|
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)
|
|
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)
|
|
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)
|
|
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) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
else if (
|
|
224
|
-
|
|
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('['))
|
|
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) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
else if (
|
|
257
|
-
|
|
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
|
|
358
|
+
for (const file of allFiles || []) {
|
|
275
359
|
const content = typeof file.content === 'string' ? file.content : '';
|
|
276
|
-
if (!content)
|
|
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)
|
|
381
|
+
if (!result) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
296
384
|
|
|
297
385
|
if (result.version === null && result.specifier === null) {
|
|
298
386
|
findings.push(directDependencyUnpinnedFinding());
|