@lateos/npm-scan 0.18.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +265 -233
  2. package/LICENSING.md +19 -19
  3. package/README.de.md +708 -708
  4. package/README.fr.md +707 -707
  5. package/README.ja.md +704 -704
  6. package/README.md +861 -826
  7. package/README.zh.md +708 -708
  8. package/VALIDATION.md +92 -0
  9. package/backend/cra.js +68 -68
  10. package/backend/db/pg-schema.sql +155 -0
  11. package/backend/db/schema.sql +32 -32
  12. package/backend/db.js +88 -88
  13. package/backend/detectors/atk-001-lifecycle.js +17 -17
  14. package/backend/detectors/atk-002-obfusc.js +261 -261
  15. package/backend/detectors/atk-003-creds.js +13 -13
  16. package/backend/detectors/atk-004-persist.js +13 -13
  17. package/backend/detectors/atk-005-exfil.js +13 -13
  18. package/backend/detectors/atk-006-depconf.js +14 -14
  19. package/backend/detectors/atk-007-typosquat.js +34 -34
  20. package/backend/detectors/atk-008-tarball-tamper.js +91 -91
  21. package/backend/detectors/atk-009-dormant-trigger.js +62 -62
  22. package/backend/detectors/atk-010-sandbox-evasion.js +50 -50
  23. package/backend/detectors/atk-011-transitive-prop.js +76 -76
  24. package/backend/detectors/config/thresholds.js +66 -0
  25. package/backend/detectors/config/whitelist.json +74 -0
  26. package/backend/detectors/cve-2026-48710-badhost/codePattern.js +99 -99
  27. package/backend/detectors/cve-2026-48710-badhost/findings.js +105 -105
  28. package/backend/detectors/cve-2026-48710-badhost/index.js +15 -15
  29. package/backend/detectors/cve-2026-48710-badhost/manifest.js +305 -305
  30. package/backend/detectors/cve-2026-48710-badhost/transitive.js +189 -189
  31. package/backend/detectors/hf-impersonation/index.js +396 -396
  32. package/backend/detectors/hf-impersonation/jaro-winkler.js +44 -44
  33. package/backend/detectors/hf-impersonation/known-orgs.js +5 -5
  34. package/backend/detectors/hf-impersonation/simhash.js +46 -46
  35. package/backend/detectors/index.js +87 -81
  36. package/backend/detectors/lib/ast-patterns.js +21 -0
  37. package/backend/detectors/lib/entropy-analyzer.js +24 -0
  38. package/backend/detectors/megalodon/d1-workflow-scan.js +147 -147
  39. package/backend/detectors/megalodon/d2-credential-harvest.js +61 -61
  40. package/backend/detectors/megalodon/d3-publish-velocity.js +67 -67
  41. package/backend/detectors/megalodon/d4-publisher-drift.js +124 -124
  42. package/backend/detectors/megalodon/d5-bot-commit-identity.js +3 -3
  43. package/backend/detectors/megalodon/d6-date-anachronism.js +3 -3
  44. package/backend/detectors/megalodon/index.js +80 -80
  45. package/backend/detectors/megalodon/types.js +9 -9
  46. package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -42
  47. package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -116
  48. package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -72
  49. package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -45
  50. package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -95
  51. package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -38
  52. package/backend/detectors/mini-shai-hulud/index.js +118 -118
  53. package/backend/detectors/mini-shai-hulud/iocs.json +79 -79
  54. package/backend/detectors/tier1-binary-embed.js +34 -5
  55. package/backend/detectors/tier1-obfuscation-heuristics.js +156 -0
  56. package/backend/detectors/tier1-slsa-attestation.js +12 -0
  57. package/backend/detectors/tier1-version-anomaly.js +187 -0
  58. package/backend/detectors.test.js +88 -0
  59. package/backend/fetch.js +175 -175
  60. package/backend/index.js +4 -4
  61. package/backend/license.js +89 -89
  62. package/backend/lockfile.js +379 -379
  63. package/backend/pdf.js +245 -245
  64. package/backend/policy.js +193 -193
  65. package/backend/report.js +254 -254
  66. package/backend/sbom.js +66 -66
  67. package/backend/scripts/analyze-false-positives.js +146 -0
  68. package/backend/scripts/analyze-validation.js +151 -0
  69. package/backend/scripts/detect-false-positives.js +93 -0
  70. package/backend/scripts/fetch-top-packages.js +129 -0
  71. package/backend/scripts/validate-detectors.js +142 -0
  72. package/backend/siem/cef.js +32 -32
  73. package/backend/siem/ecs.js +40 -40
  74. package/backend/siem/index.js +18 -18
  75. package/backend/siem/qradar.js +56 -56
  76. package/backend/siem/sentinel.js +27 -27
  77. package/backend/tests-d5-enhanced.test.js +46 -0
  78. package/backend/tests-d6-version-anomaly.test.js +58 -0
  79. package/backend/tests-d6.test.js +116 -0
  80. package/backend/tests-d6c.test.js +106 -0
  81. package/backend/tests-d7-obfuscation.test.js +91 -0
  82. package/backend/tests.test.js +898 -0
  83. package/backend/vsix-scan/detectors/activation-event-risk.js +116 -116
  84. package/backend/vsix-scan/detectors/burst-publish.js +52 -52
  85. package/backend/vsix-scan/detectors/exfil-pattern.js +88 -88
  86. package/backend/vsix-scan/detectors/known-ioc.js +105 -105
  87. package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -69
  88. package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -70
  89. package/backend/vsix-scan/index.js +183 -183
  90. package/backend/vsix-scan/marketplace-client.js +145 -145
  91. package/backend/vsix-scan/vsix-iocs.json +31 -31
  92. package/cli/cli.js +458 -458
  93. package/package.json +74 -57
  94. package/.dockerignore +0 -20
  95. package/.husky/pre-commit +0 -1
  96. package/SECURITY.md +0 -73
  97. package/deploy/helm/npm-scan/Chart.yaml +0 -22
  98. package/deploy/helm/npm-scan/templates/_helpers.tpl +0 -9
  99. package/deploy/helm/npm-scan/templates/api.yaml +0 -94
  100. package/deploy/helm/npm-scan/templates/ingress.yaml +0 -28
  101. package/deploy/helm/npm-scan/templates/postgresql.yaml +0 -67
  102. package/deploy/helm/npm-scan/templates/secrets.yaml +0 -19
  103. package/deploy/helm/npm-scan/templates/worker.yaml +0 -32
  104. package/deploy/helm/npm-scan/values.byoc.yaml +0 -75
  105. package/deploy/helm/npm-scan/values.yaml +0 -103
  106. package/scripts/download-corpus.js +0 -30
  107. package/scripts/gen-mal-corpus.js +0 -35
  108. package/scripts/generate-campaign-fixtures.js +0 -170
  109. package/src/config/top-5000.json +0 -87
  110. package/test/fixtures/lockfiles/npm-lock.json +0 -69
  111. package/test/fixtures/lockfiles/pnpm-lock.yaml +0 -118
  112. package/test/fixtures/lockfiles/yarn.lock +0 -104
  113. package/test/fixtures/mock-data.js +0 -69
@@ -1,305 +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
- }
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
+ }