iamsecure 0.0.0-stage → 0.1.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 +127 -2
- package/bin/iamsecure.js +136 -0
- package/package.json +36 -4
- package/src/ai/index.js +170 -0
- package/src/ai/providers.js +161 -0
- package/src/code/context.js +123 -0
- package/src/code/engine.js +117 -0
- package/src/code/files.js +85 -0
- package/src/code/index.js +30 -0
- package/src/code/rules.js +545 -0
- package/src/code/secrets.js +123 -0
- package/src/code/strip.js +51 -0
- package/src/deps/cvss.js +59 -0
- package/src/deps/graph.js +88 -0
- package/src/deps/index.js +144 -0
- package/src/deps/lockfiles/composer.js +31 -0
- package/src/deps/lockfiles/npm.js +77 -0
- package/src/deps/lockfiles/pnpm.js +56 -0
- package/src/deps/lockfiles/python.js +101 -0
- package/src/deps/lockfiles/yarn.js +93 -0
- package/src/deps/osv.js +106 -0
- package/src/project.js +102 -0
- package/src/report/sarif.js +58 -0
- package/src/report/terminal.js +253 -0
- package/src/risk/exploit.js +53 -0
- package/src/risk/score.js +215 -0
- package/src/scan.js +91 -0
- package/src/ui/index.html +980 -0
- package/src/ui/server.js +97 -0
- package/src/util/cache.js +35 -0
- package/src/util/http.js +43 -0
- package/src/util/semver.js +23 -0
- package/src/util/toml.js +129 -0
- package/src/util/yaml.js +85 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blank out comments while keeping strings, line numbers and column positions intact,
|
|
3
|
+
* so rules never fire on commented-out code.
|
|
4
|
+
* @param {string} text
|
|
5
|
+
* @param {'js'|'php'|'py'} lang
|
|
6
|
+
* @param {{blankStrings?: boolean}} [opts] also blank the contents of string literals (quotes are kept)
|
|
7
|
+
*/
|
|
8
|
+
export function stripComments(text, lang = 'js', { blankStrings = false } = {}) {
|
|
9
|
+
const out = text.split('');
|
|
10
|
+
const blank = (from, to) => {
|
|
11
|
+
for (let k = from; k < to; k++) if (out[k] !== '\n') out[k] = ' ';
|
|
12
|
+
};
|
|
13
|
+
const lineComment = lang === 'py' ? ['#'] : lang === 'php' ? ['//', '#'] : ['//'];
|
|
14
|
+
const quotes = lang === 'js' ? '\'"`' : '\'"';
|
|
15
|
+
let i = 0;
|
|
16
|
+
while (i < text.length) {
|
|
17
|
+
const ch = text[i];
|
|
18
|
+
if (quotes.includes(ch)) {
|
|
19
|
+
// Python triple-quoted strings.
|
|
20
|
+
const triple = lang === 'py' && text.startsWith(ch.repeat(3), i);
|
|
21
|
+
const end = triple ? ch.repeat(3) : ch;
|
|
22
|
+
let j = i + end.length;
|
|
23
|
+
while (j < text.length && !text.startsWith(end, j)) {
|
|
24
|
+
if (text[j] === '\\') j++;
|
|
25
|
+
else if (!triple && ch !== '`' && text[j] === '\n') break;
|
|
26
|
+
j++;
|
|
27
|
+
}
|
|
28
|
+
if (blankStrings) blank(i + end.length, Math.min(j, text.length));
|
|
29
|
+
i = j + end.length;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (lang !== 'py' && text.startsWith('/*', i)) {
|
|
33
|
+
const j = text.indexOf('*/', i + 2);
|
|
34
|
+
const stop = j === -1 ? text.length : j + 2;
|
|
35
|
+
blank(i, stop);
|
|
36
|
+
i = stop;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const lc = lineComment.find((c) => text.startsWith(c, i));
|
|
40
|
+
// `//` right after ':' is a URL, not a comment (http://…).
|
|
41
|
+
if (lc && !(lc === '//' && text[i - 1] === ':')) {
|
|
42
|
+
const j = text.indexOf('\n', i);
|
|
43
|
+
const stop = j === -1 ? text.length : j;
|
|
44
|
+
blank(i, stop);
|
|
45
|
+
i = stop;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
return out.join('');
|
|
51
|
+
}
|
package/src/deps/cvss.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// CVSS v3.x base score calculator (spec: https://www.first.org/cvss/v3.1/specification-document).
|
|
2
|
+
// For v4 vectors we only parse the metrics; the score then comes from the advisory's label.
|
|
3
|
+
|
|
4
|
+
const W = {
|
|
5
|
+
AV: { N: 0.85, A: 0.62, L: 0.55, P: 0.2 },
|
|
6
|
+
AC: { L: 0.77, H: 0.44 },
|
|
7
|
+
UI: { N: 0.85, R: 0.62 },
|
|
8
|
+
CIA: { H: 0.56, L: 0.22, N: 0 },
|
|
9
|
+
PR_U: { N: 0.85, L: 0.62, H: 0.27 },
|
|
10
|
+
PR_C: { N: 0.85, L: 0.68, H: 0.5 },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function roundUp(x) {
|
|
14
|
+
const i = Math.round(x * 100000);
|
|
15
|
+
return i % 10000 === 0 ? i / 100000 : (Math.floor(i / 10000) + 1) / 10;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseVector(vector) {
|
|
19
|
+
const metrics = {};
|
|
20
|
+
for (const part of String(vector).split('/')) {
|
|
21
|
+
const [k, v] = part.split(':');
|
|
22
|
+
if (k && v) metrics[k] = v;
|
|
23
|
+
}
|
|
24
|
+
return metrics;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @returns {{version:string, score:number|null, metrics:Record<string,string>}} */
|
|
28
|
+
export function cvssScore(vector) {
|
|
29
|
+
const m = parseVector(vector);
|
|
30
|
+
const version = m.CVSS ?? '';
|
|
31
|
+
if (!version.startsWith('3')) return { version, score: null, metrics: m };
|
|
32
|
+
const scopeChanged = m.S === 'C';
|
|
33
|
+
const [C, I, A] = [W.CIA[m.C], W.CIA[m.I], W.CIA[m.A]];
|
|
34
|
+
const PR = (scopeChanged ? W.PR_C : W.PR_U)[m.PR];
|
|
35
|
+
if ([W.AV[m.AV], W.AC[m.AC], W.UI[m.UI], PR, C, I, A].some((x) => x === undefined)) {
|
|
36
|
+
return { version, score: null, metrics: m };
|
|
37
|
+
}
|
|
38
|
+
const iss = 1 - (1 - C) * (1 - I) * (1 - A);
|
|
39
|
+
const impact = scopeChanged ? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15) : 6.42 * iss;
|
|
40
|
+
const exploitability = 8.22 * W.AV[m.AV] * W.AC[m.AC] * PR * W.UI[m.UI];
|
|
41
|
+
if (impact <= 0) return { version, score: 0, metrics: m };
|
|
42
|
+
const raw = scopeChanged ? 1.08 * (impact + exploitability) : impact + exploitability;
|
|
43
|
+
return { version, score: roundUp(Math.min(raw, 10)), metrics: m };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const LEVELS = ['critical', 'high', 'medium', 'low', 'none'];
|
|
47
|
+
|
|
48
|
+
export function levelFromScore(score) {
|
|
49
|
+
if (score >= 9) return 'critical';
|
|
50
|
+
if (score >= 7) return 'high';
|
|
51
|
+
if (score >= 4) return 'medium';
|
|
52
|
+
if (score > 0) return 'low';
|
|
53
|
+
return 'none';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Representative score for advisories that only carry a label. */
|
|
57
|
+
export function scoreFromLabel(label) {
|
|
58
|
+
return { CRITICAL: 9.5, HIGH: 8, MODERATE: 5.5, MEDIUM: 5.5, LOW: 2.5 }[String(label).toUpperCase()] ?? null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolved dependency graph for one lockfile. Nodes are unique name@version pairs;
|
|
3
|
+
* roots are the dependencies declared in package.json (or a workspace's package.json).
|
|
4
|
+
*/
|
|
5
|
+
export class DepGraph {
|
|
6
|
+
constructor(ecosystem, lockfile) {
|
|
7
|
+
this.ecosystem = ecosystem;
|
|
8
|
+
this.lockfile = lockfile;
|
|
9
|
+
/** @type {Map<string, {key:string,name:string,version:string,local:boolean,deps:Set<string>,prod?:boolean,direct?:boolean,parent?:string|null,depth?:number,reachedBy?:Set<string>}>} */
|
|
10
|
+
this.nodes = new Map();
|
|
11
|
+
/** @type {{name:string,key:string,dev:boolean,workspace:string}[]} */
|
|
12
|
+
this.roots = [];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
addNode(name, version, { local = false } = {}) {
|
|
16
|
+
const key = `${name}@${version}`;
|
|
17
|
+
if (!this.nodes.has(key)) this.nodes.set(key, { key, name, version, local, deps: new Set() });
|
|
18
|
+
return key;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
addEdge(from, to) {
|
|
22
|
+
if (from && to && from !== to) this.nodes.get(from)?.deps.add(to);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
addRoot(name, key, { dev = false, workspace = '.' } = {}) {
|
|
26
|
+
if (key && this.nodes.has(key) && !this.nodes.get(key).local) this.roots.push({ name, key, dev, workspace });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Walks from every root and records, per node: whether any production root reaches it,
|
|
31
|
+
* which direct dependencies pull it in, and a shortest "why is this installed" parent pointer
|
|
32
|
+
* (production chains win over dev chains).
|
|
33
|
+
*/
|
|
34
|
+
annotate() {
|
|
35
|
+
for (const n of this.nodes.values()) {
|
|
36
|
+
n.prod = false;
|
|
37
|
+
n.direct = false;
|
|
38
|
+
n.parent = undefined;
|
|
39
|
+
n.depth = Infinity;
|
|
40
|
+
n.reachedBy = new Set();
|
|
41
|
+
}
|
|
42
|
+
const roots = [...this.roots].sort((a, b) => Number(a.dev) - Number(b.dev));
|
|
43
|
+
for (const root of roots) {
|
|
44
|
+
const start = this.nodes.get(root.key);
|
|
45
|
+
start.direct = true;
|
|
46
|
+
if (start.depth > 0 && !(root.dev && start.prod)) {
|
|
47
|
+
start.parent = null;
|
|
48
|
+
start.depth = 0;
|
|
49
|
+
}
|
|
50
|
+
const seen = new Set([root.key]);
|
|
51
|
+
const queue = [root.key];
|
|
52
|
+
for (let qi = 0; qi < queue.length; qi++) {
|
|
53
|
+
const n = this.nodes.get(queue[qi]);
|
|
54
|
+
n.reachedBy.add(root.name);
|
|
55
|
+
if (!root.dev) n.prod = true;
|
|
56
|
+
for (const childKey of n.deps) {
|
|
57
|
+
if (seen.has(childKey)) continue;
|
|
58
|
+
seen.add(childKey);
|
|
59
|
+
const c = this.nodes.get(childKey);
|
|
60
|
+
if (!c || c.local) continue;
|
|
61
|
+
const better = c.parent === undefined || (n.depth + 1 < c.depth && !(root.dev && c.prod));
|
|
62
|
+
if (better && c.depth !== 0) {
|
|
63
|
+
c.parent = n.key;
|
|
64
|
+
c.depth = n.depth + 1;
|
|
65
|
+
}
|
|
66
|
+
queue.push(childKey);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Chain of node keys from a direct dependency down to `key`. */
|
|
74
|
+
pathTo(key) {
|
|
75
|
+
const chain = [];
|
|
76
|
+
const guard = new Set();
|
|
77
|
+
for (let k = key; k && !guard.has(k); k = this.nodes.get(k)?.parent) {
|
|
78
|
+
guard.add(k);
|
|
79
|
+
chain.unshift(k);
|
|
80
|
+
}
|
|
81
|
+
return chain;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Nodes that should be checked against advisory databases. */
|
|
85
|
+
packages() {
|
|
86
|
+
return [...this.nodes.values()].filter((n) => !n.local && n.version && !/^(file|link|git|https?):/.test(n.version));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseNpmLock } from './lockfiles/npm.js';
|
|
4
|
+
import { parseYarnLock } from './lockfiles/yarn.js';
|
|
5
|
+
import { parsePnpmLock } from './lockfiles/pnpm.js';
|
|
6
|
+
import { parseComposerLock } from './lockfiles/composer.js';
|
|
7
|
+
import { parseTomlLock, parsePipfileLock, parseRequirements } from './lockfiles/python.js';
|
|
8
|
+
import { readJson } from '../project.js';
|
|
9
|
+
import { queryOsv, normalizeVuln, pkgId } from './osv.js';
|
|
10
|
+
|
|
11
|
+
const JS_LOCKFILES = [
|
|
12
|
+
['package-lock.json', (t, p) => parseNpmLock(t, p.manifest, 'package-lock.json')],
|
|
13
|
+
['npm-shrinkwrap.json', (t, p) => parseNpmLock(t, p.manifest, 'npm-shrinkwrap.json')],
|
|
14
|
+
['pnpm-lock.yaml', (t) => parsePnpmLock(t)],
|
|
15
|
+
['yarn.lock', (t, p) => parseYarnLock(t, p.manifests)],
|
|
16
|
+
// npm ≥7 keeps a copy of the resolved tree here even when package-lock.json isn't committed.
|
|
17
|
+
['node_modules/.package-lock.json', (t, p) => parseNpmLock(t, p.manifest, 'node_modules/.package-lock.json')],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** Find and parse the lockfile(s) of a project into dependency graphs. */
|
|
21
|
+
export function loadGraphs(project) {
|
|
22
|
+
const graphs = [];
|
|
23
|
+
const warnings = [];
|
|
24
|
+
|
|
25
|
+
if (project.manifest) {
|
|
26
|
+
const found = JS_LOCKFILES.filter(([f]) => fs.existsSync(path.join(project.root, f)));
|
|
27
|
+
if (!found.length) {
|
|
28
|
+
warnings.push(
|
|
29
|
+
'package.json found but no lockfile — the full dependency tree can\'t be resolved. Run `npm install --package-lock-only` (or your package manager\'s equivalent) and scan again.',
|
|
30
|
+
);
|
|
31
|
+
} else {
|
|
32
|
+
const [file, parse] = found[0];
|
|
33
|
+
if (found.length > 1 && !found[1][0].startsWith('node_modules')) {
|
|
34
|
+
warnings.push(`Multiple lockfiles found (${found.map((f) => f[0]).join(', ')}); using ${file}.`);
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
graphs.push(parse(fs.readFileSync(path.join(project.root, file), 'utf8'), project));
|
|
38
|
+
} catch (err) {
|
|
39
|
+
warnings.push(`Could not parse ${file}: ${err.message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const read = (f) => fs.readFileSync(path.join(project.root, f), 'utf8');
|
|
44
|
+
const exists = (f) => fs.existsSync(path.join(project.root, f));
|
|
45
|
+
const tryParse = (file, fn) => {
|
|
46
|
+
try {
|
|
47
|
+
graphs.push(fn());
|
|
48
|
+
} catch (err) {
|
|
49
|
+
warnings.push(`Could not parse ${file}: ${err.message}`);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// PHP
|
|
54
|
+
if (exists('composer.lock')) {
|
|
55
|
+
tryParse('composer.lock', () => parseComposerLock(read('composer.lock'), readJson(path.join(project.root, 'composer.json'))));
|
|
56
|
+
} else if (exists('composer.json')) {
|
|
57
|
+
warnings.push('composer.json found but no composer.lock — run `composer update --lock` so exact versions can be checked.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Python: prefer a real lockfile, fall back to pinned requirements files.
|
|
61
|
+
const pyproject = exists('pyproject.toml') ? read('pyproject.toml') : null;
|
|
62
|
+
const pyLock = ['uv.lock', 'poetry.lock'].find(exists);
|
|
63
|
+
if (pyLock) tryParse(pyLock, () => parseTomlLock(read(pyLock), pyLock, pyproject));
|
|
64
|
+
else if (exists('Pipfile.lock')) tryParse('Pipfile.lock', () => parsePipfileLock(read('Pipfile.lock')));
|
|
65
|
+
else {
|
|
66
|
+
const reqFiles = fs.readdirSync(project.root).filter((f) => /^requirements([._-].*)?\.(txt|in)$/i.test(f) && !f.endsWith('.in'));
|
|
67
|
+
if (reqFiles.length) {
|
|
68
|
+
tryParse(reqFiles.join(', '), () => {
|
|
69
|
+
const g = parseRequirements(reqFiles.map((name) => ({ name, text: read(name) })));
|
|
70
|
+
if (g.unpinned) warnings.push(`${g.unpinned} Python requirement(s) aren't pinned with == and were skipped — pin them or use a lockfile (uv/poetry/pip-tools).`);
|
|
71
|
+
return g;
|
|
72
|
+
});
|
|
73
|
+
} else if (pyproject) {
|
|
74
|
+
warnings.push('pyproject.toml found but no uv.lock / poetry.lock / pinned requirements — Python dependencies were not checked.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { graphs, warnings };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Check every package in every graph against OSV and turn hits into findings. */
|
|
82
|
+
export async function scanDependencies(graphs, { onProgress } = {}) {
|
|
83
|
+
const all = graphs.flatMap((g) => g.packages().map((n) => ({ node: n, graph: g, ecosystem: g.ecosystem })));
|
|
84
|
+
const unique = new Map(all.map((p) => [pkgId({ ...p.node, ecosystem: p.ecosystem }), { name: p.node.name, version: p.node.version, ecosystem: p.ecosystem }]));
|
|
85
|
+
const vulnsByPkg = await queryOsv([...unique.values()], { onProgress });
|
|
86
|
+
|
|
87
|
+
const findings = [];
|
|
88
|
+
for (const { node, graph, ecosystem } of all) {
|
|
89
|
+
const records = vulnsByPkg.get(pkgId({ ...node, ecosystem }));
|
|
90
|
+
if (!records) continue;
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
for (const rec of records) {
|
|
93
|
+
// OSV can return the same issue under several ids (GHSA + PYSEC, …) — keep one.
|
|
94
|
+
if ([rec.id, ...(rec.aliases ?? [])].some((id) => seen.has(id))) continue;
|
|
95
|
+
seen.add(rec.id);
|
|
96
|
+
for (const a of rec.aliases ?? []) seen.add(a);
|
|
97
|
+
|
|
98
|
+
const adv = normalizeVuln(rec, { name: node.name, version: node.version, ecosystem });
|
|
99
|
+
const chain = graph.pathTo(node.key);
|
|
100
|
+
findings.push({
|
|
101
|
+
id: `dep:${ecosystem}:${node.key}:${adv.id}`,
|
|
102
|
+
kind: 'dependency',
|
|
103
|
+
title: adv.summary,
|
|
104
|
+
ruleId: adv.id,
|
|
105
|
+
severity: adv.severity,
|
|
106
|
+
severityScore: adv.severityScore,
|
|
107
|
+
package: {
|
|
108
|
+
name: node.name,
|
|
109
|
+
version: node.version,
|
|
110
|
+
ecosystem,
|
|
111
|
+
lockfile: graph.lockfile,
|
|
112
|
+
direct: !!node.direct,
|
|
113
|
+
prod: !!node.prod,
|
|
114
|
+
path: chain,
|
|
115
|
+
introducedBy: [...(node.reachedBy ?? [])],
|
|
116
|
+
fixedIn: adv.fixedIn,
|
|
117
|
+
},
|
|
118
|
+
advisory: adv,
|
|
119
|
+
fix: fixText(node, adv, chain),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return findings;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function fixText(node, adv, chain) {
|
|
127
|
+
if (adv.malicious) return `Remove ${node.name} immediately, rotate any credentials on machines that installed it, and audit for compromise.`;
|
|
128
|
+
if (!adv.fixedIn) return `No patched version published. Consider replacing ${node.name} or mitigating in code.`;
|
|
129
|
+
if (node.direct || chain.length <= 1) return `Upgrade ${node.name} to ${adv.fixedIn} or later.`;
|
|
130
|
+
const top = chain[0].slice(0, chain[0].lastIndexOf('@'));
|
|
131
|
+
return `Upgrade ${node.name} to ${adv.fixedIn}+ — it comes in via ${top}; update ${top}, or force the version with "overrides" (npm) / "resolutions" (yarn) / "pnpm.overrides".`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function treeStats(graphs) {
|
|
135
|
+
let total = 0, direct = 0, dev = 0;
|
|
136
|
+
for (const g of graphs) {
|
|
137
|
+
for (const n of g.packages()) {
|
|
138
|
+
total++;
|
|
139
|
+
if (n.direct) direct++;
|
|
140
|
+
if (!n.prod) dev++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { total, direct, transitive: total - direct, devOnly: dev };
|
|
144
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { DepGraph } from '../graph.js';
|
|
2
|
+
|
|
3
|
+
const isPlatform = (name) => name === 'php' || /^(ext|lib|composer)-/.test(name) || !name.includes('/');
|
|
4
|
+
const clean = (v) => String(v).replace(/^v(?=\d)/, '');
|
|
5
|
+
|
|
6
|
+
/** composer.lock (PHP). OSV ecosystem: Packagist. */
|
|
7
|
+
export function parseComposerLock(text, composerJson) {
|
|
8
|
+
const lock = JSON.parse(text);
|
|
9
|
+
const g = new DepGraph('Packagist', 'composer.lock');
|
|
10
|
+
const byName = new Map();
|
|
11
|
+
for (const p of [...(lock.packages ?? []), ...(lock['packages-dev'] ?? [])]) {
|
|
12
|
+
byName.set(p.name.toLowerCase(), { key: g.addNode(p.name, clean(p.version)), pkg: p });
|
|
13
|
+
}
|
|
14
|
+
for (const { key, pkg } of byName.values()) {
|
|
15
|
+
for (const dep of Object.keys(pkg.require ?? {})) {
|
|
16
|
+
if (!isPlatform(dep)) g.addEdge(key, byName.get(dep.toLowerCase())?.key);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
for (const [field, dev] of [['require', false], ['require-dev', true]]) {
|
|
20
|
+
for (const name of Object.keys(composerJson?.[field] ?? {})) {
|
|
21
|
+
if (!isPlatform(name)) g.addRoot(name, byName.get(name.toLowerCase())?.key, { dev });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// No composer.json? Treat packages nothing depends on as roots.
|
|
25
|
+
if (!composerJson) {
|
|
26
|
+
const depended = new Set([...g.nodes.values()].flatMap((n) => [...n.deps]));
|
|
27
|
+
const devNames = new Set((lock['packages-dev'] ?? []).map((p) => p.name.toLowerCase()));
|
|
28
|
+
for (const [name, { key }] of byName) if (!depended.has(key)) g.addRoot(name, key, { dev: devNames.has(name) });
|
|
29
|
+
}
|
|
30
|
+
return g.annotate();
|
|
31
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { DepGraph } from '../graph.js';
|
|
2
|
+
|
|
3
|
+
/** package-lock.json / npm-shrinkwrap.json, lockfileVersion 1, 2 and 3. */
|
|
4
|
+
export function parseNpmLock(text, manifest, lockfile = 'package-lock.json') {
|
|
5
|
+
const lock = JSON.parse(text);
|
|
6
|
+
const pkgs = lock.packages ?? convertV1(lock, manifest);
|
|
7
|
+
const g = new DepGraph('npm', lockfile);
|
|
8
|
+
|
|
9
|
+
const isLocal = (p) => p !== '' && !p.includes('node_modules/');
|
|
10
|
+
const nameOf = (p) => pkgs[p]?.name || p.slice(p.lastIndexOf('node_modules/') + 'node_modules/'.length);
|
|
11
|
+
|
|
12
|
+
const realPath = (p) => {
|
|
13
|
+
const guard = new Set();
|
|
14
|
+
while (pkgs[p]?.link && !guard.has(p)) {
|
|
15
|
+
guard.add(p);
|
|
16
|
+
p = pkgs[p].resolved;
|
|
17
|
+
}
|
|
18
|
+
return pkgs[p] ? p : null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Node's resolution: look in ./node_modules, then each ancestor's node_modules.
|
|
22
|
+
const resolve = (from, name) => {
|
|
23
|
+
let base = from;
|
|
24
|
+
for (;;) {
|
|
25
|
+
const cand = (base ? base + '/' : '') + 'node_modules/' + name;
|
|
26
|
+
if (pkgs[cand]) return realPath(cand);
|
|
27
|
+
if (base === '') return null;
|
|
28
|
+
const i = base.lastIndexOf('/node_modules/');
|
|
29
|
+
base = i === -1 ? '' : base.slice(0, i);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const keys = new Map();
|
|
34
|
+
for (const [p, e] of Object.entries(pkgs)) {
|
|
35
|
+
if (p === '' || e.link) continue;
|
|
36
|
+
keys.set(p, isLocal(p) ? g.addNode(nameOf(p), 'workspace', { local: true }) : g.addNode(nameOf(p), e.version ?? '0.0.0'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const [p, e] of Object.entries(pkgs)) {
|
|
40
|
+
if (e.link) continue;
|
|
41
|
+
const declared = { ...e.peerDependencies, ...e.optionalDependencies, ...e.dependencies };
|
|
42
|
+
if (p === '' || isLocal(p)) {
|
|
43
|
+
const addRoot = (name, dev) => {
|
|
44
|
+
const target = resolve(p, name);
|
|
45
|
+
if (target) g.addRoot(name, keys.get(target), { dev, workspace: p || '.' });
|
|
46
|
+
};
|
|
47
|
+
for (const name of Object.keys(declared)) addRoot(name, false);
|
|
48
|
+
for (const name of Object.keys(e.devDependencies ?? {})) addRoot(name, true);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
for (const name of Object.keys(declared)) {
|
|
52
|
+
const target = resolve(p, name);
|
|
53
|
+
if (target && !isLocal(target)) g.addEdge(keys.get(p), keys.get(target));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return g.annotate();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Lockfile v1 nests deps under "dependencies" with "requires"; reshape it into the v2 path map. */
|
|
60
|
+
function convertV1(lock, manifest) {
|
|
61
|
+
const pkgs = {
|
|
62
|
+
'': {
|
|
63
|
+
dependencies: manifest?.dependencies,
|
|
64
|
+
optionalDependencies: manifest?.optionalDependencies,
|
|
65
|
+
devDependencies: manifest?.devDependencies,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
const walk = (deps, prefix) => {
|
|
69
|
+
for (const [name, e] of Object.entries(deps ?? {})) {
|
|
70
|
+
const p = (prefix ? prefix + '/' : '') + 'node_modules/' + name;
|
|
71
|
+
pkgs[p] = { version: e.version, dependencies: e.requires };
|
|
72
|
+
walk(e.dependencies, p);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
walk(lock.dependencies, '');
|
|
76
|
+
return pkgs;
|
|
77
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { DepGraph } from '../graph.js';
|
|
2
|
+
import { parseYaml } from '../../util/yaml.js';
|
|
3
|
+
|
|
4
|
+
/** pnpm-lock.yaml, lockfile versions 5.x, 6.x and 9.x. */
|
|
5
|
+
export function parsePnpmLock(text) {
|
|
6
|
+
const doc = parseYaml(text);
|
|
7
|
+
const ver = parseFloat(doc.lockfileVersion) || 5;
|
|
8
|
+
const section = (ver >= 9 ? doc.snapshots : doc.packages) ?? {};
|
|
9
|
+
const g = new DepGraph('npm', 'pnpm-lock.yaml');
|
|
10
|
+
|
|
11
|
+
// Turns a dependency reference ("18.2.0(react@18.2.0)", "/foo/1.0.0", "npm:bar@2.0.0") into a section key.
|
|
12
|
+
const sectionKey = (name, ref) => {
|
|
13
|
+
if (!ref || typeof ref !== 'string' || /^(link|file|workspace):/.test(ref)) return null;
|
|
14
|
+
if (ver < 6 && ref.startsWith('/')) return ref;
|
|
15
|
+
const alias = ref.replace(/^npm:/, '').replace(/^\//, '').match(/^(@?[^@(]+)@(.+)$/);
|
|
16
|
+
const [n, v] = alias ? [alias[1], alias[2]] : [name, ref];
|
|
17
|
+
if (ver >= 9) return `${n}@${v}`;
|
|
18
|
+
if (ver >= 6) return `/${n}@${v}`;
|
|
19
|
+
return `/${n}/${v}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const parseKey = (k) => {
|
|
23
|
+
let s = k.replace(/^\//, '');
|
|
24
|
+
if (ver < 6) {
|
|
25
|
+
s = s.replace(/_.*$/, '');
|
|
26
|
+
const i = s.lastIndexOf('/');
|
|
27
|
+
return { name: s.slice(0, i), version: s.slice(i + 1) };
|
|
28
|
+
}
|
|
29
|
+
s = s.replace(/\(.*$/, '');
|
|
30
|
+
const i = s.lastIndexOf('@');
|
|
31
|
+
return { name: s.slice(0, i), version: s.slice(i + 1) };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const nodeKeys = new Map();
|
|
35
|
+
for (const k of Object.keys(section)) {
|
|
36
|
+
const { name, version } = parseKey(k);
|
|
37
|
+
if (name && version) nodeKeys.set(k, g.addNode(name, version));
|
|
38
|
+
}
|
|
39
|
+
for (const [k, e] of Object.entries(section)) {
|
|
40
|
+
if (!e || typeof e !== 'object') continue;
|
|
41
|
+
for (const [dep, ref] of Object.entries({ ...e.optionalDependencies, ...e.dependencies })) {
|
|
42
|
+
g.addEdge(nodeKeys.get(k), nodeKeys.get(sectionKey(dep, ref)));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const importers = doc.importers ? Object.entries(doc.importers) : [['.', doc]];
|
|
47
|
+
for (const [workspace, imp] of importers) {
|
|
48
|
+
for (const type of ['dependencies', 'optionalDependencies', 'devDependencies']) {
|
|
49
|
+
for (const [name, val] of Object.entries(imp?.[type] ?? {})) {
|
|
50
|
+
const ref = typeof val === 'object' ? val.version : val;
|
|
51
|
+
g.addRoot(name, nodeKeys.get(sectionKey(name, ref)), { dev: type === 'devDependencies', workspace });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return g.annotate();
|
|
56
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { DepGraph } from '../graph.js';
|
|
2
|
+
import { parseToml } from '../../util/toml.js';
|
|
3
|
+
|
|
4
|
+
// PEP 503 normalisation so "PyYAML", "pyyaml" and "py_yaml" match.
|
|
5
|
+
const norm = (n) => String(n).toLowerCase().replace(/[-_.]+/g, '-');
|
|
6
|
+
const reqName = (spec) => norm(String(spec).trim().split(/[\s<>=!~;\[(@]/)[0]);
|
|
7
|
+
|
|
8
|
+
/** Direct dependency names from pyproject.toml: PEP 621 [project] and Poetry tables. */
|
|
9
|
+
export function pyprojectRoots(text) {
|
|
10
|
+
if (!text) return null;
|
|
11
|
+
let doc;
|
|
12
|
+
try {
|
|
13
|
+
doc = parseToml(text);
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const prod = new Set();
|
|
18
|
+
const dev = new Set();
|
|
19
|
+
for (const spec of doc.project?.dependencies ?? []) prod.add(reqName(spec));
|
|
20
|
+
for (const list of Object.values(doc.project?.['optional-dependencies'] ?? {})) for (const s of list) dev.add(reqName(s));
|
|
21
|
+
for (const list of Object.values(doc['dependency-groups'] ?? {})) for (const s of list) if (typeof s === 'string') dev.add(reqName(s));
|
|
22
|
+
const poetry = doc.tool?.poetry ?? {};
|
|
23
|
+
for (const n of Object.keys(poetry.dependencies ?? {})) if (n !== 'python') prod.add(norm(n));
|
|
24
|
+
for (const n of Object.keys(poetry['dev-dependencies'] ?? {})) dev.add(norm(n));
|
|
25
|
+
for (const grp of Object.values(poetry.group ?? {})) for (const n of Object.keys(grp.dependencies ?? {})) dev.add(norm(n));
|
|
26
|
+
for (const s of doc.tool?.uv?.['dev-dependencies'] ?? []) dev.add(reqName(s));
|
|
27
|
+
return { prod, dev };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function finish(g, byName, roots, depsOf) {
|
|
31
|
+
for (const [name, key] of byName) for (const d of depsOf(name)) g.addEdge(key, byName.get(norm(d)));
|
|
32
|
+
if (roots && (roots.prod.size || roots.dev.size)) {
|
|
33
|
+
for (const n of roots.prod) g.addRoot(n, byName.get(n), { dev: false });
|
|
34
|
+
for (const n of roots.dev) if (!roots.prod.has(n)) g.addRoot(n, byName.get(n), { dev: true });
|
|
35
|
+
} else {
|
|
36
|
+
const depended = new Set([...g.nodes.values()].flatMap((n) => [...n.deps]));
|
|
37
|
+
for (const [name, key] of byName) if (!depended.has(key)) g.addRoot(name, key, { dev: false });
|
|
38
|
+
}
|
|
39
|
+
return g.annotate();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** poetry.lock and uv.lock share the [[package]] layout. */
|
|
43
|
+
export function parseTomlLock(text, file, pyproject) {
|
|
44
|
+
const doc = parseToml(text);
|
|
45
|
+
const g = new DepGraph('PyPI', file);
|
|
46
|
+
const byName = new Map();
|
|
47
|
+
const pkgs = new Map();
|
|
48
|
+
for (const p of doc.package ?? []) {
|
|
49
|
+
if (p.source?.virtual || p.source?.editable) continue; // the project itself (uv)
|
|
50
|
+
byName.set(norm(p.name), g.addNode(norm(p.name), p.version));
|
|
51
|
+
pkgs.set(norm(p.name), p);
|
|
52
|
+
}
|
|
53
|
+
const depsOf = (name) => {
|
|
54
|
+
const d = pkgs.get(name)?.dependencies;
|
|
55
|
+
if (Array.isArray(d)) return d.map((x) => (typeof x === 'object' ? x.name : x));
|
|
56
|
+
return Object.keys(d ?? {});
|
|
57
|
+
};
|
|
58
|
+
let roots = pyprojectRoots(pyproject);
|
|
59
|
+
// uv records the project's own deps on its virtual/editable package entry.
|
|
60
|
+
const self = (doc.package ?? []).find((p) => p.source?.virtual || p.source?.editable);
|
|
61
|
+
if (self && !roots) {
|
|
62
|
+
roots = { prod: new Set((self.dependencies ?? []).map((d) => norm(d.name))), dev: new Set() };
|
|
63
|
+
for (const list of Object.values(self['dev-dependencies'] ?? {})) for (const d of list) roots.dev.add(norm(d.name));
|
|
64
|
+
}
|
|
65
|
+
return finish(g, byName, roots, depsOf);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Pipfile.lock: flat "default"/"develop" maps of pinned versions. */
|
|
69
|
+
export function parsePipfileLock(text) {
|
|
70
|
+
const lock = JSON.parse(text);
|
|
71
|
+
const g = new DepGraph('PyPI', 'Pipfile.lock');
|
|
72
|
+
for (const [section, dev] of [['default', false], ['develop', true]]) {
|
|
73
|
+
for (const [name, info] of Object.entries(lock[section] ?? {})) {
|
|
74
|
+
const version = String(info.version ?? '').replace(/^==/, '');
|
|
75
|
+
if (!version) continue;
|
|
76
|
+
g.addRoot(norm(name), g.addNode(norm(name), version), { dev });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return g.annotate();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** requirements*.txt — only exact pins (==) can be checked. */
|
|
83
|
+
export function parseRequirements(files) {
|
|
84
|
+
const g = new DepGraph('PyPI', files.map((f) => f.name).join(', '));
|
|
85
|
+
let unpinned = 0;
|
|
86
|
+
for (const { name: file, text } of files) {
|
|
87
|
+
const dev = /dev|test|lint|doc/i.test(file);
|
|
88
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
89
|
+
const line = raw.replace(/\s#.*$/, '').trim();
|
|
90
|
+
if (!line || line.startsWith('#') || line.startsWith('-')) continue;
|
|
91
|
+
const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*===?\s*([^\s;,]+)/.exec(line);
|
|
92
|
+
if (!m) {
|
|
93
|
+
unpinned++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
g.addRoot(norm(m[1]), g.addNode(norm(m[1]), m[2]), { dev });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
g.unpinned = unpinned;
|
|
100
|
+
return g.annotate();
|
|
101
|
+
}
|