@lateos/npm-scan 0.16.0 → 0.16.5
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/.dockerignore +20 -20
- package/.husky/pre-commit +1 -1
- package/CHANGELOG.md +199 -199
- package/LICENSING.md +19 -19
- package/README.de.md +708 -669
- package/README.fr.md +707 -668
- package/README.ja.md +704 -665
- package/README.md +826 -801
- package/README.zh.md +708 -669
- package/SECURITY.md +72 -72
- package/backend/cra.js +68 -68
- package/backend/db/schema.sql +32 -32
- package/backend/db.js +88 -88
- package/backend/detectors/atk-001-lifecycle.js +17 -17
- package/backend/detectors/atk-002-obfusc.js +261 -261
- package/backend/detectors/atk-003-creds.js +13 -13
- package/backend/detectors/atk-004-persist.js +13 -13
- package/backend/detectors/atk-005-exfil.js +13 -13
- package/backend/detectors/atk-006-depconf.js +14 -14
- package/backend/detectors/atk-007-typosquat.js +34 -34
- package/backend/detectors/atk-008-tarball-tamper.js +91 -91
- package/backend/detectors/atk-009-dormant-trigger.js +62 -62
- package/backend/detectors/atk-010-sandbox-evasion.js +50 -50
- package/backend/detectors/atk-011-transitive-prop.js +76 -76
- package/backend/detectors/axios-poisoning/d1-version-fingerprint.js +24 -0
- package/backend/detectors/axios-poisoning/d2-decoy-dep.js +24 -0
- package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +90 -0
- package/backend/detectors/axios-poisoning/index.js +94 -0
- package/backend/detectors/cve-2026-48710-badhost/codePattern.js +99 -99
- package/backend/detectors/cve-2026-48710-badhost/findings.js +105 -105
- package/backend/detectors/cve-2026-48710-badhost/index.js +15 -15
- package/backend/detectors/cve-2026-48710-badhost/manifest.js +305 -305
- package/backend/detectors/cve-2026-48710-badhost/transitive.js +189 -189
- package/backend/detectors/hf-impersonation/index.js +396 -396
- package/backend/detectors/hf-impersonation/jaro-winkler.js +44 -44
- package/backend/detectors/hf-impersonation/known-orgs.js +5 -5
- package/backend/detectors/hf-impersonation/simhash.js +46 -46
- package/backend/detectors/index.js +75 -38
- package/backend/detectors/megalodon/d1-workflow-scan.js +147 -147
- package/backend/detectors/megalodon/d2-credential-harvest.js +61 -61
- package/backend/detectors/megalodon/d3-publish-velocity.js +67 -67
- package/backend/detectors/megalodon/d4-publisher-drift.js +124 -124
- package/backend/detectors/megalodon/d5-bot-commit-identity.js +3 -3
- package/backend/detectors/megalodon/d6-date-anachronism.js +3 -3
- package/backend/detectors/megalodon/index.js +80 -80
- package/backend/detectors/megalodon/types.js +9 -9
- package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -42
- package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -116
- package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -72
- package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -45
- package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -95
- package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -38
- package/backend/detectors/mini-shai-hulud/index.js +118 -118
- package/backend/detectors/mini-shai-hulud/iocs.json +79 -79
- package/backend/detectors/msh-supplement/d1-obfuscation.js +18 -0
- package/backend/detectors/msh-supplement/d2-persistence.js +47 -0
- package/backend/detectors/msh-supplement/d3-geo-killswitch.js +35 -0
- package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +33 -0
- package/backend/detectors/msh-supplement/index.js +107 -0
- package/backend/detectors/tier1-binary-embed.js +219 -0
- package/backend/detectors/tier1-infostealer.js +280 -0
- package/backend/detectors/tier1-lifecycle-hook.js +176 -0
- package/backend/detectors/tier1-metadata-spoof.js +180 -0
- package/backend/detectors/tier1-typosquat.js +219 -0
- package/backend/detectors/typosquat-vpmdhaj/d1-maintainer.js +77 -0
- package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +37 -0
- package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +66 -0
- package/backend/detectors/typosquat-vpmdhaj/index.js +98 -0
- package/backend/fetch.js +175 -175
- package/backend/index.js +4 -4
- package/backend/license.js +89 -89
- package/backend/lockfile.js +379 -379
- package/backend/pdf.js +245 -245
- package/backend/policy.js +193 -176
- package/backend/provenance.js +79 -0
- package/backend/report.js +254 -254
- package/backend/sbom.js +66 -66
- package/backend/siem/cef.js +32 -32
- package/backend/siem/ecs.js +40 -40
- package/backend/siem/index.js +18 -18
- package/backend/siem/qradar.js +56 -56
- package/backend/siem/sentinel.js +27 -27
- package/backend/vsix-scan/detectors/activation-event-risk.js +116 -116
- package/backend/vsix-scan/detectors/burst-publish.js +52 -52
- package/backend/vsix-scan/detectors/exfil-pattern.js +88 -88
- package/backend/vsix-scan/detectors/known-ioc.js +105 -105
- package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -69
- package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -70
- package/backend/vsix-scan/index.js +183 -183
- package/backend/vsix-scan/marketplace-client.js +145 -145
- package/backend/vsix-scan/vsix-iocs.json +31 -31
- package/cli/cli.js +458 -458
- package/deploy/helm/npm-scan/Chart.yaml +21 -21
- package/deploy/helm/npm-scan/templates/_helpers.tpl +8 -8
- package/deploy/helm/npm-scan/templates/api.yaml +93 -93
- package/deploy/helm/npm-scan/templates/ingress.yaml +27 -27
- package/deploy/helm/npm-scan/templates/postgresql.yaml +66 -66
- package/deploy/helm/npm-scan/templates/secrets.yaml +18 -18
- package/deploy/helm/npm-scan/templates/worker.yaml +31 -31
- package/deploy/helm/npm-scan/values.byoc.yaml +74 -74
- package/deploy/helm/npm-scan/values.yaml +102 -102
- package/package.json +57 -57
- package/scripts/download-corpus.js +30 -30
- package/scripts/gen-mal-corpus.js +34 -34
- package/scripts/generate-campaign-fixtures.js +170 -0
- package/src/config/top-5000.json +87 -0
- package/test/fixtures/lockfiles/npm-lock.json +68 -68
- package/test/fixtures/lockfiles/pnpm-lock.yaml +117 -117
- package/test/fixtures/lockfiles/yarn.lock +103 -103
- package/test/fixtures/mock-data.js +69 -69
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const KNOWN_DECOYS = ['plain-crypto-js'];
|
|
2
|
+
|
|
3
|
+
export function scanDecoyDependency(pkgJson) {
|
|
4
|
+
const deps = { ...pkgJson?.dependencies, ...pkgJson?.devDependencies, ...pkgJson?.peerDependencies };
|
|
5
|
+
const findings = [];
|
|
6
|
+
|
|
7
|
+
for (const depName of KNOWN_DECOYS) {
|
|
8
|
+
if (deps[depName]) {
|
|
9
|
+
findings.push({
|
|
10
|
+
injectedDependency: depName,
|
|
11
|
+
pattern: 'Pre-staged decoy for supply chain attack',
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (findings.length > 0) {
|
|
17
|
+
return {
|
|
18
|
+
triggered: true,
|
|
19
|
+
findings,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { triggered: false, findings: [] };
|
|
24
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const SUSPICIOUS_HOOKS = ['postinstall', 'install', 'preinstall'];
|
|
2
|
+
const TEMP_DIR_RE = /(?:os\.tmpdir|tmpdir|temp|%TEMP%|\/tmp|\/var\/tmp)/;
|
|
3
|
+
const POWERSHELL_RE = /powershell|pwsh|cmd\.exe|Invoke-Expression|IEX\s*\(/;
|
|
4
|
+
const LAUNCHD_RE = /\/Library\/Launch(Daemons|Agents)\/|launchctl\s+(load|start|submit)/;
|
|
5
|
+
const SYSTEMD_SERVICE_RE = /\/etc\/systemd\/system\/|systemctl\s+(enable|start|daemon-reload)/;
|
|
6
|
+
const CRON_PERSIST_RE = /crontab\s+-[ei]|@reboot\s+|@daily\s+|@hourly\s+/;
|
|
7
|
+
const DLL_LOAD_RE = /LoadLibrary|dlopen|LoadLibraryEx|lib\.(?:LoadLibrary|dlopen)/;
|
|
8
|
+
const PROCESS_INJECT_RE = /CreateRemoteThread|VirtualAllocEx|WriteProcessMemory|NtCreateThreadEx/;
|
|
9
|
+
const NET_CALLBACK_RE = /(?:https?:\/\/|wss?:\/\/|ws:\/\/)(?:[^\s'"]*\.[^\s'"]{2,})/;
|
|
10
|
+
const BINARY_DROP_RE = /(?:fs\.writeFileSync|writeFile|writeFileSync)\s*\([^)]*(?:\.exe|\.dll|\.bin|\.bat|\.ps1)/;
|
|
11
|
+
|
|
12
|
+
const SUSPICIOUS_HOOK_PATTERNS = [
|
|
13
|
+
/curl|wget|fetch|https?:\/\//,
|
|
14
|
+
/powershell|cmd\.exe|bash\b|sh\b/,
|
|
15
|
+
/process\.exit|fs\.chmod|exec(?:Sync)?\s*\(/,
|
|
16
|
+
/spawn|fork|detached/,
|
|
17
|
+
/systemd|launchctl|crontab|schtasks/,
|
|
18
|
+
/LoadLibrary|dlopen/,
|
|
19
|
+
/eval|Function\s*\(/,
|
|
20
|
+
/__dirname|__filename/,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function scanPostinstallRAT(pkgJson, files = []) {
|
|
24
|
+
const scripts = pkgJson?.scripts || {};
|
|
25
|
+
const code = files.map(f => f.content || '').join('\n');
|
|
26
|
+
|
|
27
|
+
const activeHooks = [];
|
|
28
|
+
for (const hook of SUSPICIOUS_HOOKS) {
|
|
29
|
+
if (scripts[hook]) {
|
|
30
|
+
activeHooks.push({ hook, command: scripts[hook] });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (activeHooks.length === 0) {
|
|
35
|
+
return { triggered: false, platforms: [], c2Indicators: [], payloadType: null, hooks: [], hasBinaryDrop: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const combined = code + '\n' + activeHooks.map(h => h.command).join('\n');
|
|
39
|
+
|
|
40
|
+
const hasSuspiciousCode = SUSPICIOUS_HOOK_PATTERNS.some(p => p.test(combined));
|
|
41
|
+
|
|
42
|
+
if (activeHooks.length > 0 && !hasSuspiciousCode) {
|
|
43
|
+
return { triggered: false, platforms: [], c2Indicators: [], payloadType: null, hooks: [], hasBinaryDrop: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const platforms = [];
|
|
47
|
+
let c2Indicators = [];
|
|
48
|
+
let hasBinaryDrop = false;
|
|
49
|
+
|
|
50
|
+
if (POWERSHELL_RE.test(combined)) platforms.push('windows');
|
|
51
|
+
if (LAUNCHD_RE.test(combined)) platforms.push('macos');
|
|
52
|
+
if (SYSTEMD_SERVICE_RE.test(combined) || CRON_PERSIST_RE.test(combined)) platforms.push('linux');
|
|
53
|
+
if (TEMP_DIR_RE.test(combined) && (POWERSHELL_RE.test(combined) || BINARY_DROP_RE.test(combined))) {
|
|
54
|
+
if (!platforms.includes('windows')) platforms.push('windows');
|
|
55
|
+
if (!platforms.includes('linux')) platforms.push('linux');
|
|
56
|
+
if (!platforms.includes('macos')) platforms.push('macos');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (DLL_LOAD_RE.test(combined)) platforms.push('windows');
|
|
60
|
+
if (PROCESS_INJECT_RE.test(combined)) platforms.push('windows');
|
|
61
|
+
|
|
62
|
+
if (NET_CALLBACK_RE.test(combined)) {
|
|
63
|
+
const urls = combined.match(NET_CALLBACK_RE);
|
|
64
|
+
c2Indicators = urls ? [...new Set(urls.map(u => u.replace(/['")]/g, '')))] : ['Network callback to external server'];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (BINARY_DROP_RE.test(combined)) hasBinaryDrop = true;
|
|
68
|
+
|
|
69
|
+
let payloadType = null;
|
|
70
|
+
if (platforms.length >= 2 && c2Indicators.length > 0 && hasBinaryDrop) {
|
|
71
|
+
payloadType = 'cross_platform_RAT';
|
|
72
|
+
} else if (hasBinaryDrop && c2Indicators.length > 0) {
|
|
73
|
+
payloadType = 'network_backdoor';
|
|
74
|
+
} else if (hasBinaryDrop && platforms.length > 0) {
|
|
75
|
+
payloadType = 'platform_persistence';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (payloadType) {
|
|
79
|
+
return {
|
|
80
|
+
triggered: true,
|
|
81
|
+
payloadType,
|
|
82
|
+
platforms: [...new Set(platforms)],
|
|
83
|
+
c2Indicators,
|
|
84
|
+
hooks: activeHooks.map(h => h.hook),
|
|
85
|
+
hasBinaryDrop,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { triggered: false, platforms: [], c2Indicators: [], payloadType: null, hooks: [], hasBinaryDrop: false };
|
|
90
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { scanVersionBlocklist } from './d1-version-fingerprint.js';
|
|
2
|
+
import { scanDecoyDependency } from './d2-decoy-dep.js';
|
|
3
|
+
import { scanPostinstallRAT } from './d3-postinstall-rat.js';
|
|
4
|
+
import { attachProvenance } from '../../provenance.js';
|
|
5
|
+
|
|
6
|
+
const RULE_SEVERITY = { D1: 'critical', D2: 'critical', D3: 'critical' };
|
|
7
|
+
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info', 'none'];
|
|
8
|
+
|
|
9
|
+
function highestSeverity(severities) {
|
|
10
|
+
for (const s of SEVERITY_ORDER) if (severities.includes(s)) return s;
|
|
11
|
+
return 'none';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function scan(pkgJson, files = [], registryMeta = null, allFiles = null) {
|
|
15
|
+
const pkgName = pkgJson?.name || 'unknown';
|
|
16
|
+
const pkgVersion = pkgJson?.version || '0.0.0';
|
|
17
|
+
const fileList = allFiles || files || [];
|
|
18
|
+
|
|
19
|
+
const d1Result = scanVersionBlocklist(pkgJson);
|
|
20
|
+
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
|
+
});
|
|
41
|
+
|
|
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
|
+
}];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const d2Result = scanDecoyDependency(pkgJson);
|
|
54
|
+
const d3Result = scanPostinstallRAT(pkgJson, fileList);
|
|
55
|
+
|
|
56
|
+
const results = { D1: d1Result, D2: d2Result, D3: d3Result };
|
|
57
|
+
|
|
58
|
+
const triggered = Object.entries(results)
|
|
59
|
+
.filter(([_, r]) => r.triggered)
|
|
60
|
+
.map(([id]) => id);
|
|
61
|
+
|
|
62
|
+
if (triggered.length === 0) return [];
|
|
63
|
+
|
|
64
|
+
const severity = highestSeverity(triggered.map(id => RULE_SEVERITY[id]));
|
|
65
|
+
|
|
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
|
+
});
|
|
85
|
+
|
|
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
|
+
}];
|
|
94
|
+
}
|
|
@@ -1,99 +1,99 @@
|
|
|
1
|
-
import { codePatternAuthFinding, codePatternInfoFinding } from './findings.js';
|
|
2
|
-
|
|
3
|
-
const AUTH_CONTEXT_PATHS = [
|
|
4
|
-
'middleware',
|
|
5
|
-
'auth',
|
|
6
|
-
'security',
|
|
7
|
-
'router',
|
|
8
|
-
'depends',
|
|
9
|
-
'guard',
|
|
10
|
-
'permission',
|
|
11
|
-
];
|
|
12
|
-
|
|
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;
|
|
15
|
-
|
|
16
|
-
function hasAuthContext(filePath) {
|
|
17
|
-
const lower = filePath.toLowerCase();
|
|
18
|
-
return AUTH_CONTEXT_PATHS.some(ctx => lower.includes(ctx));
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function findFunctionBoundaries(lines) {
|
|
22
|
-
const functions = [];
|
|
23
|
-
let currentFn = null;
|
|
24
|
-
let fnBodyStart = -1;
|
|
25
|
-
let indent = 0;
|
|
26
|
-
|
|
27
|
-
for (let i = 0; i < lines.length; i++) {
|
|
28
|
-
const line = lines[i];
|
|
29
|
-
const defMatch = line.match(/^(def\s+\w+|async\s+def\s+\w+)/);
|
|
30
|
-
if (defMatch) {
|
|
31
|
-
if (currentFn) {
|
|
32
|
-
functions.push({ name: currentFn, startLine: fnBodyStart, endLine: i - 1 });
|
|
33
|
-
}
|
|
34
|
-
currentFn = defMatch[1];
|
|
35
|
-
fnBodyStart = i;
|
|
36
|
-
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
|
-
functions.push({ name: currentFn, startLine: fnBodyStart, endLine: i - 1 });
|
|
39
|
-
currentFn = null;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
if (currentFn) {
|
|
43
|
-
functions.push({ name: currentFn, startLine: fnBodyStart, endLine: lines.length - 1 });
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return functions;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function hasScopePathInFunction(lines, fnStart, fnEnd) {
|
|
50
|
-
for (let i = fnStart; i <= fnEnd && i < lines.length; i++) {
|
|
51
|
-
if (SCOPE_PATH_PATTERN.test(lines[i])) return true;
|
|
52
|
-
}
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function scanCodePatterns(allFiles) {
|
|
57
|
-
const findings = [];
|
|
58
|
-
|
|
59
|
-
for (const file of (allFiles || [])) {
|
|
60
|
-
const content = typeof file.content === 'string' ? file.content : '';
|
|
61
|
-
if (!content) continue;
|
|
62
|
-
const path = file.path || '';
|
|
63
|
-
if (!path.endsWith('.py')) continue;
|
|
64
|
-
|
|
65
|
-
const lines = content.split('\n');
|
|
66
|
-
const isAuthContext = hasAuthContext(path);
|
|
67
|
-
const functions = findFunctionBoundaries(lines);
|
|
68
|
-
const suppressedLines = new Set();
|
|
69
|
-
|
|
70
|
-
for (const fn of functions) {
|
|
71
|
-
if (hasScopePathInFunction(lines, fn.startLine, fn.endLine)) {
|
|
72
|
-
for (let i = fn.startLine; i <= fn.endLine && i < lines.length; i++) {
|
|
73
|
-
if (URL_PATH_PATTERN.test(lines[i])) {
|
|
74
|
-
suppressedLines.add(i + 1);
|
|
75
|
-
}
|
|
76
|
-
URL_PATH_PATTERN.lastIndex = 0;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (isAuthContext) {
|
|
82
|
-
let m;
|
|
83
|
-
while ((m = URL_PATH_PATTERN.exec(content)) !== null) {
|
|
84
|
-
const lineNumber = content.slice(0, m.index).split('\n').length;
|
|
85
|
-
if (suppressedLines.has(lineNumber)) continue;
|
|
86
|
-
findings.push(codePatternAuthFinding(path, lineNumber));
|
|
87
|
-
}
|
|
88
|
-
} else {
|
|
89
|
-
let m;
|
|
90
|
-
while ((m = URL_PATH_PATTERN.exec(content)) !== null) {
|
|
91
|
-
const lineNumber = content.slice(0, m.index).split('\n').length;
|
|
92
|
-
if (suppressedLines.has(lineNumber)) continue;
|
|
93
|
-
findings.push(codePatternInfoFinding(path, lineNumber));
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return findings;
|
|
99
|
-
}
|
|
1
|
+
import { codePatternAuthFinding, codePatternInfoFinding } from './findings.js';
|
|
2
|
+
|
|
3
|
+
const AUTH_CONTEXT_PATHS = [
|
|
4
|
+
'middleware',
|
|
5
|
+
'auth',
|
|
6
|
+
'security',
|
|
7
|
+
'router',
|
|
8
|
+
'depends',
|
|
9
|
+
'guard',
|
|
10
|
+
'permission',
|
|
11
|
+
];
|
|
12
|
+
|
|
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;
|
|
15
|
+
|
|
16
|
+
function hasAuthContext(filePath) {
|
|
17
|
+
const lower = filePath.toLowerCase();
|
|
18
|
+
return AUTH_CONTEXT_PATHS.some(ctx => lower.includes(ctx));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function findFunctionBoundaries(lines) {
|
|
22
|
+
const functions = [];
|
|
23
|
+
let currentFn = null;
|
|
24
|
+
let fnBodyStart = -1;
|
|
25
|
+
let indent = 0;
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < lines.length; i++) {
|
|
28
|
+
const line = lines[i];
|
|
29
|
+
const defMatch = line.match(/^(def\s+\w+|async\s+def\s+\w+)/);
|
|
30
|
+
if (defMatch) {
|
|
31
|
+
if (currentFn) {
|
|
32
|
+
functions.push({ name: currentFn, startLine: fnBodyStart, endLine: i - 1 });
|
|
33
|
+
}
|
|
34
|
+
currentFn = defMatch[1];
|
|
35
|
+
fnBodyStart = i;
|
|
36
|
+
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
|
+
functions.push({ name: currentFn, startLine: fnBodyStart, endLine: i - 1 });
|
|
39
|
+
currentFn = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (currentFn) {
|
|
43
|
+
functions.push({ name: currentFn, startLine: fnBodyStart, endLine: lines.length - 1 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return functions;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hasScopePathInFunction(lines, fnStart, fnEnd) {
|
|
50
|
+
for (let i = fnStart; i <= fnEnd && i < lines.length; i++) {
|
|
51
|
+
if (SCOPE_PATH_PATTERN.test(lines[i])) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function scanCodePatterns(allFiles) {
|
|
57
|
+
const findings = [];
|
|
58
|
+
|
|
59
|
+
for (const file of (allFiles || [])) {
|
|
60
|
+
const content = typeof file.content === 'string' ? file.content : '';
|
|
61
|
+
if (!content) continue;
|
|
62
|
+
const path = file.path || '';
|
|
63
|
+
if (!path.endsWith('.py')) continue;
|
|
64
|
+
|
|
65
|
+
const lines = content.split('\n');
|
|
66
|
+
const isAuthContext = hasAuthContext(path);
|
|
67
|
+
const functions = findFunctionBoundaries(lines);
|
|
68
|
+
const suppressedLines = new Set();
|
|
69
|
+
|
|
70
|
+
for (const fn of functions) {
|
|
71
|
+
if (hasScopePathInFunction(lines, fn.startLine, fn.endLine)) {
|
|
72
|
+
for (let i = fn.startLine; i <= fn.endLine && i < lines.length; i++) {
|
|
73
|
+
if (URL_PATH_PATTERN.test(lines[i])) {
|
|
74
|
+
suppressedLines.add(i + 1);
|
|
75
|
+
}
|
|
76
|
+
URL_PATH_PATTERN.lastIndex = 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (isAuthContext) {
|
|
82
|
+
let m;
|
|
83
|
+
while ((m = URL_PATH_PATTERN.exec(content)) !== null) {
|
|
84
|
+
const lineNumber = content.slice(0, m.index).split('\n').length;
|
|
85
|
+
if (suppressedLines.has(lineNumber)) continue;
|
|
86
|
+
findings.push(codePatternAuthFinding(path, lineNumber));
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
let m;
|
|
90
|
+
while ((m = URL_PATH_PATTERN.exec(content)) !== null) {
|
|
91
|
+
const lineNumber = content.slice(0, m.index).split('\n').length;
|
|
92
|
+
if (suppressedLines.has(lineNumber)) continue;
|
|
93
|
+
findings.push(codePatternInfoFinding(path, lineNumber));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return findings;
|
|
99
|
+
}
|
|
@@ -1,105 +1,105 @@
|
|
|
1
|
-
const CVE = 'CVE-2026-48710';
|
|
2
|
-
const NICKNAME = 'BadHost';
|
|
3
|
-
const CVSS = 7.0;
|
|
4
|
-
const REFERENCES = [
|
|
5
|
-
'https://ostif.org/disclosing-the-badhost-vulnerability-in-starlette/',
|
|
6
|
-
'https://github.com/Kludex/starlette/security/advisories/GHSA-86qp-5c8j-p5mr',
|
|
7
|
-
'https://badhost.org/',
|
|
8
|
-
'https://osv.dev/vulnerability/PYSEC-2026-161',
|
|
9
|
-
];
|
|
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.';
|
|
12
|
-
|
|
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
|
-
|
|
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.';
|
|
16
|
-
|
|
17
|
-
function makeFinding(overrides = {}) {
|
|
18
|
-
return {
|
|
19
|
-
id: CVE,
|
|
20
|
-
severity: 'high',
|
|
21
|
-
title: `${NICKNAME} — ${overrides.source || 'unknown'}`,
|
|
22
|
-
description: '',
|
|
23
|
-
remediation: '',
|
|
24
|
-
evidence: JSON.stringify({
|
|
25
|
-
cve: CVE,
|
|
26
|
-
nickname: NICKNAME,
|
|
27
|
-
cvss: CVSS,
|
|
28
|
-
references: REFERENCES,
|
|
29
|
-
...overrides,
|
|
30
|
-
}),
|
|
31
|
-
...overrides,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function directDependencyFinding(version, specifier) {
|
|
36
|
-
return makeFinding({
|
|
37
|
-
severity: 'high',
|
|
38
|
-
confidence: 'HIGH',
|
|
39
|
-
source: 'direct-dependency',
|
|
40
|
-
title: `${NICKNAME}: Starlette ${version} vulnerable`,
|
|
41
|
-
description: `Starlette ${version} (${specifier}) is vulnerable to CVE-2026-48710 (BadHost) — authentication bypass via Host header injection. Upgrade to starlette >= 1.0.1.`,
|
|
42
|
-
remediation: `${DEPENDENCY_REMEDIATION} ${MITIGATION_NOTE}`,
|
|
43
|
-
file: null,
|
|
44
|
-
line: null,
|
|
45
|
-
via: null,
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function directDependencyUnpinnedFinding() {
|
|
50
|
-
return makeFinding({
|
|
51
|
-
severity: 'high',
|
|
52
|
-
confidence: 'HIGH',
|
|
53
|
-
source: 'direct-dependency-unpinned',
|
|
54
|
-
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.',
|
|
56
|
-
remediation: `${DEPENDENCY_REMEDIATION} ${MITIGATION_NOTE}`,
|
|
57
|
-
file: null,
|
|
58
|
-
line: null,
|
|
59
|
-
via: null,
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function transitiveDependencyFinding(packageName, tier) {
|
|
64
|
-
const confidence = tier === 1 ? 'HIGH' : 'MEDIUM';
|
|
65
|
-
const tierLabel = tier === 1 ? 'Tier 1' : 'Tier 2';
|
|
66
|
-
return makeFinding({
|
|
67
|
-
severity: 'high',
|
|
68
|
-
confidence,
|
|
69
|
-
source: 'transitive-dependency',
|
|
70
|
-
title: `${NICKNAME}: Transitive via ${packageName}`,
|
|
71
|
-
description: `Starlette not directly pinned; inherited through ${packageName} (${tierLabel}). ${packageName} depends on Starlette — if its version constraint allows Starlette < 1.0.1, your deployment is vulnerable.`,
|
|
72
|
-
remediation: `${DEPENDENCY_REMEDIATION} ${MITIGATION_NOTE}`,
|
|
73
|
-
file: null,
|
|
74
|
-
line: null,
|
|
75
|
-
via: packageName,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function codePatternAuthFinding(filePath, lineNumber) {
|
|
80
|
-
return makeFinding({
|
|
81
|
-
severity: 'medium',
|
|
82
|
-
confidence: 'MEDIUM',
|
|
83
|
-
source: 'code-pattern',
|
|
84
|
-
title: `${NICKNAME}: Dangerous path extraction in auth/middleware`,
|
|
85
|
-
description: `request.url.path used in auth/middleware context at ${filePath}:${lineNumber} — use request.scope['path'] instead. The reconstructed request.url is influenced by the unvalidated Host header in Starlette < 1.0.1.`,
|
|
86
|
-
remediation: `${CODE_REMEDIATION} ${MITIGATION_NOTE}`,
|
|
87
|
-
file: filePath,
|
|
88
|
-
line: lineNumber,
|
|
89
|
-
via: null,
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function codePatternInfoFinding(filePath, lineNumber) {
|
|
94
|
-
return makeFinding({
|
|
95
|
-
severity: 'info',
|
|
96
|
-
confidence: 'LOW',
|
|
97
|
-
source: 'code-pattern',
|
|
98
|
-
title: `${NICKNAME}: request.url.path usage detected`,
|
|
99
|
-
description: `request.url.path used at ${filePath}:${lineNumber} — may be influenced by unvalidated Host header in Starlette < 1.0.1. Verify request.scope['path'] is used for security decisions.`,
|
|
100
|
-
remediation: `${CODE_REMEDIATION} ${MITIGATION_NOTE}`,
|
|
101
|
-
file: filePath,
|
|
102
|
-
line: lineNumber,
|
|
103
|
-
via: null,
|
|
104
|
-
});
|
|
105
|
-
}
|
|
1
|
+
const CVE = 'CVE-2026-48710';
|
|
2
|
+
const NICKNAME = 'BadHost';
|
|
3
|
+
const CVSS = 7.0;
|
|
4
|
+
const REFERENCES = [
|
|
5
|
+
'https://ostif.org/disclosing-the-badhost-vulnerability-in-starlette/',
|
|
6
|
+
'https://github.com/Kludex/starlette/security/advisories/GHSA-86qp-5c8j-p5mr',
|
|
7
|
+
'https://badhost.org/',
|
|
8
|
+
'https://osv.dev/vulnerability/PYSEC-2026-161',
|
|
9
|
+
];
|
|
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.';
|
|
12
|
+
|
|
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
|
+
|
|
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.';
|
|
16
|
+
|
|
17
|
+
function makeFinding(overrides = {}) {
|
|
18
|
+
return {
|
|
19
|
+
id: CVE,
|
|
20
|
+
severity: 'high',
|
|
21
|
+
title: `${NICKNAME} — ${overrides.source || 'unknown'}`,
|
|
22
|
+
description: '',
|
|
23
|
+
remediation: '',
|
|
24
|
+
evidence: JSON.stringify({
|
|
25
|
+
cve: CVE,
|
|
26
|
+
nickname: NICKNAME,
|
|
27
|
+
cvss: CVSS,
|
|
28
|
+
references: REFERENCES,
|
|
29
|
+
...overrides,
|
|
30
|
+
}),
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function directDependencyFinding(version, specifier) {
|
|
36
|
+
return makeFinding({
|
|
37
|
+
severity: 'high',
|
|
38
|
+
confidence: 'HIGH',
|
|
39
|
+
source: 'direct-dependency',
|
|
40
|
+
title: `${NICKNAME}: Starlette ${version} vulnerable`,
|
|
41
|
+
description: `Starlette ${version} (${specifier}) is vulnerable to CVE-2026-48710 (BadHost) — authentication bypass via Host header injection. Upgrade to starlette >= 1.0.1.`,
|
|
42
|
+
remediation: `${DEPENDENCY_REMEDIATION} ${MITIGATION_NOTE}`,
|
|
43
|
+
file: null,
|
|
44
|
+
line: null,
|
|
45
|
+
via: null,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function directDependencyUnpinnedFinding() {
|
|
50
|
+
return makeFinding({
|
|
51
|
+
severity: 'high',
|
|
52
|
+
confidence: 'HIGH',
|
|
53
|
+
source: 'direct-dependency-unpinned',
|
|
54
|
+
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.',
|
|
56
|
+
remediation: `${DEPENDENCY_REMEDIATION} ${MITIGATION_NOTE}`,
|
|
57
|
+
file: null,
|
|
58
|
+
line: null,
|
|
59
|
+
via: null,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function transitiveDependencyFinding(packageName, tier) {
|
|
64
|
+
const confidence = tier === 1 ? 'HIGH' : 'MEDIUM';
|
|
65
|
+
const tierLabel = tier === 1 ? 'Tier 1' : 'Tier 2';
|
|
66
|
+
return makeFinding({
|
|
67
|
+
severity: 'high',
|
|
68
|
+
confidence,
|
|
69
|
+
source: 'transitive-dependency',
|
|
70
|
+
title: `${NICKNAME}: Transitive via ${packageName}`,
|
|
71
|
+
description: `Starlette not directly pinned; inherited through ${packageName} (${tierLabel}). ${packageName} depends on Starlette — if its version constraint allows Starlette < 1.0.1, your deployment is vulnerable.`,
|
|
72
|
+
remediation: `${DEPENDENCY_REMEDIATION} ${MITIGATION_NOTE}`,
|
|
73
|
+
file: null,
|
|
74
|
+
line: null,
|
|
75
|
+
via: packageName,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function codePatternAuthFinding(filePath, lineNumber) {
|
|
80
|
+
return makeFinding({
|
|
81
|
+
severity: 'medium',
|
|
82
|
+
confidence: 'MEDIUM',
|
|
83
|
+
source: 'code-pattern',
|
|
84
|
+
title: `${NICKNAME}: Dangerous path extraction in auth/middleware`,
|
|
85
|
+
description: `request.url.path used in auth/middleware context at ${filePath}:${lineNumber} — use request.scope['path'] instead. The reconstructed request.url is influenced by the unvalidated Host header in Starlette < 1.0.1.`,
|
|
86
|
+
remediation: `${CODE_REMEDIATION} ${MITIGATION_NOTE}`,
|
|
87
|
+
file: filePath,
|
|
88
|
+
line: lineNumber,
|
|
89
|
+
via: null,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function codePatternInfoFinding(filePath, lineNumber) {
|
|
94
|
+
return makeFinding({
|
|
95
|
+
severity: 'info',
|
|
96
|
+
confidence: 'LOW',
|
|
97
|
+
source: 'code-pattern',
|
|
98
|
+
title: `${NICKNAME}: request.url.path usage detected`,
|
|
99
|
+
description: `request.url.path used at ${filePath}:${lineNumber} — may be influenced by unvalidated Host header in Starlette < 1.0.1. Verify request.scope['path'] is used for security decisions.`,
|
|
100
|
+
remediation: `${CODE_REMEDIATION} ${MITIGATION_NOTE}`,
|
|
101
|
+
file: filePath,
|
|
102
|
+
line: lineNumber,
|
|
103
|
+
via: null,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { scanFiles } from './manifest.js';
|
|
2
|
-
import { scanTransitive } from './transitive.js';
|
|
3
|
-
import { scanCodePatterns } from './codePattern.js';
|
|
4
|
-
|
|
5
|
-
export async function scan(pkgJson, files = [], registryMeta = null, allFiles = null) {
|
|
6
|
-
const targetFiles = allFiles || files;
|
|
7
|
-
|
|
8
|
-
const manifestFindings = scanFiles(targetFiles);
|
|
9
|
-
const transitiveFindings = scanTransitive(targetFiles);
|
|
10
|
-
const codeFindings = scanCodePatterns(targetFiles);
|
|
11
|
-
|
|
12
|
-
const allFindings = [...manifestFindings, ...transitiveFindings, ...codeFindings];
|
|
13
|
-
|
|
14
|
-
return allFindings;
|
|
15
|
-
}
|
|
1
|
+
import { scanFiles } from './manifest.js';
|
|
2
|
+
import { scanTransitive } from './transitive.js';
|
|
3
|
+
import { scanCodePatterns } from './codePattern.js';
|
|
4
|
+
|
|
5
|
+
export async function scan(pkgJson, files = [], registryMeta = null, allFiles = null) {
|
|
6
|
+
const targetFiles = allFiles || files;
|
|
7
|
+
|
|
8
|
+
const manifestFindings = scanFiles(targetFiles);
|
|
9
|
+
const transitiveFindings = scanTransitive(targetFiles);
|
|
10
|
+
const codeFindings = scanCodePatterns(targetFiles);
|
|
11
|
+
|
|
12
|
+
const allFindings = [...manifestFindings, ...transitiveFindings, ...codeFindings];
|
|
13
|
+
|
|
14
|
+
return allFindings;
|
|
15
|
+
}
|