cleartoship 0.4.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 (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/action.yml +154 -0
  4. package/dist/banner.d.ts +1 -0
  5. package/dist/banner.js +14 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +103 -0
  8. package/dist/data/popular.d.ts +17 -0
  9. package/dist/data/popular.js +103 -0
  10. package/dist/index.d.ts +5 -0
  11. package/dist/index.js +4 -0
  12. package/dist/report.d.ts +15 -0
  13. package/dist/report.js +317 -0
  14. package/dist/scan.d.ts +24 -0
  15. package/dist/scan.js +81 -0
  16. package/dist/scanners/community.d.ts +2 -0
  17. package/dist/scanners/community.js +155 -0
  18. package/dist/scanners/dependencies.d.ts +21 -0
  19. package/dist/scanners/dependencies.js +549 -0
  20. package/dist/scanners/index.d.ts +8 -0
  21. package/dist/scanners/index.js +13 -0
  22. package/dist/scanners/rls.d.ts +7 -0
  23. package/dist/scanners/rls.js +531 -0
  24. package/dist/scanners/secrets.d.ts +2 -0
  25. package/dist/scanners/secrets.js +439 -0
  26. package/dist/scanners/server-actions.d.ts +2 -0
  27. package/dist/scanners/server-actions.js +466 -0
  28. package/dist/types.d.ts +65 -0
  29. package/dist/types.js +10 -0
  30. package/dist/utils/ast.d.ts +13 -0
  31. package/dist/utils/ast.js +107 -0
  32. package/dist/utils/detect.d.ts +2 -0
  33. package/dist/utils/detect.js +77 -0
  34. package/dist/utils/entropy.d.ts +7 -0
  35. package/dist/utils/entropy.js +19 -0
  36. package/dist/utils/files.d.ts +13 -0
  37. package/dist/utils/files.js +154 -0
  38. package/dist/utils/osv.d.ts +26 -0
  39. package/dist/utils/osv.js +123 -0
  40. package/dist/utils/registry.d.ts +44 -0
  41. package/dist/utils/registry.js +195 -0
  42. package/dist/utils/sql.d.ts +20 -0
  43. package/dist/utils/sql.js +157 -0
  44. package/dist/utils/suppress.d.ts +19 -0
  45. package/dist/utils/suppress.js +56 -0
  46. package/dist/utils/traverse.d.ts +2 -0
  47. package/dist/utils/traverse.js +14 -0
  48. package/dist/vendor/gitleaks/rules.d.ts +13 -0
  49. package/dist/vendor/gitleaks/rules.js +1571 -0
  50. package/dist/vendor/guardvibe/index.d.ts +11 -0
  51. package/dist/vendor/guardvibe/index.js +60 -0
  52. package/dist/vendor/guardvibe/rules/advanced-security.d.ts +2 -0
  53. package/dist/vendor/guardvibe/rules/advanced-security.js +418 -0
  54. package/dist/vendor/guardvibe/rules/ai-host-security.d.ts +2 -0
  55. package/dist/vendor/guardvibe/rules/ai-host-security.js +190 -0
  56. package/dist/vendor/guardvibe/rules/ai-security.d.ts +2 -0
  57. package/dist/vendor/guardvibe/rules/ai-security.js +413 -0
  58. package/dist/vendor/guardvibe/rules/ai-tool-runtime.d.ts +2 -0
  59. package/dist/vendor/guardvibe/rules/ai-tool-runtime.js +194 -0
  60. package/dist/vendor/guardvibe/rules/api-security.d.ts +2 -0
  61. package/dist/vendor/guardvibe/rules/api-security.js +154 -0
  62. package/dist/vendor/guardvibe/rules/auth.d.ts +2 -0
  63. package/dist/vendor/guardvibe/rules/auth.js +222 -0
  64. package/dist/vendor/guardvibe/rules/cicd.d.ts +2 -0
  65. package/dist/vendor/guardvibe/rules/cicd.js +108 -0
  66. package/dist/vendor/guardvibe/rules/core.d.ts +2 -0
  67. package/dist/vendor/guardvibe/rules/core.js +484 -0
  68. package/dist/vendor/guardvibe/rules/cve-versions.d.ts +2 -0
  69. package/dist/vendor/guardvibe/rules/cve-versions.js +1130 -0
  70. package/dist/vendor/guardvibe/rules/database.d.ts +2 -0
  71. package/dist/vendor/guardvibe/rules/database.js +173 -0
  72. package/dist/vendor/guardvibe/rules/deployment.d.ts +2 -0
  73. package/dist/vendor/guardvibe/rules/deployment.js +269 -0
  74. package/dist/vendor/guardvibe/rules/dockerfile.d.ts +2 -0
  75. package/dist/vendor/guardvibe/rules/dockerfile.js +99 -0
  76. package/dist/vendor/guardvibe/rules/firebase.d.ts +2 -0
  77. package/dist/vendor/guardvibe/rules/firebase.js +96 -0
  78. package/dist/vendor/guardvibe/rules/go.d.ts +2 -0
  79. package/dist/vendor/guardvibe/rules/go.js +85 -0
  80. package/dist/vendor/guardvibe/rules/modern-stack.d.ts +2 -0
  81. package/dist/vendor/guardvibe/rules/modern-stack.js +631 -0
  82. package/dist/vendor/guardvibe/rules/nextjs.d.ts +2 -0
  83. package/dist/vendor/guardvibe/rules/nextjs.js +233 -0
  84. package/dist/vendor/guardvibe/rules/other-services.d.ts +2 -0
  85. package/dist/vendor/guardvibe/rules/other-services.js +72 -0
  86. package/dist/vendor/guardvibe/rules/payments.d.ts +2 -0
  87. package/dist/vendor/guardvibe/rules/payments.js +123 -0
  88. package/dist/vendor/guardvibe/rules/react-native.d.ts +2 -0
  89. package/dist/vendor/guardvibe/rules/react-native.js +132 -0
  90. package/dist/vendor/guardvibe/rules/services.d.ts +2 -0
  91. package/dist/vendor/guardvibe/rules/services.js +149 -0
  92. package/dist/vendor/guardvibe/rules/shell.d.ts +2 -0
  93. package/dist/vendor/guardvibe/rules/shell.js +72 -0
  94. package/dist/vendor/guardvibe/rules/sql.d.ts +2 -0
  95. package/dist/vendor/guardvibe/rules/sql.js +60 -0
  96. package/dist/vendor/guardvibe/rules/supply-chain.d.ts +2 -0
  97. package/dist/vendor/guardvibe/rules/supply-chain.js +243 -0
  98. package/dist/vendor/guardvibe/rules/terraform.d.ts +2 -0
  99. package/dist/vendor/guardvibe/rules/terraform.js +84 -0
  100. package/dist/vendor/guardvibe/rules/types.d.ts +14 -0
  101. package/dist/vendor/guardvibe/rules/types.js +11 -0
  102. package/dist/vendor/guardvibe/rules/web-security.d.ts +2 -0
  103. package/dist/vendor/guardvibe/rules/web-security.js +258 -0
  104. package/examples/security.yml +23 -0
  105. package/package.json +70 -0
@@ -0,0 +1,549 @@
1
+ import { basename, join } from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+ import { read, rel, isProse, lineAt } from '../utils/files.js';
4
+ import { Registry, pool } from '../utils/registry.js';
5
+ import { queryOsv, severityFromCvss } from '../utils/osv.js';
6
+ import { POPULAR_NPM, POPULAR_PYPI, nearestPopular } from '../data/popular.js';
7
+ import { emptyResult } from '../types.js';
8
+ const NEW_PACKAGE_DAYS = 60;
9
+ const LOW_DOWNLOADS = 250;
10
+ const VERY_LOW_DOWNLOADS = 50;
11
+ function collectFromPackageJson(source, relPath) {
12
+ let pkg;
13
+ try {
14
+ pkg = JSON.parse(source);
15
+ }
16
+ catch {
17
+ return [];
18
+ }
19
+ const lines = source.split('\n');
20
+ const lineOf = (name) => {
21
+ const needle = `"${name}"`;
22
+ for (let i = 0; i < lines.length; i++) {
23
+ if (lines[i].includes(needle))
24
+ return i + 1;
25
+ }
26
+ return 1;
27
+ };
28
+ const out = [];
29
+ for (const [field, dev] of [
30
+ ['dependencies', false],
31
+ ['devDependencies', true],
32
+ ['optionalDependencies', true],
33
+ ['peerDependencies', true],
34
+ ]) {
35
+ const block = pkg?.[field];
36
+ if (!block || typeof block !== 'object')
37
+ continue;
38
+ for (const [name, range] of Object.entries(block)) {
39
+ if (typeof range !== 'string')
40
+ continue;
41
+ out.push({ name, range, ecosystem: 'npm', file: relPath, line: lineOf(name), dev });
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+ function collectFromRequirements(source, relPath) {
47
+ const out = [];
48
+ source.split('\n').forEach((raw, i) => {
49
+ const line = raw.split('#')[0].trim();
50
+ if (!line || line.startsWith('-'))
51
+ return;
52
+ const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*(.*)$/.exec(line);
53
+ if (!m)
54
+ return;
55
+ out.push({
56
+ name: m[1],
57
+ range: (m[3] ?? '').trim(),
58
+ ecosystem: 'pypi',
59
+ file: relPath,
60
+ line: i + 1,
61
+ dev: false,
62
+ });
63
+ });
64
+ return out;
65
+ }
66
+ function collectFromPyproject(source, relPath) {
67
+ const out = [];
68
+ const lines = source.split('\n');
69
+ let inDeps = false;
70
+ lines.forEach((raw, i) => {
71
+ const line = raw.trim();
72
+ if (/^\[/.test(line)) {
73
+ inDeps = /\[(tool\.poetry\.(dev-)?dependencies|project\.optional-dependencies)\]/.test(line);
74
+ return;
75
+ }
76
+ if (/^dependencies\s*=\s*\[/.test(line))
77
+ inDeps = true;
78
+ if (!inDeps)
79
+ return;
80
+ const quoted = /^["']([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(line.replace(/^\s*["']?/, (m0) => m0));
81
+ const poetry = /^([A-Za-z0-9][A-Za-z0-9._-]*)\s*=/.exec(line);
82
+ const strItem = /["']([A-Za-z0-9][A-Za-z0-9._-]*)\s*[<>=!~ ]*[^"']*["']/.exec(line);
83
+ const name = poetry?.[1] ?? strItem?.[1] ?? quoted?.[1];
84
+ if (!name || name === 'python')
85
+ return;
86
+ out.push({ name, range: '', ecosystem: 'pypi', file: relPath, line: i + 1, dev: false });
87
+ });
88
+ return out;
89
+ }
90
+ /** Valid npm package name, optionally scoped. */
91
+ const NPM_NAME = String.raw `(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*`;
92
+ const INSTALL_COMMAND = new RegExp(String.raw `\b(?:npm\s+(?:i|install|add)|yarn\s+add|pnpm\s+(?:i|install|add)|bun\s+(?:i|install|add)|npx|pnpm\s+dlx|bunx)\s+([^\n\`|;&>]+)`, 'gi');
93
+ const PIP_COMMAND = /\b(?:pip3?\s+install|uv\s+pip\s+install|poetry\s+add|uv\s+add)\s+([^\n`|;&>]+)/gi;
94
+ /**
95
+ * Pulls package names out of install commands written in prose. Agent
96
+ * instruction files and READMEs are where a hallucinated name is copy-pasted
97
+ * from long before anyone adds it to a manifest, so they are worth reading.
98
+ */
99
+ function collectFromProse(source, relPath) {
100
+ const out = [];
101
+ const npmName = new RegExp(`^${NPM_NAME}$`);
102
+ const harvest = (re, ecosystem, firstArgOnly) => {
103
+ re.lastIndex = 0;
104
+ let m;
105
+ while ((m = re.exec(source)) !== null) {
106
+ const args = m[1].trim().split(/\s+/);
107
+ const line = lineAt(source, m.index);
108
+ for (const arg of args) {
109
+ if (arg.startsWith('-'))
110
+ continue; // flag
111
+ // Strip a version spec, but not a scope: `@types/node` vs `react@18`.
112
+ const bare = arg.replace(/(?!^)@[^@/]*$/, '').replace(/\[.*\]$/, '');
113
+ if (!bare || bare.includes('/') && !bare.startsWith('@'))
114
+ continue; // path or URL
115
+ if (/^[.~/]|:/.test(bare))
116
+ continue;
117
+ const ok = ecosystem === 'npm' ? npmName.test(bare) : /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(bare);
118
+ if (!ok)
119
+ continue;
120
+ out.push({ name: bare, range: '', ecosystem, file: relPath, line, dev: false, fromProse: true });
121
+ if (firstArgOnly)
122
+ break;
123
+ }
124
+ }
125
+ };
126
+ harvest(INSTALL_COMMAND, 'npm', false);
127
+ harvest(PIP_COMMAND, 'pypi', false);
128
+ return out;
129
+ }
130
+ /** Lifecycle scripts that run automatically on `npm install`. */
131
+ const INSTALL_HOOKS = ['preinstall', 'install', 'postinstall', 'prepare', 'prepublish'];
132
+ const DANGEROUS_SCRIPT = /\b(curl|wget|https?:\/\/|base64\s+(-d|--decode)|eval\s|node\s+-e|child_process|\|\s*(sh|bash)\b|chmod\s+\+x)/i;
133
+ function collectInstallScriptFindings(source, relPath) {
134
+ let pkg;
135
+ try {
136
+ pkg = JSON.parse(source);
137
+ }
138
+ catch {
139
+ return [];
140
+ }
141
+ const findings = [];
142
+ const lines = source.split('\n');
143
+ for (const hook of INSTALL_HOOKS) {
144
+ const script = pkg?.scripts?.[hook];
145
+ if (typeof script !== 'string' || !DANGEROUS_SCRIPT.test(script))
146
+ continue;
147
+ const line = Math.max(1, lines.findIndex((l) => l.includes(`"${hook}"`)) + 1);
148
+ findings.push({
149
+ id: 'CTS028',
150
+ severity: 'critical',
151
+ title: `Install hook \`${hook}\` runs network or shell code`,
152
+ detail: `The \`${hook}\` script (\`${script.length > 120 ? script.slice(0, 117) + '...' : script}\`) executes ` +
153
+ 'automatically on every `npm install`, including in CI and on every contributor’s machine, ' +
154
+ 'before any code review happens. Fetching or evaluating code there is the standard ' +
155
+ 'supply-chain execution path.',
156
+ fix: 'Move the work into an explicit script the developer opts into (`npm run setup`), or vendor ' +
157
+ 'the artefact and verify its checksum instead of fetching it at install time.',
158
+ file: relPath,
159
+ line,
160
+ cwe: 'CWE-829: Inclusion of Functionality from Untrusted Control Sphere',
161
+ owasp: 'A03:2025 - Software Supply Chain Failures',
162
+ meta: { hook, script },
163
+ });
164
+ }
165
+ return findings;
166
+ }
167
+ /**
168
+ * Resolves the version actually installed for each declared dependency.
169
+ *
170
+ * A manifest only carries a range, and OSV needs an exact version. Lockfiles
171
+ * are the truth, so they are read directly (bypassing the walker's size cap,
172
+ * since a lockfile is routinely megabytes). Where no lockfile exists, the
173
+ * range's floor is used: `^14.2.3` resolves to `14.2.3`, which is the oldest
174
+ * version the range permits and therefore the one worth checking.
175
+ */
176
+ function resolveVersions(root, declared) {
177
+ const resolved = new Map();
178
+ const readIfPresent = (name) => {
179
+ try {
180
+ return readFileSync(join(root, name), 'utf8');
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ };
186
+ const npmLock = readIfPresent('package-lock.json');
187
+ if (npmLock) {
188
+ try {
189
+ const doc = JSON.parse(npmLock);
190
+ for (const [path, entry] of Object.entries(doc.packages ?? {})) {
191
+ if (!path.startsWith('node_modules/'))
192
+ continue;
193
+ const name = path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length);
194
+ if (entry?.version)
195
+ resolved.set(name, String(entry.version));
196
+ }
197
+ for (const [name, entry] of Object.entries(doc.dependencies ?? {})) {
198
+ if (entry?.version && !resolved.has(name))
199
+ resolved.set(name, String(entry.version));
200
+ }
201
+ }
202
+ catch {
203
+ /* a malformed lockfile just means we fall back to the range floor */
204
+ }
205
+ }
206
+ const pnpmLock = readIfPresent('pnpm-lock.yaml');
207
+ if (pnpmLock) {
208
+ // Entries look like `/next@14.2.3:` (v6/v9) or `/next/14.2.3:` (v5).
209
+ const re = /^\s{2}\/?((?:@[^/\s]+\/)?[^/@\s]+)[@/](\d+\.\d+\.\d+[^\s:(]*)/gm;
210
+ let m;
211
+ while ((m = re.exec(pnpmLock)) !== null) {
212
+ if (!resolved.has(m[1]))
213
+ resolved.set(m[1], m[2]);
214
+ }
215
+ }
216
+ const yarnLock = readIfPresent('yarn.lock');
217
+ if (yarnLock) {
218
+ // Parsed line by line rather than with a regex: matching an entry header
219
+ // and its indented `version` line in one pattern needs a nested quantifier,
220
+ // which backtracks catastrophically on a crafted lockfile — and a lockfile
221
+ // is exactly the attacker-influenced input this tool reads in CI.
222
+ let pendingNames = [];
223
+ for (const rawLine of yarnLock.split('\n')) {
224
+ if (rawLine.length === 0 || rawLine.startsWith('#'))
225
+ continue;
226
+ if (!/^\s/.test(rawLine)) {
227
+ // Entry header: `"next@npm:^14.2.3", next@^14.0.0:`
228
+ pendingNames = rawLine
229
+ .replace(/:\s*$/, '')
230
+ .split(',')
231
+ .map((part) => part.trim().replace(/^"|"$/g, ''))
232
+ .map((spec) => {
233
+ const at = spec.lastIndexOf('@');
234
+ return at > 0 ? spec.slice(0, at) : spec;
235
+ })
236
+ .filter(Boolean);
237
+ continue;
238
+ }
239
+ const version = /^\s+version:?\s+"?([^"\s]+)"?\s*$/.exec(rawLine)?.[1];
240
+ if (version && pendingNames.length > 0) {
241
+ for (const name of pendingNames) {
242
+ if (!resolved.has(name))
243
+ resolved.set(name, version);
244
+ }
245
+ pendingNames = [];
246
+ }
247
+ }
248
+ }
249
+ // Fall back to the floor of each declared range.
250
+ for (const d of declared) {
251
+ if (resolved.has(d.name))
252
+ continue;
253
+ const floor = /(\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)/.exec(d.range)?.[1];
254
+ if (floor)
255
+ resolved.set(d.name, floor);
256
+ }
257
+ return resolved;
258
+ }
259
+ export const dependencyScanner = {
260
+ name: 'Dependency hallucination & slopsquatting',
261
+ applies(ctx) {
262
+ return ctx.files.some((f) => {
263
+ const b = basename(f);
264
+ return (b === 'package.json' || b === 'requirements.txt' || b === 'pyproject.toml' || isProse(f));
265
+ });
266
+ },
267
+ async run(ctx) {
268
+ const result = emptyResult();
269
+ const declared = [];
270
+ for (const file of ctx.files) {
271
+ const b = basename(file);
272
+ const source = read(file);
273
+ if (source === null)
274
+ continue;
275
+ const relPath = rel(ctx.root, file);
276
+ if (b === 'package.json') {
277
+ declared.push(...collectFromPackageJson(source, relPath));
278
+ result.findings.push(...collectInstallScriptFindings(source, relPath));
279
+ }
280
+ else if (b === 'requirements.txt')
281
+ declared.push(...collectFromRequirements(source, relPath));
282
+ else if (b === 'pyproject.toml')
283
+ declared.push(...collectFromPyproject(source, relPath));
284
+ else if (isProse(file))
285
+ declared.push(...collectFromProse(source, relPath));
286
+ }
287
+ // Local workspace references never hit a registry.
288
+ const checkable = declared.filter((d) => !/^(file:|link:|workspace:|portal:|git\+|https?:|github:|npm:)/.test(d.range));
289
+ // Dedupe by package, but let a manifest entry win over a mention in prose.
290
+ // Files are walked alphabetically, so AGENTS.md is seen before package.json;
291
+ // without this, a real dependency is recorded as a prose reference, which
292
+ // both mislocates the finding and skips version resolution for it.
293
+ const unique = new Map();
294
+ for (const d of checkable) {
295
+ const key = `${d.ecosystem}:${d.name}`;
296
+ const existing = unique.get(key);
297
+ if (!existing || (existing.fromProse && !d.fromProse))
298
+ unique.set(key, d);
299
+ }
300
+ const list = [...unique.values()];
301
+ if (list.length === 0) {
302
+ result.checks.push({ label: 'Dependency verification', passed: true, note: 'no dependencies declared' });
303
+ return result;
304
+ }
305
+ if (ctx.offline) {
306
+ result.checks.push({
307
+ label: `Dependency verification (${list.length} packages)`,
308
+ passed: true,
309
+ note: 'skipped — running with --offline',
310
+ });
311
+ return result;
312
+ }
313
+ const registry = new Registry(ctx.cacheDir, ctx.offline);
314
+ const facts = await pool(list, 8, (d) => registry.lookup(d.name, d.ecosystem));
315
+ if (registry.networkErrors > 0) {
316
+ result.warnings.push(`${registry.networkErrors} registry lookup(s) failed; those packages were treated as valid.`);
317
+ }
318
+ list.forEach((d, i) => {
319
+ const f = facts[i];
320
+ const popular = d.ecosystem === 'npm' ? POPULAR_NPM : POPULAR_PYPI;
321
+ const registryName = d.ecosystem === 'npm' ? 'npm' : 'PyPI';
322
+ const origin = d.fromProse
323
+ ? `referenced by an install command in ${d.file}`
324
+ : `declared in ${d.file}`;
325
+ if (!f.exists && f.securityHold) {
326
+ result.findings.push({
327
+ id: 'CTS026',
328
+ severity: 'critical',
329
+ title: 'Dependency was removed by the registry for malware',
330
+ detail: `\`${d.name}\` is ${origin}, and ${registryName} serves HTTP 451 for it — the response ` +
331
+ 'reserved for a package taken down on legal or security grounds. This name was not merely ' +
332
+ 'invented; it was published, weaponised and pulled.',
333
+ fix: `Remove \`${d.name}\` everywhere it appears and treat any machine that installed it as ` +
334
+ 'compromised: rotate the credentials that were present in that environment and audit the lockfile history.',
335
+ file: d.file,
336
+ line: d.line,
337
+ cwe: 'CWE-1357: Reliance on Insufficiently Trustworthy Component',
338
+ owasp: 'A03:2025 - Software Supply Chain Failures',
339
+ meta: { package: d.name, ecosystem: d.ecosystem, securityHold: true },
340
+ });
341
+ return;
342
+ }
343
+ if (!f.exists && f.unpublished) {
344
+ result.findings.push({
345
+ id: 'CTS027',
346
+ severity: 'critical',
347
+ title: 'Dependency was unpublished and its name is open to takeover',
348
+ detail: `\`${d.name}\` is ${origin} but no longer exists on ${registryName}, yet it still records ` +
349
+ `${f.weeklyDownloads} weekly downloads. It was published and then withdrawn, so the name is ` +
350
+ 'free for anyone to claim — and whoever claims it inherits every one of those installs.',
351
+ fix: `Remove \`${d.name}\` and replace it with a maintained package. Your install is already ` +
352
+ 'failing or falling back to a cache; the risk is the day someone republishes the name.',
353
+ file: d.file,
354
+ line: d.line,
355
+ cwe: 'CWE-1357: Reliance on Insufficiently Trustworthy Component',
356
+ owasp: 'A03:2025 - Software Supply Chain Failures',
357
+ meta: { package: d.name, ecosystem: d.ecosystem, unpublished: true, weeklyDownloads: f.weeklyDownloads },
358
+ });
359
+ return;
360
+ }
361
+ if (!f.exists) {
362
+ const near = nearestPopular(d.name, popular, 2);
363
+ result.findings.push({
364
+ id: 'CTS020',
365
+ severity: 'critical',
366
+ title: 'Dependency does not exist on the registry',
367
+ detail: `\`${d.name}\` is ${origin} but no such package is published on ${registryName}. ` +
368
+ 'This is the classic signature of an AI-hallucinated import. The name is unclaimed, so an ' +
369
+ 'attacker can register it and have their code execute in every install and CI run from then on.' +
370
+ (near ? ` Did you mean \`${near}\`?` : ''),
371
+ fix: near
372
+ ? `Replace \`${d.name}\` with \`${near}\`, or remove the dependency and the code importing it.`
373
+ : `Remove \`${d.name}\` and the code that imports it, or publish the package yourself to claim the name.`,
374
+ file: d.file,
375
+ line: d.line,
376
+ cwe: 'CWE-1357: Reliance on Insufficiently Trustworthy Component',
377
+ owasp: 'A03:2025 - Software Supply Chain Failures',
378
+ meta: { package: d.name, ecosystem: d.ecosystem, suggestion: near, fromProse: d.fromProse },
379
+ });
380
+ return;
381
+ }
382
+ if (f.error)
383
+ return; // lookup failed; already reported as a warning
384
+ const ageDays = f.created
385
+ ? Math.floor((Date.now() - Date.parse(f.created)) / 86_400_000)
386
+ : null;
387
+ const downloads = f.weeklyDownloads;
388
+ const near = nearestPopular(d.name, popular, 1);
389
+ if (near) {
390
+ // Squats of very popular names still rack up thousands of installs from
391
+ // people making the same mistake, so download volume tunes the severity
392
+ // rather than gating the finding.
393
+ const busy = downloads !== undefined && downloads >= 10_000;
394
+ result.findings.push({
395
+ id: 'CTS023',
396
+ severity: busy ? 'medium' : 'high',
397
+ title: 'Dependency name is one edit away from a popular package',
398
+ detail: `\`${d.name}\` differs from the widely-used \`${near}\` by a single character` +
399
+ (downloads !== undefined ? ` and has ${downloads.toLocaleString()} weekly downloads` : '') +
400
+ '. Typosquats and slopsquats are published precisely to catch this substitution — and a ' +
401
+ 'squat of a popular name still collects real install traffic, so a download count is not ' +
402
+ 'on its own reassuring.',
403
+ fix: `Confirm you meant \`${d.name}\` and not \`${near}\`. Check the package's repository, ` +
404
+ 'maintainer and install scripts before trusting it.',
405
+ file: d.file,
406
+ line: d.line,
407
+ cwe: 'CWE-1357: Reliance on Insufficiently Trustworthy Component',
408
+ owasp: 'A03:2025 - Software Supply Chain Failures',
409
+ meta: { package: d.name, lookalike: near, weeklyDownloads: downloads },
410
+ });
411
+ }
412
+ if (ageDays !== null && ageDays <= NEW_PACKAGE_DAYS && (downloads === undefined || downloads < LOW_DOWNLOADS)) {
413
+ result.findings.push({
414
+ id: 'CTS021',
415
+ severity: 'high',
416
+ title: 'Newly published, barely used dependency',
417
+ detail: `\`${d.name}\` was first published ${ageDays} day${ageDays === 1 ? '' : 's'} ago` +
418
+ (downloads !== undefined ? ` and has ${downloads} weekly downloads` : '') +
419
+ '. A package that appears in an AI-written manifest and was registered days ago is the ' +
420
+ 'expected shape of a slopsquat: the model invents the name, an attacker registers it.',
421
+ fix: `Open https://www.npmjs.com/package/${d.name} and check the repository, the maintainer and the ` +
422
+ 'install scripts before trusting it. Pin an exact version if you keep it.',
423
+ file: d.file,
424
+ line: d.line,
425
+ cwe: 'CWE-1357: Reliance on Insufficiently Trustworthy Component',
426
+ owasp: 'A03:2025 - Software Supply Chain Failures',
427
+ meta: { package: d.name, ageDays, weeklyDownloads: downloads },
428
+ });
429
+ }
430
+ else if (downloads !== undefined &&
431
+ downloads < VERY_LOW_DOWNLOADS &&
432
+ !d.dev &&
433
+ !near) {
434
+ result.findings.push({
435
+ id: 'CTS022',
436
+ severity: 'low',
437
+ title: 'Runtime dependency with almost no users',
438
+ detail: `\`${d.name}\` has ${downloads} weekly downloads. That is not a vulnerability by itself, but ` +
439
+ 'an unmaintained single-author package in your runtime path is worth a deliberate decision ' +
440
+ 'rather than an accidental one.',
441
+ fix: 'Confirm the package is maintained and actually needed, or vendor the few functions you use.',
442
+ file: d.file,
443
+ line: d.line,
444
+ owasp: 'A03:2025 - Software Supply Chain Failures',
445
+ meta: { package: d.name, weeklyDownloads: downloads },
446
+ });
447
+ }
448
+ if (f.deprecated) {
449
+ result.findings.push({
450
+ id: 'CTS025',
451
+ severity: 'low',
452
+ title: 'Dependency is deprecated upstream',
453
+ detail: `The latest published version of \`${d.name}\` is marked deprecated by its maintainer.`,
454
+ fix: 'Check the deprecation notice on the registry page and migrate to the successor package.',
455
+ file: d.file,
456
+ line: d.line,
457
+ owasp: 'A03:2025 - Software Supply Chain Failures',
458
+ meta: { package: d.name },
459
+ });
460
+ }
461
+ });
462
+ // Known vulnerabilities, from OSV.dev rather than hand-maintained version
463
+ // patterns. Only packages that exist and resolve to a concrete version can
464
+ // be queried.
465
+ const versions = resolveVersions(ctx.root, declared);
466
+ const osvQueries = [];
467
+ const osvSubjects = [];
468
+ list.forEach((d, i) => {
469
+ if (!facts[i].exists || facts[i].error)
470
+ return;
471
+ if (d.fromProse)
472
+ return; // a mention in prose is not an installed version
473
+ const version = versions.get(d.name);
474
+ if (!version)
475
+ return;
476
+ osvQueries.push({
477
+ name: d.name,
478
+ version,
479
+ ecosystem: d.ecosystem === 'npm' ? 'npm' : 'PyPI',
480
+ });
481
+ osvSubjects.push(d);
482
+ });
483
+ let osvChecked = 0;
484
+ if (osvQueries.length > 0) {
485
+ const { results: vulnResults, failed } = await queryOsv(osvQueries);
486
+ if (failed) {
487
+ result.warnings.push('OSV.dev lookup failed; known-vulnerability results are incomplete for this run.');
488
+ }
489
+ else {
490
+ osvChecked = osvQueries.length;
491
+ }
492
+ vulnResults.forEach((vulns, i) => {
493
+ if (vulns.length === 0)
494
+ return;
495
+ const d = osvSubjects[i];
496
+ const q = osvQueries[i];
497
+ // Report the worst one per package; the rest are listed in meta.
498
+ const worst = vulns.reduce((a, b) => ((b.cvss ?? 0) > (a.cvss ?? 0) ? b : a));
499
+ const cve = worst.aliases.find((a) => a.startsWith('CVE-')) ?? worst.id;
500
+ const others = vulns.length - 1;
501
+ result.findings.push({
502
+ id: 'CTS024',
503
+ severity: severityFromCvss(worst.cvss),
504
+ title: `Dependency has a known vulnerability (${cve})`,
505
+ detail: `\`${d.name}@${q.version}\` is affected by ${cve}: ${worst.summary}` +
506
+ (worst.cvss !== null ? ` CVSS ${worst.cvss}.` : '') +
507
+ (others > 0
508
+ ? ` ${others} further advisor${others === 1 ? 'y' : 'ies'} also affect this version.`
509
+ : ''),
510
+ fix: worst.fixedIn
511
+ ? `Upgrade \`${d.name}\` to ${worst.fixedIn} or later.`
512
+ : `No fixed version is published yet. Check https://osv.dev/vulnerability/${worst.id} for mitigations.`,
513
+ file: d.file,
514
+ line: d.line,
515
+ cwe: 'CWE-1395: Dependency on Vulnerable Third-Party Component',
516
+ owasp: 'A03:2025 - Software Supply Chain Failures',
517
+ meta: {
518
+ package: d.name,
519
+ version: q.version,
520
+ resolvedFrom: versions.has(d.name) ? 'lockfile-or-range' : 'range',
521
+ advisories: vulns.map((v) => ({
522
+ id: v.id,
523
+ aliases: v.aliases,
524
+ cvss: v.cvss,
525
+ fixedIn: v.fixedIn,
526
+ })),
527
+ },
528
+ });
529
+ });
530
+ if (osvChecked > 0) {
531
+ const vulnerable = result.findings.filter((f) => f.id === 'CTS024').length;
532
+ result.checks.push({
533
+ label: `Known vulnerabilities (${osvChecked} resolved versions checked against OSV.dev)`,
534
+ passed: vulnerable === 0,
535
+ note: vulnerable > 0 ? `${vulnerable} affected` : undefined,
536
+ });
537
+ }
538
+ }
539
+ const missing = result.findings.filter((f) => ['CTS020', 'CTS026', 'CTS027'].includes(f.id)).length;
540
+ result.checks.push({
541
+ label: `Dependency verification (${list.length} package${list.length === 1 ? '' : 's'} checked against npm/PyPI)`,
542
+ passed: missing === 0,
543
+ note: missing > 0 ? `${missing} could not be resolved` : undefined,
544
+ });
545
+ return result;
546
+ },
547
+ };
548
+ /** Exposed for tests: harvest package names from prose install commands. */
549
+ export const collectProseForTest = collectFromProse;
@@ -0,0 +1,8 @@
1
+ import { serverActionsScanner } from './server-actions.js';
2
+ import { rlsScanner } from './rls.js';
3
+ import { dependencyScanner } from './dependencies.js';
4
+ import { secretsScanner } from './secrets.js';
5
+ import { communityScanner } from './community.js';
6
+ import type { Scanner } from '../types.js';
7
+ export declare const SCANNERS: Scanner[];
8
+ export { serverActionsScanner, rlsScanner, dependencyScanner, secretsScanner, communityScanner, };
@@ -0,0 +1,13 @@
1
+ import { serverActionsScanner } from './server-actions.js';
2
+ import { rlsScanner } from './rls.js';
3
+ import { dependencyScanner } from './dependencies.js';
4
+ import { secretsScanner } from './secrets.js';
5
+ import { communityScanner } from './community.js';
6
+ export const SCANNERS = [
7
+ dependencyScanner,
8
+ serverActionsScanner,
9
+ rlsScanner,
10
+ secretsScanner,
11
+ communityScanner,
12
+ ];
13
+ export { serverActionsScanner, rlsScanner, dependencyScanner, secretsScanner, communityScanner, };
@@ -0,0 +1,7 @@
1
+ import type { Scanner } from '../types.js';
2
+ declare function parseColumns(body: string): string[];
3
+ export declare const rlsScanner: Scanner;
4
+ export declare const _internals: {
5
+ parseColumns: typeof parseColumns;
6
+ };
7
+ export {};