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.
- package/LICENSE +21 -0
- package/package.json +50 -0
- package/src/activityFetcher.js +66 -0
- package/src/agent.js +506 -0
- package/src/blastRadius.js +134 -0
- package/src/configLoader.js +61 -0
- package/src/crossProjectFanOut.js +180 -0
- package/src/dashboardGenerator.js +642 -0
- package/src/executiveSummary.js +76 -0
- package/src/fleetAggregator.js +155 -0
- package/src/fleetDashboardGenerator.js +199 -0
- package/src/fleetSnapshot.js +103 -0
- package/src/freshnessChecker.js +306 -0
- package/src/freshnessPolicy.js +73 -0
- package/src/gitlabAuth.js +38 -0
- package/src/httpRetry.js +48 -0
- package/src/impactReport.js +92 -0
- package/src/mrReviewer.js +245 -0
- package/src/orbitClient.js +214 -0
- package/src/prGenerator.js +228 -0
- package/src/remoteFixer.js +129 -0
- package/src/riskCalculator.js +143 -0
- package/src/scanners/cvss.js +78 -0
- package/src/scanners/dependencyTreeBuilder.js +227 -0
- package/src/scanners/ecosystemFixers.js +371 -0
- package/src/scanners/manifestParser.js +99 -0
- package/src/scanners/osvScanner.js +228 -0
- package/src/scanners/supplyChainTrustSignals.js +472 -0
- package/src/strategyGenerator.js +384 -0
- package/src/upgradeImpactSimulator.js +241 -0
|
@@ -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
|
+
};
|