@lateos/npm-scan 0.15.4 → 0.15.6
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 +7 -2
- package/README.fr.md +7 -2
- package/README.ja.md +7 -2
- package/README.md +55 -29
- package/README.zh.md +7 -2
- package/backend/detectors/cve-2026-48710-badhost/codePattern.js +99 -0
- package/backend/detectors/cve-2026-48710-badhost/findings.js +105 -0
- package/backend/detectors/cve-2026-48710-badhost/index.js +15 -0
- package/backend/detectors/cve-2026-48710-badhost/manifest.js +305 -0
- package/backend/detectors/cve-2026-48710-badhost/transitive.js +189 -0
- package/backend/detectors/index.js +2 -0
- package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +1 -1
- package/backend/detectors/mini-shai-hulud/index.js +41 -3
- package/backend/detectors/mini-shai-hulud/iocs.json +32 -0
- package/backend/vsix-scan/detectors/activation-event-risk.js +116 -0
- package/backend/vsix-scan/detectors/burst-publish.js +52 -0
- package/backend/vsix-scan/detectors/exfil-pattern.js +88 -0
- package/backend/vsix-scan/detectors/known-ioc.js +105 -0
- package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -0
- package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -0
- package/backend/vsix-scan/index.js +183 -0
- package/backend/vsix-scan/marketplace-client.js +145 -0
- package/backend/vsix-scan/vsix-iocs.json +31 -0
- package/cli/cli.js +21 -4
- package/package.json +1 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { directDependencyFinding, directDependencyUnpinnedFinding } from './findings.js';
|
|
2
|
+
|
|
3
|
+
function parseReqTxtLine(line) {
|
|
4
|
+
const trimmed = line.trim();
|
|
5
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) return null;
|
|
6
|
+
const idx = trimmed.indexOf('#');
|
|
7
|
+
const spec = idx >= 0 ? trimmed.slice(0, idx).trim() : trimmed;
|
|
8
|
+
if (!spec || !spec.startsWith('starlette')) return null;
|
|
9
|
+
|
|
10
|
+
const eqIdx = spec.indexOf('==');
|
|
11
|
+
const geIdx = spec.indexOf('>=');
|
|
12
|
+
const tildeIdx = spec.indexOf('~=');
|
|
13
|
+
const ltIdx = spec.indexOf('<');
|
|
14
|
+
|
|
15
|
+
if (eqIdx >= 0) {
|
|
16
|
+
const ver = spec.slice(eqIdx + 2).trim();
|
|
17
|
+
return { name: 'starlette', version: ver, specifier: `==${ver}` };
|
|
18
|
+
}
|
|
19
|
+
if (geIdx >= 0) {
|
|
20
|
+
const rest = spec.slice(geIdx + 2).trim();
|
|
21
|
+
const parts = rest.split(',');
|
|
22
|
+
const lower = parts[0]?.trim();
|
|
23
|
+
const upper = parts[1]?.trim();
|
|
24
|
+
let specStr = `>=${lower}`;
|
|
25
|
+
if (upper && upper.startsWith('<')) specStr += `,${upper}`;
|
|
26
|
+
return { name: 'starlette', version: lower, specifier: specStr };
|
|
27
|
+
}
|
|
28
|
+
if (tildeIdx >= 0) {
|
|
29
|
+
const ver = spec.slice(tildeIdx + 2).trim();
|
|
30
|
+
return { name: 'starlette', version: ver, specifier: `~=${ver}` };
|
|
31
|
+
}
|
|
32
|
+
if (ltIdx >= 0) {
|
|
33
|
+
const ver = spec.slice(ltIdx + 1).trim();
|
|
34
|
+
const name = 'starlette';
|
|
35
|
+
return { name, version: ver, specifier: `<${ver}` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const rest = spec.slice('starlette'.length).trim();
|
|
39
|
+
if (!rest) return { name: 'starlette', version: null, specifier: null };
|
|
40
|
+
|
|
41
|
+
if (!rest.includes('=') && !rest.includes('<') && !rest.includes('>') && !rest.includes('~') && !rest.includes('!')) {
|
|
42
|
+
return { name: 'starlette', version: rest, specifier: rest };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseRequirementsTxt(content) {
|
|
49
|
+
const lines = content.split('\n');
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
const result = parseReqTxtLine(line);
|
|
52
|
+
if (result) return result;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parsePEP440(versionStr) {
|
|
58
|
+
if (!versionStr) return null;
|
|
59
|
+
const clean = versionStr.trim().replace(/^v/, '');
|
|
60
|
+
const parts = clean.split('.');
|
|
61
|
+
return {
|
|
62
|
+
major: parseInt(parts[0], 10) || 0,
|
|
63
|
+
minor: parseInt(parts[1], 10) || 0,
|
|
64
|
+
patch: parseInt(parts[2], 10) || 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
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;
|
|
73
|
+
return a.patch - b.patch;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const STARLETTE_SAFE = parsePEP440('1.0.1');
|
|
77
|
+
|
|
78
|
+
function isVulnerable(version) {
|
|
79
|
+
if (!version) return true;
|
|
80
|
+
const parsed = parsePEP440(version);
|
|
81
|
+
if (!parsed) return true;
|
|
82
|
+
return compareVersions(parsed, STARLETTE_SAFE) < 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function findStarletteInTOML(obj) {
|
|
86
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
87
|
+
|
|
88
|
+
const sectionPaths = ['dependencies', 'project.dependencies', 'tool.poetry.dependencies'];
|
|
89
|
+
for (const path of sectionPaths) {
|
|
90
|
+
const parts = path.split('.');
|
|
91
|
+
let ptr = obj;
|
|
92
|
+
let found = true;
|
|
93
|
+
for (const p of parts) {
|
|
94
|
+
if (!ptr || typeof ptr !== 'object') { found = false; break; }
|
|
95
|
+
ptr = ptr[p];
|
|
96
|
+
}
|
|
97
|
+
if (!found || !ptr || typeof ptr !== 'object') continue;
|
|
98
|
+
for (const [key, val] of Object.entries(ptr)) {
|
|
99
|
+
if (key === 'starlette' || key === '"starlette"') {
|
|
100
|
+
const version = typeof val === 'string' ? val : (val?.version || null);
|
|
101
|
+
const specifier = typeof val === 'string' ? val : null;
|
|
102
|
+
return { name: 'starlette', version, specifier };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseTomlSimple(content) {
|
|
110
|
+
const result = {};
|
|
111
|
+
let currentSection = result;
|
|
112
|
+
|
|
113
|
+
for (const line of content.split('\n')) {
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
116
|
+
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
|
|
117
|
+
if (sectionMatch) {
|
|
118
|
+
const parts = sectionMatch[1].split('.');
|
|
119
|
+
let ptr = result;
|
|
120
|
+
for (const p of parts) {
|
|
121
|
+
const key = p.replace(/^"(.*)"$/, '$1').trim();
|
|
122
|
+
if (!ptr[key]) ptr[key] = {};
|
|
123
|
+
ptr = ptr[key];
|
|
124
|
+
}
|
|
125
|
+
currentSection = ptr;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const kvMatch = trimmed.match(/^([^=]+)=\s*(.+)$/);
|
|
129
|
+
if (!kvMatch) continue;
|
|
130
|
+
const key = kvMatch[1].trim().replace(/^"(.*)"$/, '$1');
|
|
131
|
+
let val = kvMatch[2].trim();
|
|
132
|
+
if (val.startsWith('"') && val.endsWith('"')) {
|
|
133
|
+
val = val.slice(1, -1);
|
|
134
|
+
} else if (val.startsWith("'") && val.endsWith("'")) {
|
|
135
|
+
val = val.slice(1, -1);
|
|
136
|
+
} else if (val.startsWith('^') || val.startsWith('~') || val.startsWith('>') || val.startsWith('<') || val.startsWith('=') || val.startsWith('!')) {
|
|
137
|
+
val = val.replace(/"/g, '');
|
|
138
|
+
}
|
|
139
|
+
currentSection[key] = val;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function parsePyprojectToml(content) {
|
|
146
|
+
let obj;
|
|
147
|
+
try {
|
|
148
|
+
obj = JSON.parse(content);
|
|
149
|
+
} catch {
|
|
150
|
+
obj = parseTomlSimple(content);
|
|
151
|
+
}
|
|
152
|
+
return findStarletteInTOML(obj);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parsePoetryLockEntry(content) {
|
|
156
|
+
const lines = content.split('\n');
|
|
157
|
+
let inStarlette = false;
|
|
158
|
+
let version = null;
|
|
159
|
+
for (const line of lines) {
|
|
160
|
+
const trimmed = line.trim();
|
|
161
|
+
if (trimmed.startsWith('[[package]]')) {
|
|
162
|
+
inStarlette = false;
|
|
163
|
+
}
|
|
164
|
+
if (trimmed.startsWith('name = "starlette"') || trimmed.startsWith("name = 'starlette'")) {
|
|
165
|
+
inStarlette = true;
|
|
166
|
+
}
|
|
167
|
+
if (inStarlette && trimmed.startsWith('version = ')) {
|
|
168
|
+
const match = trimmed.match(/version\s*=\s*["'](.+?)["']/);
|
|
169
|
+
if (match) version = match[1];
|
|
170
|
+
}
|
|
171
|
+
if (inStarlette && trimmed.startsWith('[[package]]') && trimmed !== '[[package]]') {
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (version) {
|
|
176
|
+
return { name: 'starlette', version, specifier: `==${version}` };
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function parsePoetryLock(content) {
|
|
182
|
+
return parsePoetryLockEntry(content);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parsePipfileEntry(content) {
|
|
186
|
+
try {
|
|
187
|
+
const obj = JSON.parse(content);
|
|
188
|
+
const packages = obj?.packages || {};
|
|
189
|
+
for (const [key, val] of Object.entries(packages)) {
|
|
190
|
+
if (key === 'starlette' || key === '"starlette"') {
|
|
191
|
+
const version = typeof val === 'string' ? val : null;
|
|
192
|
+
return { name: 'starlette', version, specifier: version || null };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function parsePipfile(content) {
|
|
202
|
+
return parsePipfileEntry(content);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseSetupPyContent(content) {
|
|
206
|
+
const match = content.match(/install_requires\s*=\s*\[([^\]]+)\]/s);
|
|
207
|
+
if (!match) return null;
|
|
208
|
+
const block = match[1];
|
|
209
|
+
const lines = block.split(',').map(l => l.trim().replace(/["']/g, ''));
|
|
210
|
+
for (const line of lines) {
|
|
211
|
+
const clean = line.trim();
|
|
212
|
+
if (!clean) continue;
|
|
213
|
+
if (clean.startsWith('starlette')) {
|
|
214
|
+
const eqIdx = clean.indexOf('==');
|
|
215
|
+
const geIdx = clean.indexOf('>=');
|
|
216
|
+
const tildeIdx = clean.indexOf('~=');
|
|
217
|
+
const ltIdx = clean.indexOf('<');
|
|
218
|
+
let version = null;
|
|
219
|
+
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 }; }
|
|
225
|
+
return { name: 'starlette', version, specifier };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function parseSetupPy(content) {
|
|
232
|
+
return parseSetupPyContent(content);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseSetupCfgContent(content) {
|
|
236
|
+
const lines = content.split('\n');
|
|
237
|
+
let inInstallRequires = false;
|
|
238
|
+
for (const line of lines) {
|
|
239
|
+
const trimmed = line.trim();
|
|
240
|
+
if (trimmed.startsWith('install_requires')) {
|
|
241
|
+
inInstallRequires = true;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (inInstallRequires) {
|
|
245
|
+
if (trimmed.startsWith('[')) break;
|
|
246
|
+
if (trimmed.startsWith('starlette')) {
|
|
247
|
+
const eqIdx = trimmed.indexOf('==');
|
|
248
|
+
const geIdx = trimmed.indexOf('>=');
|
|
249
|
+
const tildeIdx = trimmed.indexOf('~=');
|
|
250
|
+
const ltIdx = trimmed.indexOf('<');
|
|
251
|
+
let version = null;
|
|
252
|
+
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 }; }
|
|
258
|
+
if (trimmed.startsWith('starlette')) {
|
|
259
|
+
return { name: 'starlette', version, specifier };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function parseSetupCfg(content) {
|
|
268
|
+
return parseSetupCfgContent(content);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function scanFiles(allFiles) {
|
|
272
|
+
const findings = [];
|
|
273
|
+
|
|
274
|
+
for (const file of (allFiles || [])) {
|
|
275
|
+
const content = typeof file.content === 'string' ? file.content : '';
|
|
276
|
+
if (!content) continue;
|
|
277
|
+
const path = file.path || '';
|
|
278
|
+
|
|
279
|
+
let result = null;
|
|
280
|
+
|
|
281
|
+
if (path === 'requirements.txt' || path.match(/^requirements\/.*\.txt$/)) {
|
|
282
|
+
result = parseRequirementsTxt(content);
|
|
283
|
+
} else if (path === 'pyproject.toml') {
|
|
284
|
+
result = parsePyprojectToml(content);
|
|
285
|
+
} else if (path === 'poetry.lock') {
|
|
286
|
+
result = parsePoetryLock(content);
|
|
287
|
+
} else if (path === 'Pipfile' || path === 'Pipfile.lock') {
|
|
288
|
+
result = parsePipfile(content);
|
|
289
|
+
} else if (path === 'setup.py') {
|
|
290
|
+
result = parseSetupPy(content);
|
|
291
|
+
} else if (path === 'setup.cfg') {
|
|
292
|
+
result = parseSetupCfg(content);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!result) continue;
|
|
296
|
+
|
|
297
|
+
if (result.version === null && result.specifier === null) {
|
|
298
|
+
findings.push(directDependencyUnpinnedFinding());
|
|
299
|
+
} else if (isVulnerable(result.version)) {
|
|
300
|
+
findings.push(directDependencyFinding(result.version, result.specifier || 'unknown'));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return findings;
|
|
305
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { transitiveDependencyFinding } from './findings.js';
|
|
2
|
+
import { parseRequirementsTxt, parsePyprojectToml, parsePoetryLock, parsePipfile, parseSetupPy, parseSetupCfg } from './manifest.js';
|
|
3
|
+
|
|
4
|
+
const TIER_1_PACKAGES = [
|
|
5
|
+
'fastapi',
|
|
6
|
+
'vllm',
|
|
7
|
+
'litellm',
|
|
8
|
+
'bentoml',
|
|
9
|
+
'text-generation-inference',
|
|
10
|
+
'ray-serve',
|
|
11
|
+
'ray[serve]',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const TIER_2_PACKAGES = [
|
|
15
|
+
'langserve',
|
|
16
|
+
'fastapi-mcp',
|
|
17
|
+
'mcp',
|
|
18
|
+
'starlette-admin',
|
|
19
|
+
'piccolo-api',
|
|
20
|
+
'fastapi-users',
|
|
21
|
+
'broadcaster',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function normalizePkgName(name) {
|
|
25
|
+
return name.replace(/["'\[\]]/g, '').trim().toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function findPackagesInManifests(allFiles) {
|
|
29
|
+
const packages = new Set();
|
|
30
|
+
|
|
31
|
+
for (const file of (allFiles || [])) {
|
|
32
|
+
const content = typeof file.content === 'string' ? file.content : '';
|
|
33
|
+
if (!content) continue;
|
|
34
|
+
const path = file.path || '';
|
|
35
|
+
|
|
36
|
+
let deps = [];
|
|
37
|
+
|
|
38
|
+
if (path === 'requirements.txt' || path.match(/^requirements\/.*\.txt$/)) {
|
|
39
|
+
const lines = content.split('\n');
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) continue;
|
|
43
|
+
const idx = trimmed.indexOf('#');
|
|
44
|
+
const spec = idx >= 0 ? trimmed.slice(0, idx).trim() : trimmed;
|
|
45
|
+
const eqIdx = spec.indexOf('==');
|
|
46
|
+
const geIdx = spec.indexOf('>=');
|
|
47
|
+
const name = eqIdx >= 0 ? spec.slice(0, eqIdx).trim() : (geIdx >= 0 ? spec.slice(0, geIdx).trim() : spec);
|
|
48
|
+
if (name && !name.includes('=') && !name.includes('<') && !name.includes('>')) {
|
|
49
|
+
deps.push(normalizePkgName(name));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} else if (path === 'pyproject.toml') {
|
|
53
|
+
try {
|
|
54
|
+
const obj = JSON.parse(content);
|
|
55
|
+
const allDeps = { ...(obj?.tool?.poetry?.dependencies || {}), ...(obj?.dependencies || {}), ...(obj?.['dev-dependencies'] || {}) };
|
|
56
|
+
for (const key of Object.keys(allDeps)) {
|
|
57
|
+
deps.push(normalizePkgName(key));
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
} else if (path === 'poetry.lock') {
|
|
61
|
+
const pattern = /name\s*=\s*["']([^"']+)["']/g;
|
|
62
|
+
let m;
|
|
63
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
64
|
+
deps.push(normalizePkgName(m[1]));
|
|
65
|
+
}
|
|
66
|
+
} else if (path === 'Pipfile' || path === 'Pipfile.lock') {
|
|
67
|
+
try {
|
|
68
|
+
const obj = JSON.parse(content);
|
|
69
|
+
for (const key of Object.keys(obj?.packages || {})) {
|
|
70
|
+
deps.push(normalizePkgName(key));
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
for (const dep of deps) packages.add(dep);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return packages;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasStarlettePin(allFiles) {
|
|
81
|
+
for (const file of (allFiles || [])) {
|
|
82
|
+
const content = typeof file.content === 'string' ? file.content : '';
|
|
83
|
+
if (!content) continue;
|
|
84
|
+
const path = file.path || '';
|
|
85
|
+
|
|
86
|
+
let result = null;
|
|
87
|
+
if (path === 'requirements.txt' || path.match(/^requirements\/.*\.txt$/)) {
|
|
88
|
+
result = parseRequirementsTxt(content);
|
|
89
|
+
} else if (path === 'pyproject.toml') {
|
|
90
|
+
result = parsePyprojectToml(content);
|
|
91
|
+
} else if (path === 'poetry.lock') {
|
|
92
|
+
result = parsePoetryLock(content);
|
|
93
|
+
} else if (path === 'Pipfile' || path === 'Pipfile.lock') {
|
|
94
|
+
result = parsePipfile(content);
|
|
95
|
+
} else if (path === 'setup.py') {
|
|
96
|
+
result = parseSetupPy(content);
|
|
97
|
+
} else if (path === 'setup.cfg') {
|
|
98
|
+
result = parseSetupCfg(content);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (result) {
|
|
102
|
+
if (result.version === null && result.specifier === null) return false;
|
|
103
|
+
const parsed = parsePEP440(result.version);
|
|
104
|
+
const safe = parsePEP440('1.0.1');
|
|
105
|
+
if (parsed && compareVersions(parsed, safe) >= 0) return true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parsePEP440(versionStr) {
|
|
113
|
+
if (!versionStr) return null;
|
|
114
|
+
const clean = versionStr.trim().replace(/^v/, '');
|
|
115
|
+
const parts = clean.split('.');
|
|
116
|
+
return {
|
|
117
|
+
major: parseInt(parts[0], 10) || 0,
|
|
118
|
+
minor: parseInt(parts[1], 10) || 0,
|
|
119
|
+
patch: parseInt(parts[2], 10) || 0,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function compareVersions(a, b) {
|
|
124
|
+
if (!a) return 1;
|
|
125
|
+
if (!b) return -1;
|
|
126
|
+
if (a.major !== b.major) return a.major - b.major;
|
|
127
|
+
if (a.minor !== b.minor) return a.minor - b.minor;
|
|
128
|
+
return a.patch - b.patch;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function scanTransitive(allFiles) {
|
|
132
|
+
const findings = [];
|
|
133
|
+
|
|
134
|
+
if (!allFiles || allFiles.length === 0) return findings;
|
|
135
|
+
|
|
136
|
+
const packages = findPackagesInManifests(allFiles);
|
|
137
|
+
|
|
138
|
+
if (hasStarlettePin(allFiles)) return findings;
|
|
139
|
+
|
|
140
|
+
const handled = new Set();
|
|
141
|
+
|
|
142
|
+
for (const pkg of packages) {
|
|
143
|
+
if (TIER_1_PACKAGES.includes(pkg)) {
|
|
144
|
+
handled.add(pkg);
|
|
145
|
+
if (pkg === 'fastapi') {
|
|
146
|
+
const version = findFastapiVersion(allFiles);
|
|
147
|
+
if (version) {
|
|
148
|
+
const parsed = parsePEP440(version);
|
|
149
|
+
const safeFastapi = parsePEP440('0.116.0');
|
|
150
|
+
if (parsed && compareVersions(parsed, safeFastapi) >= 0) continue;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
findings.push(transitiveDependencyFinding(pkg, 1));
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (findings.length === 0) {
|
|
159
|
+
for (const pkg of packages) {
|
|
160
|
+
if (handled.has(pkg)) continue;
|
|
161
|
+
if (TIER_2_PACKAGES.includes(pkg)) {
|
|
162
|
+
findings.push(transitiveDependencyFinding(pkg, 2));
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return findings;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function findFastapiVersion(allFiles) {
|
|
172
|
+
for (const file of (allFiles || [])) {
|
|
173
|
+
const content = typeof file.content === 'string' ? file.content : '';
|
|
174
|
+
if (!content) continue;
|
|
175
|
+
const path = file.path || '';
|
|
176
|
+
if (path === 'requirements.txt' || path.match(/^requirements\/.*\.txt$/)) {
|
|
177
|
+
const lines = content.split('\n');
|
|
178
|
+
for (const line of lines) {
|
|
179
|
+
const trimmed = line.trim();
|
|
180
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) continue;
|
|
181
|
+
if (trimmed.startsWith('fastapi')) {
|
|
182
|
+
const eqIdx = trimmed.indexOf('==');
|
|
183
|
+
if (eqIdx >= 0) return trimmed.slice(eqIdx + 2).trim();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
@@ -12,6 +12,7 @@ import * as atk011 from './atk-011-transitive-prop.js';
|
|
|
12
12
|
import { scanAll as megalodonScan } from './megalodon/index.js';
|
|
13
13
|
import { scan as hfScan } from './hf-impersonation/index.js';
|
|
14
14
|
import { scan as miniShaiHuludScan } from './mini-shai-hulud/index.js';
|
|
15
|
+
import { scan as badhostScan } from './cve-2026-48710-badhost/index.js';
|
|
15
16
|
|
|
16
17
|
export async function runAll(pkgJson, files = [], registryMeta = null, allFiles = null) {
|
|
17
18
|
const findings = [];
|
|
@@ -29,5 +30,6 @@ export async function runAll(pkgJson, files = [], registryMeta = null, allFiles
|
|
|
29
30
|
findings.push(...await megalodonScan(pkgJson, allFiles || files, registryMeta));
|
|
30
31
|
findings.push(...await hfScan(pkgJson, files, registryMeta, allFiles || files));
|
|
31
32
|
findings.push(...await miniShaiHuludScan(pkgJson, files, registryMeta, allFiles || files));
|
|
33
|
+
findings.push(...await badhostScan(pkgJson, files, registryMeta, allFiles || files));
|
|
32
34
|
return findings.sort((a, b) => b.severity.localeCompare(a.severity));
|
|
33
35
|
}
|
|
@@ -43,7 +43,7 @@ export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, ti
|
|
|
43
43
|
|
|
44
44
|
for (const waveKey of Object.keys(data.waves || {})) {
|
|
45
45
|
const wave = data.waves[waveKey];
|
|
46
|
-
const waveNum = waveKey === 'wave1' ? 1 : 2;
|
|
46
|
+
const waveNum = waveKey === 'wave1' ? 1 : waveKey === 'wave2' ? 2 : 3;
|
|
47
47
|
for (const ioc of (wave.iocs || [])) {
|
|
48
48
|
allIOCs.push({ ...ioc, wave: waveNum });
|
|
49
49
|
}
|
|
@@ -28,6 +28,8 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
28
28
|
slsaResult = await checkSlsaMismatch(pkgName, pkgVersion, burstResult, timeMap, config);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
const nxDownstreamResult = checkNxConsoleDownstream(pkgJson, allFiles || files);
|
|
32
|
+
|
|
31
33
|
const triggeredChecks = [];
|
|
32
34
|
if (burstResult.triggered) triggeredChecks.push('D1_BURST');
|
|
33
35
|
if (siblingResult.triggered) triggeredChecks.push('D2_SIBLING');
|
|
@@ -35,6 +37,7 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
35
37
|
if (maintainerResult.triggered) triggeredChecks.push('D4_MAINTAINER');
|
|
36
38
|
if (iocResult.triggered) triggeredChecks.push('D5_IOC');
|
|
37
39
|
if (exfilResult.triggered) triggeredChecks.push('D6_EXFIL');
|
|
40
|
+
if (nxDownstreamResult.triggered) triggeredChecks.push('D7_NX_CONSOLE');
|
|
38
41
|
|
|
39
42
|
if (triggeredChecks.length === 0) return [];
|
|
40
43
|
|
|
@@ -43,14 +46,16 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
43
46
|
waveAttribution = 'wave1-tanstack';
|
|
44
47
|
} else if (pkgName.startsWith('@antv')) {
|
|
45
48
|
waveAttribution = 'wave2-antv';
|
|
49
|
+
} else if (nxDownstreamResult.triggered) {
|
|
50
|
+
waveAttribution = 'wave3-nx-console';
|
|
46
51
|
} else if (iocResult.matches && iocResult.matches.length > 0) {
|
|
47
52
|
const waves = [...new Set(iocResult.matches.map(m => m.wave))];
|
|
48
53
|
if (waves.length === 1) {
|
|
49
|
-
waveAttribution = waves[0] === 1 ? 'wave1-tanstack' : 'wave2-antv';
|
|
54
|
+
waveAttribution = waves[0] === 1 ? 'wave1-tanstack' : waves[0] === 2 ? 'wave2-antv' : 'wave3-nx-console';
|
|
50
55
|
}
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
const isCritical = slsaResult.triggered || iocResult.triggered;
|
|
58
|
+
const isCritical = slsaResult.triggered || iocResult.triggered || nxDownstreamResult.triggered;
|
|
54
59
|
|
|
55
60
|
const evidence = {
|
|
56
61
|
campaign: 'MINI_SHAI_HULUD',
|
|
@@ -65,6 +70,9 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
65
70
|
attestationAnomalies: slsaResult.triggered ? slsaResult.anomalies : null,
|
|
66
71
|
iocMatches: iocResult.triggered ? iocResult.matches : null,
|
|
67
72
|
installScriptSnippets: exfilResult.triggered ? exfilResult.snippets : null,
|
|
73
|
+
nxConsoleDownstream: nxDownstreamResult.triggered
|
|
74
|
+
? { nxDeps: nxDownstreamResult.nxDeps, vsCodeExtensions: nxDownstreamResult.vsCodeExtensions }
|
|
75
|
+
: null,
|
|
68
76
|
};
|
|
69
77
|
|
|
70
78
|
return [{
|
|
@@ -73,8 +81,38 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
73
81
|
title: 'Mini Shai-Hulud worm campaign',
|
|
74
82
|
description: `${triggeredChecks.length} signal(s): ${triggeredChecks.join(', ')}`,
|
|
75
83
|
evidence: JSON.stringify(evidence),
|
|
76
|
-
mitigation: 'Revoke all npm tokens immediately. Rotate CI/CD secrets. Audit maintainer access on all scoped packages. Review recent version publish history for anomalous bursts. Check for postinstall scripts accessing credentials or environment variables. If Wave 1 (TanStack scope): inspect GitHub Actions workflow logs for unauthorized build steps. If Wave 2 (atool/AntV scope): rotate all npm tokens associated with @antv/* packages.',
|
|
84
|
+
mitigation: 'Revoke all npm tokens immediately. Rotate CI/CD secrets. Audit maintainer access on all scoped packages. Review recent version publish history for anomalous bursts. Check for postinstall scripts accessing credentials or environment variables. If Wave 1 (TanStack scope): inspect GitHub Actions workflow logs for unauthorized build steps. If Wave 2 (atool/AntV scope): rotate all npm tokens associated with @antv/* packages. If Wave 3 (Nx Console): remove nrwl.angular-console extension immediately, revoke all npm tokens used in CI/CD, and audit @nx/* dependency versions.',
|
|
77
85
|
}];
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
function checkNxConsoleDownstream(pkgJson, allFiles) {
|
|
89
|
+
const deps = { ...pkgJson?.dependencies, ...pkgJson?.devDependencies, ...pkgJson?.peerDependencies };
|
|
90
|
+
const nxDeps = Object.keys(deps).filter(d => d.startsWith('@nx/') || d.startsWith('nrwl/'));
|
|
91
|
+
if (nxDeps.length === 0) return { triggered: false, nxDeps: [], vsCodeExtensions: [] };
|
|
92
|
+
|
|
93
|
+
let vsCodeExtensions = [];
|
|
94
|
+
if (allFiles && Array.isArray(allFiles)) {
|
|
95
|
+
for (const file of allFiles) {
|
|
96
|
+
if (file.path && (file.path.endsWith('.vscode/extensions.json') || file.path.endsWith('.vscode/extensions.json'))) {
|
|
97
|
+
try {
|
|
98
|
+
const content = typeof file.content === 'string' ? file.content : '';
|
|
99
|
+
const parsed = JSON.parse(content);
|
|
100
|
+
const allExts = [
|
|
101
|
+
...(parsed.recommendations || []),
|
|
102
|
+
...(parsed.unwantedRecommendations || []),
|
|
103
|
+
];
|
|
104
|
+
const matched = allExts.filter(e => e.includes('nrwl.angular-console'));
|
|
105
|
+
if (matched.length > 0) {
|
|
106
|
+
vsCodeExtensions = matched;
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// non-JSON extensions.json, skip
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { triggered: true, nxDeps, vsCodeExtensions };
|
|
116
|
+
}
|
|
117
|
+
|
|
80
118
|
export { clearSiblingCache } from './d2-sibling-compromise.js';
|
|
@@ -33,6 +33,38 @@
|
|
|
33
33
|
"notes": "Blast radius: @antv/g2, @antv/g6, @antv/x6, @antv/l7, echarts-for-react, timeago.js. Seed IOC — update from threat intel."
|
|
34
34
|
}
|
|
35
35
|
]
|
|
36
|
+
},
|
|
37
|
+
"wave3": {
|
|
38
|
+
"id": "nx-console-wave3",
|
|
39
|
+
"description": "Nx Console 18.95.0 VS Code extension compromise (May 18, 2026, CVE-2026-48027, TeamPCP) — contributor token stolen via TanStack wave1 (May 11), 7-day dwell, malicious extension published using npx to fetch 498KB obfuscated Bun payload from dangling orphan commit on nrwl/nx repo. ~3M installs exposed.",
|
|
40
|
+
"windowMinutes": 36,
|
|
41
|
+
"iocs": [
|
|
42
|
+
{
|
|
43
|
+
"type": "extensionId",
|
|
44
|
+
"value": "nrwl.angular-console",
|
|
45
|
+
"maliciousVersionRanges": ["18.95.0"],
|
|
46
|
+
"notes": "Nx Console v18.95.0 — malicious VS Code extension. CVE-2026-48027. Exposure window: 11 min on Marketplace, 36 min on Open VSX."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"type": "publisherAccount",
|
|
50
|
+
"value": "nrwl",
|
|
51
|
+
"compromiseWindowStart": "2026-05-11T00:00:00.000Z",
|
|
52
|
+
"compromiseWindowEnd": "2026-05-18T13:09:00.000Z",
|
|
53
|
+
"notes": "Nx contributor token stolen via TanStack wave1 on May 11; 7-day dwell before publishing malicious extension on May 18."
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"type": "packageScope",
|
|
57
|
+
"value": "@nx",
|
|
58
|
+
"maliciousVersionRanges": [],
|
|
59
|
+
"notes": "NX_CONSOLE_DOWNSTREAM: npm packages under @nx scope deployed by compromised Nx contributor. Check for versions published within 7 days of 2026-05-18."
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"type": "packageScope",
|
|
63
|
+
"value": "nrwl",
|
|
64
|
+
"maliciousVersionRanges": [],
|
|
65
|
+
"notes": "NX_CONSOLE_DOWNSTREAM: nrwl-scoped npm packages — monitor for anomalous burst publishing."
|
|
66
|
+
}
|
|
67
|
+
]
|
|
36
68
|
}
|
|
37
69
|
},
|
|
38
70
|
"iocs": [
|