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.
- package/LICENSE +21 -0
- package/README.md +264 -0
- package/action.yml +154 -0
- package/dist/banner.d.ts +1 -0
- package/dist/banner.js +14 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +103 -0
- package/dist/data/popular.d.ts +17 -0
- package/dist/data/popular.js +103 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/report.d.ts +15 -0
- package/dist/report.js +317 -0
- package/dist/scan.d.ts +24 -0
- package/dist/scan.js +81 -0
- package/dist/scanners/community.d.ts +2 -0
- package/dist/scanners/community.js +155 -0
- package/dist/scanners/dependencies.d.ts +21 -0
- package/dist/scanners/dependencies.js +549 -0
- package/dist/scanners/index.d.ts +8 -0
- package/dist/scanners/index.js +13 -0
- package/dist/scanners/rls.d.ts +7 -0
- package/dist/scanners/rls.js +531 -0
- package/dist/scanners/secrets.d.ts +2 -0
- package/dist/scanners/secrets.js +439 -0
- package/dist/scanners/server-actions.d.ts +2 -0
- package/dist/scanners/server-actions.js +466 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.js +10 -0
- package/dist/utils/ast.d.ts +13 -0
- package/dist/utils/ast.js +107 -0
- package/dist/utils/detect.d.ts +2 -0
- package/dist/utils/detect.js +77 -0
- package/dist/utils/entropy.d.ts +7 -0
- package/dist/utils/entropy.js +19 -0
- package/dist/utils/files.d.ts +13 -0
- package/dist/utils/files.js +154 -0
- package/dist/utils/osv.d.ts +26 -0
- package/dist/utils/osv.js +123 -0
- package/dist/utils/registry.d.ts +44 -0
- package/dist/utils/registry.js +195 -0
- package/dist/utils/sql.d.ts +20 -0
- package/dist/utils/sql.js +157 -0
- package/dist/utils/suppress.d.ts +19 -0
- package/dist/utils/suppress.js +56 -0
- package/dist/utils/traverse.d.ts +2 -0
- package/dist/utils/traverse.js +14 -0
- package/dist/vendor/gitleaks/rules.d.ts +13 -0
- package/dist/vendor/gitleaks/rules.js +1571 -0
- package/dist/vendor/guardvibe/index.d.ts +11 -0
- package/dist/vendor/guardvibe/index.js +60 -0
- package/dist/vendor/guardvibe/rules/advanced-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/advanced-security.js +418 -0
- package/dist/vendor/guardvibe/rules/ai-host-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/ai-host-security.js +190 -0
- package/dist/vendor/guardvibe/rules/ai-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/ai-security.js +413 -0
- package/dist/vendor/guardvibe/rules/ai-tool-runtime.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/ai-tool-runtime.js +194 -0
- package/dist/vendor/guardvibe/rules/api-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/api-security.js +154 -0
- package/dist/vendor/guardvibe/rules/auth.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/auth.js +222 -0
- package/dist/vendor/guardvibe/rules/cicd.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/cicd.js +108 -0
- package/dist/vendor/guardvibe/rules/core.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/core.js +484 -0
- package/dist/vendor/guardvibe/rules/cve-versions.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/cve-versions.js +1130 -0
- package/dist/vendor/guardvibe/rules/database.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/database.js +173 -0
- package/dist/vendor/guardvibe/rules/deployment.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/deployment.js +269 -0
- package/dist/vendor/guardvibe/rules/dockerfile.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/dockerfile.js +99 -0
- package/dist/vendor/guardvibe/rules/firebase.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/firebase.js +96 -0
- package/dist/vendor/guardvibe/rules/go.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/go.js +85 -0
- package/dist/vendor/guardvibe/rules/modern-stack.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/modern-stack.js +631 -0
- package/dist/vendor/guardvibe/rules/nextjs.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/nextjs.js +233 -0
- package/dist/vendor/guardvibe/rules/other-services.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/other-services.js +72 -0
- package/dist/vendor/guardvibe/rules/payments.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/payments.js +123 -0
- package/dist/vendor/guardvibe/rules/react-native.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/react-native.js +132 -0
- package/dist/vendor/guardvibe/rules/services.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/services.js +149 -0
- package/dist/vendor/guardvibe/rules/shell.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/shell.js +72 -0
- package/dist/vendor/guardvibe/rules/sql.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/sql.js +60 -0
- package/dist/vendor/guardvibe/rules/supply-chain.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/supply-chain.js +243 -0
- package/dist/vendor/guardvibe/rules/terraform.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/terraform.js +84 -0
- package/dist/vendor/guardvibe/rules/types.d.ts +14 -0
- package/dist/vendor/guardvibe/rules/types.js +11 -0
- package/dist/vendor/guardvibe/rules/web-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/web-security.js +258 -0
- package/examples/security.yml +23 -0
- 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, };
|