dependencyiq 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Remote (API-only) dependency fixer.
3
+ *
4
+ * Patches a manifest file in another project entirely through the
5
+ * GitLab REST API — no `git clone`, no local checkout. This is what lets
6
+ * crossProjectFanOut.js open real fixes across dozens of repositories in
7
+ * one run instead of cloning each one.
8
+ *
9
+ * Needs a real GITLAB_TOKEN (not just the automatic CI_JOB_TOKEN) for
10
+ * this to actually work in practice: CI_JOB_TOKEN's default scope is
11
+ * restricted to the project running the job, not other projects in the
12
+ * group, unless explicitly allowlisted on the target project's CI/CD
13
+ * job token settings. getAuthHeaders() still falls back to it for
14
+ * consistency, but cross-project emergency response realistically
15
+ * requires GITLAB_TOKEN to be set.
16
+ */
17
+
18
+ const axios = require('axios');
19
+ const { applyFixToContent, DEFAULT_MANIFEST } = require('./scanners/ecosystemFixers');
20
+ const { getAuthHeaders } = require('./gitlabAuth');
21
+
22
+ const GITLAB_API = process.env.GITLAB_API_URL
23
+ || (process.env.GITLAB_BASE_URL ? `${process.env.GITLAB_BASE_URL}/api/v4` : 'https://gitlab.com/api/v4');
24
+
25
+ function apiConfig() {
26
+ return { headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' } };
27
+ }
28
+
29
+ /**
30
+ * GET the raw content of a file in a project's default branch via the
31
+ * Repository Files API. Returns null (not an empty string) if the file
32
+ * doesn't exist there, so callers can distinguish "no such manifest" from
33
+ * "empty manifest".
34
+ */
35
+ async function getRemoteFileContent(projectId, filePath, ref = 'main') {
36
+ try {
37
+ const response = await axios.get(
38
+ `${GITLAB_API}/projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(filePath)}/raw`,
39
+ { params: { ref }, headers: apiConfig().headers, timeout: 10000, responseType: 'text' }
40
+ );
41
+ return response.data;
42
+ } catch (error) {
43
+ if (error.response?.status === 404) return null;
44
+ throw error;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Find the right manifest file for an ecosystem in a remote project. Tries
50
+ * the default manifest name for that ecosystem; doesn't guess at
51
+ * non-default layouts (monorepos with manifests in subdirectories need
52
+ * the caller to pass manifestPath explicitly, e.g. from an Orbit File
53
+ * node's path).
54
+ */
55
+ async function findRemoteManifest(projectId, ecosystem, manifestPath, ref = 'main') {
56
+ const candidate = manifestPath || DEFAULT_MANIFEST[ecosystem];
57
+ if (!candidate) return { found: false, reason: `No default manifest path known for ecosystem "${ecosystem}"` };
58
+
59
+ const content = await getRemoteFileContent(projectId, candidate, ref);
60
+ if (content === null) return { found: false, reason: `${candidate} not found on ${ref}` };
61
+ return { found: true, path: candidate, content };
62
+ }
63
+
64
+ /**
65
+ * Patch a manifest in a remote project and commit the change directly to
66
+ * a new branch — no local checkout. Does NOT open the merge request
67
+ * itself; that's a separate step (crossProjectFanOut.js calls
68
+ * prGenerator's remote MR helper after this succeeds) so a dry run can
69
+ * inspect the diff before anything is opened.
70
+ *
71
+ * @returns {Object} { applied, branch, filePath, action, followUp, warning }
72
+ */
73
+ async function applyRemoteFix(projectId, vulnerability, branchName) {
74
+ const manifest = await findRemoteManifest(projectId, vulnerability.ecosystem, vulnerability.manifestFile);
75
+ if (!manifest.found) {
76
+ return { applied: false, warning: manifest.reason };
77
+ }
78
+
79
+ const result = applyFixToContent(
80
+ vulnerability.ecosystem,
81
+ manifest.content,
82
+ vulnerability.package,
83
+ vulnerability.fixedVersion,
84
+ vulnerability.recommendation
85
+ );
86
+
87
+ if (!result.touched) {
88
+ return { applied: false, warning: result.warning };
89
+ }
90
+
91
+ try {
92
+ await axios.post(
93
+ `${GITLAB_API}/projects/${encodeURIComponent(projectId)}/repository/branches`,
94
+ { branch: branchName, ref: 'main' },
95
+ apiConfig()
96
+ );
97
+ } catch (error) {
98
+ if (error.response?.status !== 400) throw error; // 400 = branch already exists, continue
99
+ }
100
+
101
+ const commitMessage = result.action === 'remove'
102
+ ? `chore: remove unused dependency ${vulnerability.package} (${vulnerability.id}, Orbit found zero importers)`
103
+ : `security: upgrade ${vulnerability.package} to ${vulnerability.fixedVersion} (${vulnerability.id})`;
104
+
105
+ await axios.post(
106
+ `${GITLAB_API}/projects/${encodeURIComponent(projectId)}/repository/commits`,
107
+ {
108
+ branch: branchName,
109
+ commit_message: commitMessage,
110
+ actions: [{ action: 'update', file_path: manifest.path, content: result.content }],
111
+ },
112
+ apiConfig()
113
+ );
114
+
115
+ return {
116
+ applied: true,
117
+ branch: branchName,
118
+ filePath: manifest.path,
119
+ action: result.action || 'upgrade',
120
+ followUp: result.followUp,
121
+ warning: result.warning || null,
122
+ };
123
+ }
124
+
125
+ module.exports = {
126
+ getRemoteFileContent,
127
+ findRemoteManifest,
128
+ applyRemoteFix,
129
+ };
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Risk Scoring Calculator
3
+ * Calculates remediation risk based on CVSS, exposure, and effort
4
+ */
5
+
6
+ /**
7
+ * Calculate risk score for a vulnerability
8
+ * @param {Object} vulnerability - Vulnerability object
9
+ * @param {Object} usageAnalysis - Usage analysis from Orbit
10
+ * @returns {Object} Risk score and breakdown
11
+ */
12
+ // Weights are the single source of truth for the score: the README,
13
+ // the dashboard "Decision trail", and this calculation all reference
14
+ // these exact numbers. The score is a plain weighted sum minus a
15
+ // test-coverage discount — no hidden multipliers — so the displayed
16
+ // breakdown always adds up to the final score. That additivity is the
17
+ // whole point: a "show your work" tool whose components didn't sum to
18
+ // its own answer would be self-refuting.
19
+ const WEIGHTS = { cvss: 0.45, exposure: 0.35, usage: 0.10, testCoverageDiscount: 0.10 };
20
+
21
+ function clamp01(n) {
22
+ return Math.min(Math.max(n, 0), 1);
23
+ }
24
+
25
+ function calculateRiskScore(vulnerability, usageAnalysis = {}) {
26
+ const defaults = {
27
+ affectedFilesCount: 0,
28
+ usageCount: 0,
29
+ isInPublicAPI: false,
30
+ isInBusinessLogic: false,
31
+ testCoverage: 0.5,
32
+ };
33
+
34
+ const analysis = { ...defaults, ...usageAnalysis };
35
+ const recommendation = analysis.recommendation || 'upgrade';
36
+ const exposureDataSource = analysis.exposureDataSource || 'unavailable';
37
+
38
+ const cvss_norm = clamp01((vulnerability.cvss ?? 0) / 10);
39
+
40
+ // Exposure: 0 means 0. A package imported by no files, in no public API,
41
+ // in no business logic, scores zero exposure — not a baseline floor.
42
+ // And when Orbit is UNAVAILABLE we have no exposure data at all, so it
43
+ // contributes nothing (the score becomes purely CVSS-driven). This is
44
+ // exactly what the dashboard footer claims happens — now it's true,
45
+ // rather than the old behaviour of fabricating ~34% exposure from
46
+ // default multipliers and mislabelling it as real.
47
+ const filesWeight = Math.min(analysis.affectedFilesCount / 10, 1.0);
48
+ const exposure_norm = exposureDataSource === 'orbit'
49
+ ? clamp01(filesWeight * 0.5 + (analysis.isInPublicAPI ? 0.35 : 0) + (analysis.isInBusinessLogic ? 0.15 : 0))
50
+ : 0;
51
+
52
+ // Usage: log-scaled count of importing files, 0 importers → 0.
53
+ const usage_norm = analysis.usageCount > 0
54
+ ? Math.min(Math.log(1 + analysis.usageCount) / Math.log(1 + 100), 1.0)
55
+ : 0;
56
+
57
+ // Exact point contributions (out of 100). These SUM to the raw score,
58
+ // so the calculator — not the view — owns the arithmetic; the
59
+ // dashboard just displays these numbers.
60
+ const contributions = {
61
+ cvss: cvss_norm * WEIGHTS.cvss * 100,
62
+ exposure: exposure_norm * WEIGHTS.exposure * 100,
63
+ usage: usage_norm * WEIGHTS.usage * 100,
64
+ testCoverageDiscount: analysis.testCoverage * WEIGHTS.testCoverageDiscount * 100,
65
+ };
66
+
67
+ const rawScore = contributions.cvss + contributions.exposure + contributions.usage - contributions.testCoverageDiscount;
68
+ const score = Math.round(Math.min(Math.max(rawScore, 0), 100));
69
+
70
+ return {
71
+ score,
72
+ severity: Math.round(cvss_norm * 100),
73
+ exposure: Math.round(exposure_norm * 100),
74
+ usage: Math.round(usage_norm * 100),
75
+ isInPublicAPI: analysis.isInPublicAPI,
76
+ // UNUSED overrides the score-based bucket: Orbit confirmed nothing in
77
+ // the project imports this package, so "delete it" outranks any
78
+ // CVSS-driven urgency — there's no upgrade to prioritize.
79
+ priority: recommendation === 'remove' ? 'UNUSED'
80
+ : score >= 80 ? 'URGENT' : score >= 50 ? 'HIGH' : score >= 20 ? 'MEDIUM' : 'LOW',
81
+ recommendation,
82
+ // 'orbit' = real blast-radius data from GitLab Orbit; 'unavailable' = Orbit
83
+ // unreachable/disabled, exposure contributes 0 rather than being guessed.
84
+ exposureDataSource,
85
+ breakdown: {
86
+ cvss: cvss_norm,
87
+ exposure: exposure_norm,
88
+ usage: usage_norm,
89
+ testCoverage: analysis.testCoverage,
90
+ // Exact weighted point contributions — what the decision trail shows.
91
+ contributions,
92
+ weights: WEIGHTS,
93
+ },
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Sort vulnerabilities by risk score
99
+ * @param {Array} vulnerabilities - Vulnerabilities with risk scores
100
+ * @returns {Array} Sorted vulnerabilities (highest risk first)
101
+ */
102
+ function sortByRisk(vulnerabilities) {
103
+ // Plain score sort: UNUSED items naturally score low (zero exposure) and
104
+ // sink to the bottom of the *risk* ranking — correct, since they're not
105
+ // risky, they're free cleanup. Surface them as a separate "cleanup
106
+ // opportunities" list (see strategyGenerator) rather than reordering
107
+ // real risk by priority label.
108
+ return vulnerabilities.sort((a, b) => (b.riskScore?.score || 0) - (a.riskScore?.score || 0));
109
+ }
110
+
111
+ /**
112
+ * Vulnerabilities where Orbit confirmed zero importers — candidates for
113
+ * removal rather than upgrade.
114
+ * @param {Array} vulnerabilities - vulnerabilities with risk scores
115
+ * @returns {Array} subset flagged UNUSED
116
+ */
117
+ function findUnusedDependencies(vulnerabilities) {
118
+ return vulnerabilities.filter(v => v.riskScore?.priority === 'UNUSED');
119
+ }
120
+
121
+ /**
122
+ * Filter vulnerabilities by priority
123
+ * @param {Array} vulnerabilities - Vulnerabilities with risk scores
124
+ * @param {string} priority - 'URGENT', 'HIGH', 'MEDIUM', 'LOW'
125
+ * @returns {Array} Filtered vulnerabilities
126
+ */
127
+ function filterByPriority(vulnerabilities, priority) {
128
+ const priorityMap = { URGENT: 80, HIGH: 50, MEDIUM: 20, LOW: 0 };
129
+ const minScore = priorityMap[priority] || 0;
130
+ const maxScore = priorityMap[Object.keys(priorityMap)[Object.values(priorityMap).indexOf(minScore) - 1]] || 100;
131
+
132
+ return vulnerabilities.filter(v => {
133
+ const score = v.riskScore?.score || 0;
134
+ return score >= minScore && score < maxScore;
135
+ });
136
+ }
137
+
138
+ module.exports = {
139
+ calculateRiskScore,
140
+ sortByRisk,
141
+ filterByPriority,
142
+ findUnusedDependencies
143
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * CVSS base-score computation from a vector string.
3
+ *
4
+ * OSV advisories very often carry a CVSS *vector*
5
+ * (e.g. "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") rather than a
6
+ * bare numeric score. The vector encodes the exact base score via a
7
+ * published, deterministic formula — so computing it is real data, not
8
+ * a guess. The previous behaviour threw the vector away and fell back to
9
+ * a severity-bucket midpoint (HIGH → 7.5), which loses precision and can
10
+ * mis-rank findings (a 9.8 and a 7.1 both collapse to "HIGH/7.5").
11
+ *
12
+ * Implements the CVSS v3.0/v3.1 base-score spec
13
+ * (https://www.first.org/cvss/v3.1/specification-document). v4.0 vectors
14
+ * are detected but not scored here (the v4 formula is substantially
15
+ * different); callers fall back to the severity bucket for those, which
16
+ * is flagged honestly rather than approximated wrongly.
17
+ */
18
+
19
+ const METRIC_VALUES = {
20
+ AV: { N: 0.85, A: 0.62, L: 0.55, P: 0.2 },
21
+ AC: { L: 0.77, H: 0.44 },
22
+ // Privileges Required is scope-dependent; both variants kept.
23
+ PR: { N: 0.85, L: 0.62, H: 0.27 },
24
+ PR_changed: { N: 0.85, L: 0.68, H: 0.5 },
25
+ UI: { N: 0.85, R: 0.62 },
26
+ C: { H: 0.56, L: 0.22, N: 0 },
27
+ I: { H: 0.56, L: 0.22, N: 0 },
28
+ A: { H: 0.56, L: 0.22, N: 0 },
29
+ };
30
+
31
+ function parseVector(vector) {
32
+ const parts = {};
33
+ for (const segment of vector.split('/')) {
34
+ const [key, value] = segment.split(':');
35
+ if (key && value) parts[key] = value;
36
+ }
37
+ return parts;
38
+ }
39
+
40
+ function roundUp1(n) {
41
+ // CVSS spec's "Roundup": round to 1 decimal, always up at the boundary.
42
+ return Math.ceil(n * 10) / 10;
43
+ }
44
+
45
+ /**
46
+ * @param {string} vector - a CVSS:3.0 or CVSS:3.1 vector string
47
+ * @returns {number|null} base score 0.0-10.0, or null if not a v3 vector
48
+ * or missing required metrics (caller should fall back, not guess).
49
+ */
50
+ function cvss3BaseScore(vector) {
51
+ if (!/^CVSS:3\.[01]\//.test(vector)) return null;
52
+ const m = parseVector(vector);
53
+
54
+ const scopeChanged = m.S === 'C';
55
+ const av = METRIC_VALUES.AV[m.AV];
56
+ const ac = METRIC_VALUES.AC[m.AC];
57
+ const pr = (scopeChanged ? METRIC_VALUES.PR_changed : METRIC_VALUES.PR)[m.PR];
58
+ const ui = METRIC_VALUES.UI[m.UI];
59
+ const c = METRIC_VALUES.C[m.C];
60
+ const i = METRIC_VALUES.I[m.I];
61
+ const a = METRIC_VALUES.A[m.A];
62
+
63
+ if ([av, ac, pr, ui, c, i, a].some(x => x === undefined)) return null;
64
+
65
+ const iss = 1 - (1 - c) * (1 - i) * (1 - a);
66
+ const impact = scopeChanged
67
+ ? 7.52 * (iss - 0.029) - 3.25 * (iss - 0.02) ** 15
68
+ : 6.42 * iss;
69
+ const exploitability = 8.22 * av * ac * pr * ui;
70
+
71
+ if (impact <= 0) return 0;
72
+ const base = scopeChanged
73
+ ? Math.min(1.08 * (impact + exploitability), 10)
74
+ : Math.min(impact + exploitability, 10);
75
+ return roundUp1(base);
76
+ }
77
+
78
+ module.exports = { cvss3BaseScore };
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Dependency tree resolution: is a vulnerable package a direct
3
+ * dependency, or pulled in transitively — and if transitive, through
4
+ * which chain ("axios → follow-redirects")?
5
+ *
6
+ * Two ecosystems get a real BFS, because both have a lockfile that
7
+ * records each resolved package's own direct dependency list:
8
+ * - npm: package-lock.json (lockfileVersion 2/3) has a flat `packages`
9
+ * map keyed by path ("node_modules/<name>").
10
+ * - PyPI (Poetry projects): poetry.lock has a `[[package]]` per
11
+ * resolved dependency with a `[package.dependencies]` table; the
12
+ * *direct* dependency set itself lives in pyproject.toml's
13
+ * `[tool.poetry.dependencies]` (poetry.lock doesn't mark which
14
+ * packages are direct vs transitive — pyproject.toml does).
15
+ *
16
+ * Other ecosystems (Go, Maven, plain pip requirements.txt with no
17
+ * Poetry lockfile) don't get a fabricated chain here — they report
18
+ * `chain: null` ("unknown") rather than guessing, since reconstructing
19
+ * their dependency graphs needs ecosystem-specific tooling (go mod
20
+ * graph, mvn dependency:tree) this project doesn't shell out to.
21
+ *
22
+ * Simplification worth stating plainly: the npm path looks up a child
23
+ * package by its hoisted top-level path (`node_modules/<name>`), not by
24
+ * resolving nested/duplicated versions (`node_modules/<parent>/node_modules/<name>`).
25
+ * npm hoists by default, so this resolves correctly for the vast
26
+ * majority of real lockfiles; a version conflict that forces nesting
27
+ * could occasionally make a true chain look like "unknown" instead of
28
+ * wrong — never the reverse, since we only report a chain we actually
29
+ * found, never a guessed one.
30
+ */
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+ const toml = require('toml');
35
+
36
+ const MAX_BFS_DEPTH = 12;
37
+
38
+ function loadNpmLockfile(repoPath) {
39
+ const lockPath = path.join(repoPath, 'package-lock.json');
40
+ if (!fs.existsSync(lockPath)) return null;
41
+ try {
42
+ return JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * @param {Object} lockfile - parsed package-lock.json
50
+ * @returns {Object|null} { directDeps: string[], lookup: Map<name, depNames[]> }
51
+ */
52
+ function buildNpmGraph(lockfile) {
53
+ if (!lockfile?.packages) return null;
54
+ const root = lockfile.packages[''];
55
+ if (!root) return null;
56
+
57
+ const directDeps = [
58
+ ...Object.keys(root.dependencies || {}),
59
+ ...Object.keys(root.devDependencies || {}),
60
+ ];
61
+
62
+ const lookup = new Map();
63
+ for (const [pkgPath, entry] of Object.entries(lockfile.packages)) {
64
+ const name = pkgPath.startsWith('node_modules/') ? pkgPath.slice('node_modules/'.length) : pkgPath;
65
+ // Only index top-level (hoisted) entries — see module docstring.
66
+ const isTopLevelPackage = pkgPath !== '' && !name.includes('node_modules/');
67
+ if (isTopLevelPackage) {
68
+ lookup.set(name, Object.keys(entry.dependencies || {}));
69
+ }
70
+ }
71
+
72
+ return { directDeps, lookup };
73
+ }
74
+
75
+ /**
76
+ * BFS from every direct dependency to find the shortest chain to
77
+ * targetPackage. Returns the chain array (direct dep first, target
78
+ * last) or null if not found within MAX_BFS_DEPTH.
79
+ */
80
+ function findShortestChain(graph, targetPackage) {
81
+ for (const start of graph.directDeps) {
82
+ if (start === targetPackage) return [start];
83
+ }
84
+
85
+ const queue = graph.directDeps.map(start => [start]);
86
+ const visited = new Set(graph.directDeps);
87
+
88
+ while (queue.length > 0) {
89
+ const chain = queue.shift();
90
+ const withinDepth = chain.length <= MAX_BFS_DEPTH;
91
+ const current = chain[chain.length - 1];
92
+ const children = withinDepth ? (graph.lookup.get(current) || []) : [];
93
+ for (const child of children) {
94
+ if (child === targetPackage) return [...chain, child];
95
+ if (!visited.has(child)) {
96
+ visited.add(child);
97
+ queue.push([...chain, child]);
98
+ }
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ // PEP 503 normalization: PyPI treats "Foo_Bar", "foo.bar", and "foo-bar"
105
+ // as the same project. poetry.lock and pyproject.toml aren't always
106
+ // written with consistent casing/separators, so every name is normalized
107
+ // before it's used as a graph key or compared.
108
+ function normalizePyPiName(name) {
109
+ return String(name).toLowerCase().replace(/[-_.]+/g, '-');
110
+ }
111
+
112
+ function loadToml(filePath) {
113
+ if (!fs.existsSync(filePath)) return null;
114
+ try {
115
+ return toml.parse(fs.readFileSync(filePath, 'utf-8'));
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * @returns {string[]|null} normalized direct dependency names declared in
123
+ * pyproject.toml's [tool.poetry.dependencies] (+ dependency groups),
124
+ * or null if pyproject.toml is missing/malformed/not a Poetry project
125
+ */
126
+ function loadPoetryDirectDeps(repoPath) {
127
+ const parsed = loadToml(path.join(repoPath, 'pyproject.toml'));
128
+ const poetry = parsed?.tool?.poetry;
129
+ if (!poetry) return null;
130
+
131
+ const names = new Set();
132
+ for (const name of Object.keys(poetry.dependencies || {})) {
133
+ if (name.toLowerCase() !== 'python') names.add(normalizePyPiName(name));
134
+ }
135
+ for (const group of Object.values(poetry.group || {})) {
136
+ for (const name of Object.keys(group?.dependencies || {})) {
137
+ names.add(normalizePyPiName(name));
138
+ }
139
+ }
140
+ return [...names];
141
+ }
142
+
143
+ /**
144
+ * @returns {Object|null} { directDeps, lookup } in the same shape
145
+ * findShortestChain() already consumes for npm, built from poetry.lock
146
+ */
147
+ function buildPoetryGraph(repoPath) {
148
+ const lockfile = loadToml(path.join(repoPath, 'poetry.lock'));
149
+ const directDeps = loadPoetryDirectDeps(repoPath);
150
+ if (!lockfile?.package || !directDeps) return null;
151
+
152
+ const lookup = new Map();
153
+ for (const pkg of lockfile.package) {
154
+ if (pkg?.name) {
155
+ const deps = Object.keys(pkg.dependencies || {}).map(normalizePyPiName);
156
+ lookup.set(normalizePyPiName(pkg.name), deps);
157
+ }
158
+ }
159
+
160
+ return { directDeps, lookup };
161
+ }
162
+
163
+ /**
164
+ * @param {string} repoPath
165
+ * @param {string} ecosystem
166
+ * @param {string} packageName
167
+ * @returns {Object} { available, isDirect, chain } — chain is null when
168
+ * not determinable (unsupported ecosystem, no lockfile, or not found
169
+ * within the BFS depth limit)
170
+ */
171
+ function resolveDependencyChain(repoPath, ecosystem, packageName) {
172
+ if (ecosystem === 'npm') {
173
+ const lockfile = loadNpmLockfile(repoPath);
174
+ if (!lockfile) {
175
+ return { available: false, isDirect: null, chain: null, reason: 'package-lock.json not found or unreadable' };
176
+ }
177
+ const graph = buildNpmGraph(lockfile);
178
+ if (!graph) {
179
+ return { available: false, isDirect: null, chain: null, reason: 'Unsupported or malformed lockfile format' };
180
+ }
181
+ if (graph.directDeps.includes(packageName)) {
182
+ return { available: true, isDirect: true, chain: [packageName] };
183
+ }
184
+ return { available: true, isDirect: false, chain: findShortestChain(graph, packageName) };
185
+ }
186
+
187
+ if (ecosystem === 'PyPI') {
188
+ const graph = buildPoetryGraph(repoPath);
189
+ if (!graph) {
190
+ return { available: false, isDirect: null, chain: null, reason: 'poetry.lock + pyproject.toml not found, unreadable, or not a Poetry project — plain requirements.txt has no recorded dependency graph' };
191
+ }
192
+ const target = normalizePyPiName(packageName);
193
+ if (graph.directDeps.includes(target)) {
194
+ return { available: true, isDirect: true, chain: [packageName] };
195
+ }
196
+ const chain = findShortestChain(graph, target);
197
+ return { available: true, isDirect: false, chain };
198
+ }
199
+
200
+ return { available: false, isDirect: null, chain: null, reason: `Chain resolution only implemented for npm and Poetry-based PyPI projects, not ${ecosystem}` };
201
+ }
202
+
203
+ /**
204
+ * Look up one package's lockfile entry (installed version + the URL it
205
+ * actually resolved from) — the `resolved` field npm writes per package
206
+ * in lockfileVersion 2/3. Used by the Supply-Chain Trust signals to tell
207
+ * "resolved from the public registry" apart from "resolved from a
208
+ * private registry" (the dependency-confusion check).
209
+ * @returns {Object|null} { version, resolved } or null if not found/no lockfile
210
+ */
211
+ function getLockfileEntry(repoPath, packageName) {
212
+ const lockfile = loadNpmLockfile(repoPath);
213
+ if (!lockfile?.packages) return null;
214
+ const entry = lockfile.packages[`node_modules/${packageName}`];
215
+ if (!entry) return null;
216
+ return { version: entry.version || null, resolved: entry.resolved || null };
217
+ }
218
+
219
+ module.exports = {
220
+ resolveDependencyChain,
221
+ buildNpmGraph,
222
+ findShortestChain,
223
+ getLockfileEntry,
224
+ normalizePyPiName,
225
+ loadPoetryDirectDeps,
226
+ buildPoetryGraph,
227
+ };