@trustify-da/trustify-da-javascript-client 0.3.0-ea.29f6867 → 0.3.0-ea.38515a7
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/README.md +151 -13
- package/dist/package.json +10 -4
- package/dist/src/analysis.d.ts +16 -0
- package/dist/src/analysis.js +53 -4
- package/dist/src/batch_opts.d.ts +24 -0
- package/dist/src/batch_opts.js +35 -0
- package/dist/src/cli.js +121 -3
- package/dist/src/cyclone_dx_sbom.d.ts +7 -0
- package/dist/src/cyclone_dx_sbom.js +16 -1
- package/dist/src/index.d.ts +64 -1
- package/dist/src/index.js +267 -4
- package/dist/src/license/licenses_api.js +9 -2
- package/dist/src/license/project_license.d.ts +0 -8
- package/dist/src/license/project_license.js +0 -11
- package/dist/src/oci_image/utils.js +11 -2
- package/dist/src/provider.d.ts +6 -3
- package/dist/src/provider.js +12 -5
- package/dist/src/providers/base_javascript.d.ts +19 -3
- package/dist/src/providers/base_javascript.js +99 -18
- package/dist/src/providers/base_pyproject.d.ts +147 -0
- package/dist/src/providers/base_pyproject.js +279 -0
- package/dist/src/providers/golang_gomodules.d.ts +12 -12
- package/dist/src/providers/golang_gomodules.js +100 -111
- package/dist/src/providers/gomod_parser.d.ts +4 -0
- package/dist/src/providers/gomod_parser.js +16 -0
- package/dist/src/providers/javascript_pnpm.d.ts +1 -1
- package/dist/src/providers/javascript_pnpm.js +2 -2
- package/dist/src/providers/manifest.d.ts +2 -0
- package/dist/src/providers/manifest.js +22 -4
- package/dist/src/providers/processors/yarn_berry_processor.js +82 -3
- package/dist/src/providers/python_pip.js +1 -1
- package/dist/src/providers/python_poetry.d.ts +42 -0
- package/dist/src/providers/python_poetry.js +146 -0
- package/dist/src/providers/python_uv.d.ts +26 -0
- package/dist/src/providers/python_uv.js +118 -0
- package/dist/src/providers/rust_cargo.d.ts +52 -0
- package/dist/src/providers/rust_cargo.js +614 -0
- package/dist/src/providers/tree-sitter-gomod.wasm +0 -0
- package/dist/src/sbom.d.ts +7 -0
- package/dist/src/sbom.js +9 -0
- package/dist/src/workspace.d.ts +61 -0
- package/dist/src/workspace.js +256 -0
- package/package.json +11 -5
package/dist/src/cli.js
CHANGED
|
@@ -12,10 +12,18 @@ const component = {
|
|
|
12
12
|
desc: 'manifest path for analyzing',
|
|
13
13
|
type: 'string',
|
|
14
14
|
normalize: true,
|
|
15
|
+
}).options({
|
|
16
|
+
workspaceDir: {
|
|
17
|
+
alias: 'w',
|
|
18
|
+
desc: 'Workspace root directory (for monorepos; lock file is expected here)',
|
|
19
|
+
type: 'string',
|
|
20
|
+
normalize: true,
|
|
21
|
+
}
|
|
15
22
|
}),
|
|
16
23
|
handler: async (args) => {
|
|
17
24
|
let manifestName = args['/path/to/manifest'];
|
|
18
|
-
|
|
25
|
+
const opts = args.workspaceDir ? { TRUSTIFY_DA_WORKSPACE_DIR: args.workspaceDir } : {};
|
|
26
|
+
let res = await client.componentAnalysis(manifestName, opts);
|
|
19
27
|
console.log(JSON.stringify(res, null, 2));
|
|
20
28
|
}
|
|
21
29
|
};
|
|
@@ -117,15 +125,22 @@ const stack = {
|
|
|
117
125
|
desc: 'For JSON report, get only the \'summary\'',
|
|
118
126
|
type: 'boolean',
|
|
119
127
|
conflicts: 'html'
|
|
128
|
+
},
|
|
129
|
+
workspaceDir: {
|
|
130
|
+
alias: 'w',
|
|
131
|
+
desc: 'Workspace root directory (for monorepos; lock file is expected here)',
|
|
132
|
+
type: 'string',
|
|
133
|
+
normalize: true,
|
|
120
134
|
}
|
|
121
135
|
}),
|
|
122
136
|
handler: async (args) => {
|
|
123
137
|
let manifest = args['/path/to/manifest'];
|
|
124
138
|
let html = args['html'];
|
|
125
139
|
let summary = args['summary'];
|
|
140
|
+
const opts = args.workspaceDir ? { TRUSTIFY_DA_WORKSPACE_DIR: args.workspaceDir } : {};
|
|
126
141
|
let theProvidersSummary = new Map();
|
|
127
142
|
let theProvidersObject = {};
|
|
128
|
-
let res = await client.stackAnalysis(manifest, html);
|
|
143
|
+
let res = await client.stackAnalysis(manifest, html, opts);
|
|
129
144
|
if (summary) {
|
|
130
145
|
for (let provider in res.providers) {
|
|
131
146
|
if (res.providers[provider].sources !== undefined) {
|
|
@@ -143,6 +158,108 @@ const stack = {
|
|
|
143
158
|
console.log(html ? res : JSON.stringify(!html && summary ? theProvidersObject : res, null, 2));
|
|
144
159
|
}
|
|
145
160
|
};
|
|
161
|
+
// command for batch stack analysis (workspace)
|
|
162
|
+
const stackBatch = {
|
|
163
|
+
command: 'stack-batch </path/to/workspace-root> [--html|--summary] [--concurrency <n>] [--ignore <pattern>...] [--metadata] [--fail-fast]',
|
|
164
|
+
desc: 'produce stack report for all packages/crates in a workspace (Cargo or JS/TS)',
|
|
165
|
+
builder: yargs => yargs.positional('/path/to/workspace-root', {
|
|
166
|
+
desc: 'workspace root directory (containing Cargo.toml+Cargo.lock or package.json+lock file)',
|
|
167
|
+
type: 'string',
|
|
168
|
+
normalize: true,
|
|
169
|
+
}).options({
|
|
170
|
+
html: {
|
|
171
|
+
alias: 'r',
|
|
172
|
+
desc: 'Get the report as HTML instead of JSON',
|
|
173
|
+
type: 'boolean',
|
|
174
|
+
conflicts: 'summary'
|
|
175
|
+
},
|
|
176
|
+
summary: {
|
|
177
|
+
alias: 's',
|
|
178
|
+
desc: 'For JSON report, get only the \'summary\' per package',
|
|
179
|
+
type: 'boolean',
|
|
180
|
+
conflicts: 'html'
|
|
181
|
+
},
|
|
182
|
+
concurrency: {
|
|
183
|
+
alias: 'c',
|
|
184
|
+
desc: 'Max parallel SBOM generations (default: 10, env: TRUSTIFY_DA_BATCH_CONCURRENCY)',
|
|
185
|
+
type: 'number',
|
|
186
|
+
},
|
|
187
|
+
ignore: {
|
|
188
|
+
alias: 'i',
|
|
189
|
+
desc: 'Extra glob patterns excluded from workspace discovery (merged with defaults). Repeat flag per pattern. Env: TRUSTIFY_DA_WORKSPACE_DISCOVERY_IGNORE (comma-separated)',
|
|
190
|
+
type: 'string',
|
|
191
|
+
array: true,
|
|
192
|
+
},
|
|
193
|
+
metadata: {
|
|
194
|
+
alias: 'm',
|
|
195
|
+
desc: 'Return { analysis, metadata } with per-manifest errors (env: TRUSTIFY_DA_BATCH_METADATA=true)',
|
|
196
|
+
type: 'boolean',
|
|
197
|
+
default: false,
|
|
198
|
+
},
|
|
199
|
+
failFast: {
|
|
200
|
+
desc: 'Stop on first invalid package.json or SBOM error (env: TRUSTIFY_DA_CONTINUE_ON_ERROR=false)',
|
|
201
|
+
type: 'boolean',
|
|
202
|
+
default: false,
|
|
203
|
+
}
|
|
204
|
+
}),
|
|
205
|
+
handler: async (args) => {
|
|
206
|
+
const workspaceRoot = args['/path/to/workspace-root'];
|
|
207
|
+
const html = args['html'];
|
|
208
|
+
const summary = args['summary'];
|
|
209
|
+
const opts = {};
|
|
210
|
+
if (args.concurrency != null) {
|
|
211
|
+
opts.batchConcurrency = args.concurrency;
|
|
212
|
+
}
|
|
213
|
+
const extraIgnores = Array.isArray(args.ignore) ? args.ignore.filter(p => p != null && String(p).trim()) : [];
|
|
214
|
+
if (extraIgnores.length > 0) {
|
|
215
|
+
opts.workspaceDiscoveryIgnore = extraIgnores;
|
|
216
|
+
}
|
|
217
|
+
if (args.metadata) {
|
|
218
|
+
opts.batchMetadata = true;
|
|
219
|
+
}
|
|
220
|
+
if (args.failFast) {
|
|
221
|
+
opts.continueOnError = false;
|
|
222
|
+
}
|
|
223
|
+
let res = await client.stackAnalysisBatch(workspaceRoot, html, opts);
|
|
224
|
+
const batchAnalysis = res && typeof res === 'object' && res != null && 'analysis' in res ? res.analysis : res;
|
|
225
|
+
if (summary && !html && typeof batchAnalysis === 'object') {
|
|
226
|
+
const summaries = {};
|
|
227
|
+
for (const [purl, report] of Object.entries(batchAnalysis)) {
|
|
228
|
+
if (report?.providers) {
|
|
229
|
+
for (const provider of Object.keys(report.providers)) {
|
|
230
|
+
const sources = report.providers[provider]?.sources;
|
|
231
|
+
if (sources) {
|
|
232
|
+
for (const [source, data] of Object.entries(sources)) {
|
|
233
|
+
if (data?.summary) {
|
|
234
|
+
if (!summaries[purl]) {
|
|
235
|
+
summaries[purl] = {};
|
|
236
|
+
}
|
|
237
|
+
if (!summaries[purl][provider]) {
|
|
238
|
+
summaries[purl][provider] = {};
|
|
239
|
+
}
|
|
240
|
+
summaries[purl][provider][source] = data.summary;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (res && typeof res === 'object' && res != null && 'metadata' in res) {
|
|
248
|
+
res = { analysis: summaries, metadata: res.metadata };
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
res = summaries;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (html) {
|
|
255
|
+
const htmlContent = res && typeof res === 'object' && 'analysis' in res ? res.analysis : res;
|
|
256
|
+
console.log(htmlContent);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
console.log(JSON.stringify(res, null, 2));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
146
263
|
// command for license checking
|
|
147
264
|
const license = {
|
|
148
265
|
command: 'license </path/to/manifest>',
|
|
@@ -210,8 +327,9 @@ const license = {
|
|
|
210
327
|
};
|
|
211
328
|
// parse and invoke the command
|
|
212
329
|
yargs(hideBin(process.argv))
|
|
213
|
-
.usage(`Usage: ${process.argv[0].includes("node") ? path.parse(process.argv[1]).base : path.parse(process.argv[0]).base} {component|stack|image|validate-token|license}`)
|
|
330
|
+
.usage(`Usage: ${process.argv[0].includes("node") ? path.parse(process.argv[1]).base : path.parse(process.argv[0]).base} {component|stack|stack-batch|image|validate-token|license}`)
|
|
214
331
|
.command(stack)
|
|
332
|
+
.command(stackBatch)
|
|
215
333
|
.command(component)
|
|
216
334
|
.command(image)
|
|
217
335
|
.command(validateToken)
|
|
@@ -70,6 +70,13 @@ export default class CycloneDxSbom {
|
|
|
70
70
|
* @return {boolean}
|
|
71
71
|
*/
|
|
72
72
|
checkIfPackageInsideDependsOnList(component: any, name: string): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Checks if any entry in the dependsOn list of sourceRef starts with the given purl prefix.
|
|
75
|
+
* @param {PackageURL} sourceRef - The source component
|
|
76
|
+
* @param {string} purlPrefix - The purl prefix to match (e.g. "pkg:npm/minimist@")
|
|
77
|
+
* @return {boolean}
|
|
78
|
+
*/
|
|
79
|
+
checkDependsOnByPurlPrefix(sourceRef: PackageURL, purlPrefix: string): boolean;
|
|
73
80
|
/** Removes the root component from the sbom
|
|
74
81
|
*/
|
|
75
82
|
removeRootComponent(): void;
|
|
@@ -120,6 +120,7 @@ export default class CycloneDxSbom {
|
|
|
120
120
|
getAsJsonString(opts) {
|
|
121
121
|
let manifestType = opts["manifest-type"];
|
|
122
122
|
this.setSourceManifest(opts["source-manifest"]);
|
|
123
|
+
const rootPurl = this.rootComponent?.purl;
|
|
123
124
|
this.sbomObject = {
|
|
124
125
|
"bomFormat": "CycloneDX",
|
|
125
126
|
"specVersion": "1.4",
|
|
@@ -129,7 +130,7 @@ export default class CycloneDxSbom {
|
|
|
129
130
|
"component": this.rootComponent,
|
|
130
131
|
"properties": new Array()
|
|
131
132
|
},
|
|
132
|
-
"components": this.components,
|
|
133
|
+
"components": this.components.filter(c => c.purl !== rootPurl),
|
|
133
134
|
"dependencies": this.dependencies
|
|
134
135
|
};
|
|
135
136
|
if (this.rootComponent === undefined) {
|
|
@@ -241,6 +242,20 @@ export default class CycloneDxSbom {
|
|
|
241
242
|
return false;
|
|
242
243
|
}
|
|
243
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Checks if any entry in the dependsOn list of sourceRef starts with the given purl prefix.
|
|
247
|
+
* @param {PackageURL} sourceRef - The source component
|
|
248
|
+
* @param {string} purlPrefix - The purl prefix to match (e.g. "pkg:npm/minimist@")
|
|
249
|
+
* @return {boolean}
|
|
250
|
+
*/
|
|
251
|
+
checkDependsOnByPurlPrefix(sourceRef, purlPrefix) {
|
|
252
|
+
const sourcePurl = sourceRef.toString();
|
|
253
|
+
const depIndex = this.getDependencyIndex(sourcePurl);
|
|
254
|
+
if (depIndex < 0) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return this.dependencies[depIndex].dependsOn.some(dep => dep.startsWith(purlPrefix));
|
|
258
|
+
}
|
|
244
259
|
/** Removes the root component from the sbom
|
|
245
260
|
*/
|
|
246
261
|
removeRootComponent() {
|
package/dist/src/index.d.ts
CHANGED
|
@@ -18,11 +18,13 @@ export { ImageRef } from "./oci_image/images.js";
|
|
|
18
18
|
declare namespace _default {
|
|
19
19
|
export { componentAnalysis };
|
|
20
20
|
export { stackAnalysis };
|
|
21
|
+
export { stackAnalysisBatch };
|
|
21
22
|
export { imageAnalysis };
|
|
22
23
|
export { validateToken };
|
|
23
24
|
}
|
|
24
25
|
export default _default;
|
|
25
26
|
export type Options = {
|
|
27
|
+
TRUSTIFY_DA_CARGO_PATH?: string | undefined;
|
|
26
28
|
TRUSTIFY_DA_DOCKER_PATH?: string | undefined;
|
|
27
29
|
TRUSTIFY_DA_GO_MVS_LOGIC_ENABLED?: string | undefined;
|
|
28
30
|
TRUSTIFY_DA_GO_PATH?: string | undefined;
|
|
@@ -47,12 +49,45 @@ export type Options = {
|
|
|
47
49
|
TRUSTIFY_DA_SYFT_CONFIG_PATH?: string | undefined;
|
|
48
50
|
TRUSTIFY_DA_SYFT_PATH?: string | undefined;
|
|
49
51
|
TRUSTIFY_DA_YARN_PATH?: string | undefined;
|
|
52
|
+
TRUSTIFY_DA_WORKSPACE_DIR?: string | undefined;
|
|
50
53
|
TRUSTIFY_DA_LICENSE_CHECK?: string | undefined;
|
|
51
54
|
MATCH_MANIFEST_VERSIONS?: string | undefined;
|
|
52
55
|
TRUSTIFY_DA_SOURCE?: string | undefined;
|
|
53
56
|
TRUSTIFY_DA_TOKEN?: string | undefined;
|
|
54
57
|
TRUSTIFY_DA_TELEMETRY_ID?: string | undefined;
|
|
55
|
-
|
|
58
|
+
TRUSTIFY_DA_WORKSPACE_DIR?: string | undefined;
|
|
59
|
+
batchConcurrency?: number | undefined;
|
|
60
|
+
TRUSTIFY_DA_BATCH_CONCURRENCY?: string | undefined;
|
|
61
|
+
workspaceDiscoveryIgnore?: string[] | undefined;
|
|
62
|
+
TRUSTIFY_DA_WORKSPACE_DISCOVERY_IGNORE?: string | undefined;
|
|
63
|
+
continueOnError?: boolean | undefined;
|
|
64
|
+
TRUSTIFY_DA_CONTINUE_ON_ERROR?: string | undefined;
|
|
65
|
+
batchMetadata?: boolean | undefined;
|
|
66
|
+
TRUSTIFY_DA_BATCH_METADATA?: string | undefined;
|
|
67
|
+
TRUSTIFY_DA_UV_PATH?: string | undefined;
|
|
68
|
+
TRUSTIFY_DA_POETRY_PATH?: string | undefined;
|
|
69
|
+
[key: string]: string | number | boolean | string[] | undefined;
|
|
70
|
+
};
|
|
71
|
+
export type BatchAnalysisMetadata = {
|
|
72
|
+
workspaceRoot: string;
|
|
73
|
+
ecosystem: "javascript" | "cargo" | "unknown";
|
|
74
|
+
total: number;
|
|
75
|
+
successful: number;
|
|
76
|
+
failed: number;
|
|
77
|
+
errors: Array<{
|
|
78
|
+
manifestPath: string;
|
|
79
|
+
phase: "validation" | "sbom";
|
|
80
|
+
reason: string;
|
|
81
|
+
}>;
|
|
82
|
+
};
|
|
83
|
+
export type SbomResult = {
|
|
84
|
+
ok: true;
|
|
85
|
+
purl: string;
|
|
86
|
+
sbom: object;
|
|
87
|
+
} | {
|
|
88
|
+
ok: false;
|
|
89
|
+
manifestPath: string;
|
|
90
|
+
reason: string;
|
|
56
91
|
};
|
|
57
92
|
/**
|
|
58
93
|
* Get component analysis report for a manifest content.
|
|
@@ -91,6 +126,26 @@ declare function stackAnalysis(manifest: string, html: false, opts?: Options | u
|
|
|
91
126
|
* or backend request failed
|
|
92
127
|
*/
|
|
93
128
|
declare function stackAnalysis(manifest: string, html?: boolean | undefined, opts?: Options | undefined): Promise<string | import("@trustify-da/trustify-da-api-model/model/v5/AnalysisReport").AnalysisReport>;
|
|
129
|
+
/**
|
|
130
|
+
* Get stack analysis for all workspace packages/crates (batch).
|
|
131
|
+
* Detects ecosystem from workspace root: Cargo (Cargo.toml + Cargo.lock) or JS/TS (package.json + lock file).
|
|
132
|
+
* SBOMs are generated in parallel (see `batchConcurrency`) unless `continueOnError: false` (fail-fast sequential).
|
|
133
|
+
* With `opts.batchMetadata` / `TRUSTIFY_DA_BATCH_METADATA`, returns `{ analysis, metadata }` including validation and SBOM errors.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} workspaceRoot - Path to workspace root (containing lock file and workspace config)
|
|
136
|
+
* @param {boolean} [html=false] - true returns HTML, false returns JSON report
|
|
137
|
+
* @param {Options} [opts={}] - `batchConcurrency`, discovery ignores, `continueOnError` (default true), `batchMetadata` (default false)
|
|
138
|
+
* @returns {Promise<string|Object.<string, import('@trustify-da/trustify-da-api-model/model/v5/AnalysisReport').AnalysisReport>|{ analysis: string|Object.<string, import('@trustify-da/trustify-da-api-model/model/v5/AnalysisReport').AnalysisReport>, metadata: BatchAnalysisMetadata }>}
|
|
139
|
+
* @throws {Error} if workspace root invalid, no manifests found, no packages pass validation, no SBOMs produced, or backend request failed. When `opts.batchMetadata` is set, `error.batchMetadata` may be set on thrown errors.
|
|
140
|
+
*/
|
|
141
|
+
declare function stackAnalysisBatch(workspaceRoot: string, html?: boolean, opts?: Options): Promise<string | {
|
|
142
|
+
[x: string]: import("@trustify-da/trustify-da-api-model/model/v5/AnalysisReport").AnalysisReport;
|
|
143
|
+
} | {
|
|
144
|
+
analysis: string | {
|
|
145
|
+
[x: string]: import("@trustify-da/trustify-da-api-model/model/v5/AnalysisReport").AnalysisReport;
|
|
146
|
+
};
|
|
147
|
+
metadata: BatchAnalysisMetadata;
|
|
148
|
+
}>;
|
|
94
149
|
/**
|
|
95
150
|
* @overload
|
|
96
151
|
* @param {Array<string>} imageRefs
|
|
@@ -131,4 +186,12 @@ declare function imageAnalysis(imageRefs: Array<string>, html?: boolean | undefi
|
|
|
131
186
|
* @throws {Error} if the backend request failed.
|
|
132
187
|
*/
|
|
133
188
|
declare function validateToken(opts?: Options): Promise<object>;
|
|
189
|
+
import { discoverWorkspacePackages } from './workspace.js';
|
|
190
|
+
import { discoverWorkspaceCrates } from './workspace.js';
|
|
191
|
+
import { validatePackageJson } from './workspace.js';
|
|
192
|
+
import { resolveWorkspaceDiscoveryIgnore } from './workspace.js';
|
|
193
|
+
import { filterManifestPathsByDiscoveryIgnore } from './workspace.js';
|
|
194
|
+
import { resolveContinueOnError } from './batch_opts.js';
|
|
195
|
+
import { resolveBatchMetadata } from './batch_opts.js';
|
|
196
|
+
export { discoverWorkspacePackages, discoverWorkspaceCrates, validatePackageJson, resolveWorkspaceDiscoveryIgnore, filterManifestPathsByDiscoveryIgnore, resolveContinueOnError, resolveBatchMetadata };
|
|
134
197
|
export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDetails, licensesFromReport, normalizeLicensesResponse, runLicenseCheck, getCompatibility } from "./license/index.js";
|
package/dist/src/index.js
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { EOL } from "os";
|
|
3
|
+
import pLimit from 'p-limit';
|
|
3
4
|
import { availableProviders, match } from './provider.js';
|
|
4
5
|
import analysis from './analysis.js';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import { getCustom } from "./tools.js";
|
|
8
|
+
import { resolveBatchMetadata, resolveContinueOnError } from './batch_opts.js';
|
|
9
|
+
import { discoverWorkspaceCrates, discoverWorkspacePackages, filterManifestPathsByDiscoveryIgnore, resolveWorkspaceDiscoveryIgnore, validatePackageJson, } from './workspace.js';
|
|
7
10
|
import.meta.dirname;
|
|
8
11
|
import * as url from 'url';
|
|
9
12
|
export { parseImageRef } from "./oci_image/utils.js";
|
|
10
13
|
export { ImageRef } from "./oci_image/images.js";
|
|
11
14
|
export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDetails, licensesFromReport, normalizeLicensesResponse, runLicenseCheck, getCompatibility } from "./license/index.js";
|
|
12
|
-
export default { componentAnalysis, stackAnalysis, imageAnalysis, validateToken };
|
|
15
|
+
export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken };
|
|
16
|
+
export { discoverWorkspacePackages, discoverWorkspaceCrates, validatePackageJson, resolveWorkspaceDiscoveryIgnore, filterManifestPathsByDiscoveryIgnore, resolveContinueOnError, resolveBatchMetadata, };
|
|
13
17
|
/**
|
|
14
18
|
* @typedef {{
|
|
19
|
+
* TRUSTIFY_DA_CARGO_PATH?: string | undefined,
|
|
15
20
|
* TRUSTIFY_DA_DOCKER_PATH?: string | undefined,
|
|
16
21
|
* TRUSTIFY_DA_GO_MVS_LOGIC_ENABLED?: string | undefined,
|
|
17
22
|
* TRUSTIFY_DA_GO_PATH?: string | undefined,
|
|
@@ -36,14 +41,36 @@ export default { componentAnalysis, stackAnalysis, imageAnalysis, validateToken
|
|
|
36
41
|
* TRUSTIFY_DA_SYFT_CONFIG_PATH?: string | undefined,
|
|
37
42
|
* TRUSTIFY_DA_SYFT_PATH?: string | undefined,
|
|
38
43
|
* TRUSTIFY_DA_YARN_PATH?: string | undefined,
|
|
44
|
+
* TRUSTIFY_DA_WORKSPACE_DIR?: string | undefined,
|
|
39
45
|
* TRUSTIFY_DA_LICENSE_CHECK?: string | undefined,
|
|
40
46
|
* MATCH_MANIFEST_VERSIONS?: string | undefined,
|
|
41
47
|
* TRUSTIFY_DA_SOURCE?: string | undefined,
|
|
42
48
|
* TRUSTIFY_DA_TOKEN?: string | undefined,
|
|
43
49
|
* TRUSTIFY_DA_TELEMETRY_ID?: string | undefined,
|
|
44
|
-
*
|
|
50
|
+
* TRUSTIFY_DA_WORKSPACE_DIR?: string | undefined,
|
|
51
|
+
* batchConcurrency?: number | undefined,
|
|
52
|
+
* TRUSTIFY_DA_BATCH_CONCURRENCY?: string | undefined,
|
|
53
|
+
* workspaceDiscoveryIgnore?: string[] | undefined,
|
|
54
|
+
* TRUSTIFY_DA_WORKSPACE_DISCOVERY_IGNORE?: string | undefined,
|
|
55
|
+
* continueOnError?: boolean | undefined,
|
|
56
|
+
* TRUSTIFY_DA_CONTINUE_ON_ERROR?: string | undefined,
|
|
57
|
+
* batchMetadata?: boolean | undefined,
|
|
58
|
+
* TRUSTIFY_DA_BATCH_METADATA?: string | undefined,
|
|
59
|
+
* TRUSTIFY_DA_UV_PATH?: string | undefined,
|
|
60
|
+
* TRUSTIFY_DA_POETRY_PATH?: string | undefined,
|
|
61
|
+
* [key: string]: string | number | boolean | string[] | undefined,
|
|
45
62
|
* }} Options
|
|
46
63
|
*/
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {{
|
|
66
|
+
* workspaceRoot: string,
|
|
67
|
+
* ecosystem: 'javascript' | 'cargo' | 'unknown',
|
|
68
|
+
* total: number,
|
|
69
|
+
* successful: number,
|
|
70
|
+
* failed: number,
|
|
71
|
+
* errors: Array<{ manifestPath: string, phase: 'validation' | 'sbom', reason: string }>
|
|
72
|
+
* }} BatchAnalysisMetadata
|
|
73
|
+
*/
|
|
47
74
|
/**
|
|
48
75
|
* Logs messages to the console if the TRUSTIFY_DA_DEBUG environment variable is set to "true".
|
|
49
76
|
* @param {string} alongsideText - The text to prepend to the log message.
|
|
@@ -129,7 +156,7 @@ export function selectTrustifyDABackend(opts = {}) {
|
|
|
129
156
|
async function stackAnalysis(manifest, html = false, opts = {}) {
|
|
130
157
|
const theUrl = selectTrustifyDABackend(opts);
|
|
131
158
|
fs.accessSync(manifest, fs.constants.R_OK); // throws error if file unreadable
|
|
132
|
-
let provider = match(manifest, availableProviders); // throws error if no matching provider
|
|
159
|
+
let provider = match(manifest, availableProviders, opts); // throws error if no matching provider
|
|
133
160
|
return await analysis.requestStack(provider, manifest, theUrl, html, opts); // throws error request sending failed
|
|
134
161
|
}
|
|
135
162
|
/**
|
|
@@ -143,7 +170,7 @@ async function componentAnalysis(manifest, opts = {}) {
|
|
|
143
170
|
const theUrl = selectTrustifyDABackend(opts);
|
|
144
171
|
fs.accessSync(manifest, fs.constants.R_OK);
|
|
145
172
|
opts["manifest-type"] = path.basename(manifest);
|
|
146
|
-
let provider = match(manifest, availableProviders); // throws error if no matching provider
|
|
173
|
+
let provider = match(manifest, availableProviders, opts); // throws error if no matching provider
|
|
147
174
|
return await analysis.requestComponent(provider, manifest, theUrl, opts); // throws error request sending failed
|
|
148
175
|
}
|
|
149
176
|
/**
|
|
@@ -176,6 +203,242 @@ async function imageAnalysis(imageRefs, html = false, opts = {}) {
|
|
|
176
203
|
const theUrl = selectTrustifyDABackend(opts);
|
|
177
204
|
return await analysis.requestImages(imageRefs, theUrl, html, opts);
|
|
178
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Max concurrent SBOM generations for batch workspace analysis. Env/opts override default 10.
|
|
208
|
+
* @param {Options} opts
|
|
209
|
+
* @returns {number}
|
|
210
|
+
* @private
|
|
211
|
+
*/
|
|
212
|
+
function resolveBatchConcurrency(opts) {
|
|
213
|
+
const fromEnv = getCustom('TRUSTIFY_DA_BATCH_CONCURRENCY', null, opts);
|
|
214
|
+
const raw = opts.batchConcurrency ?? fromEnv ?? '10';
|
|
215
|
+
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10);
|
|
216
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
217
|
+
return 10;
|
|
218
|
+
}
|
|
219
|
+
return Math.min(256, n);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* @param {string} root
|
|
223
|
+
* @param {'javascript' | 'cargo' | 'unknown'} ecosystem
|
|
224
|
+
* @param {number} totalSbomAttempts
|
|
225
|
+
* @param {number} successfulSbomCount
|
|
226
|
+
* @param {Array<{ manifestPath: string, phase: 'validation' | 'sbom', reason: string }>} errors
|
|
227
|
+
* @returns {BatchAnalysisMetadata}
|
|
228
|
+
* @private
|
|
229
|
+
*/
|
|
230
|
+
function buildBatchAnalysisMetadata(root, ecosystem, totalSbomAttempts, successfulSbomCount, errors) {
|
|
231
|
+
return {
|
|
232
|
+
workspaceRoot: root,
|
|
233
|
+
ecosystem,
|
|
234
|
+
total: totalSbomAttempts,
|
|
235
|
+
successful: successfulSbomCount,
|
|
236
|
+
failed: errors.length,
|
|
237
|
+
errors: [...errors],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* @typedef {{ ok: true, purl: string, sbom: object } | { ok: false, manifestPath: string, reason: string }} SbomResult
|
|
242
|
+
*/
|
|
243
|
+
/**
|
|
244
|
+
* Generate an SBOM for a single manifest, returning a normalized result.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} manifestPath
|
|
247
|
+
* @param {Options} workspaceOpts - opts with `TRUSTIFY_DA_WORKSPACE_DIR` set
|
|
248
|
+
* @returns {Promise<SbomResult>}
|
|
249
|
+
* @private
|
|
250
|
+
*/
|
|
251
|
+
async function generateOneSbom(manifestPath, workspaceOpts) {
|
|
252
|
+
const provider = match(manifestPath, availableProviders, workspaceOpts);
|
|
253
|
+
const provided = await provider.provideStack(manifestPath, workspaceOpts);
|
|
254
|
+
const sbom = JSON.parse(provided.content);
|
|
255
|
+
const purl = sbom?.metadata?.component?.purl || sbom?.metadata?.component?.['bom-ref'];
|
|
256
|
+
if (!purl) {
|
|
257
|
+
return { ok: false, manifestPath, reason: 'missing purl in SBOM' };
|
|
258
|
+
}
|
|
259
|
+
return { ok: true, purl, sbom };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Detect the workspace ecosystem and discover manifest paths.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} root - Resolved workspace root
|
|
265
|
+
* @param {Options} opts
|
|
266
|
+
* @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'unknown', manifestPaths: string[] }>}
|
|
267
|
+
* @private
|
|
268
|
+
*/
|
|
269
|
+
async function detectWorkspaceManifests(root, opts) {
|
|
270
|
+
const cargoToml = path.join(root, 'Cargo.toml');
|
|
271
|
+
const cargoLock = path.join(root, 'Cargo.lock');
|
|
272
|
+
const packageJson = path.join(root, 'package.json');
|
|
273
|
+
if (fs.existsSync(cargoToml) && fs.existsSync(cargoLock)) {
|
|
274
|
+
return { ecosystem: 'cargo', manifestPaths: await discoverWorkspaceCrates(root, opts) };
|
|
275
|
+
}
|
|
276
|
+
const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
|
|
277
|
+
|| fs.existsSync(path.join(root, 'yarn.lock'))
|
|
278
|
+
|| fs.existsSync(path.join(root, 'package-lock.json'));
|
|
279
|
+
if (fs.existsSync(packageJson) && hasJsLock) {
|
|
280
|
+
let manifestPaths = await discoverWorkspacePackages(root, opts);
|
|
281
|
+
if (manifestPaths.length === 0) {
|
|
282
|
+
manifestPaths = [packageJson];
|
|
283
|
+
}
|
|
284
|
+
return { ecosystem: 'javascript', manifestPaths };
|
|
285
|
+
}
|
|
286
|
+
return { ecosystem: 'unknown', manifestPaths: [] };
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Validate discovered JS package.json manifests, collecting errors.
|
|
290
|
+
*
|
|
291
|
+
* @param {string[]} manifestPaths
|
|
292
|
+
* @param {boolean} continueOnError
|
|
293
|
+
* @param {Array<{ manifestPath: string, phase: 'validation' | 'sbom', reason: string }>} collectedErrors - mutated in place
|
|
294
|
+
* @returns {{ validPaths: string[] }}
|
|
295
|
+
* @throws {Error} on first invalid manifest when `continueOnError` is false
|
|
296
|
+
* @private
|
|
297
|
+
*/
|
|
298
|
+
function validateJsManifests(manifestPaths, continueOnError, collectedErrors) {
|
|
299
|
+
const validPaths = [];
|
|
300
|
+
for (const p of manifestPaths) {
|
|
301
|
+
const v = validatePackageJson(p);
|
|
302
|
+
if (v.valid) {
|
|
303
|
+
validPaths.push(p);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
collectedErrors.push({ manifestPath: p, phase: 'validation', reason: v.error });
|
|
307
|
+
console.warn(`Skipping invalid package.json (${v.error}): ${p}`);
|
|
308
|
+
if (!continueOnError) {
|
|
309
|
+
throw new Error(`Invalid package.json (${v.error}): ${p}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { validPaths };
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Generate SBOMs for all manifests. In fail-fast mode, stops on first error.
|
|
317
|
+
* In continue-on-error mode, runs concurrently and collects failures.
|
|
318
|
+
*
|
|
319
|
+
* @param {string[]} manifestPaths
|
|
320
|
+
* @param {Options} workspaceOpts
|
|
321
|
+
* @param {boolean} continueOnError
|
|
322
|
+
* @param {number} concurrency
|
|
323
|
+
* @param {Array<{ manifestPath: string, phase: 'validation' | 'sbom', reason: string }>} collectedErrors - mutated in place
|
|
324
|
+
* @returns {Promise<Object.<string, object>>} sbomByPurl map
|
|
325
|
+
* @throws {Error} on first SBOM failure when `continueOnError` is false
|
|
326
|
+
* @private
|
|
327
|
+
*/
|
|
328
|
+
async function generateSboms(manifestPaths, workspaceOpts, continueOnError, concurrency, collectedErrors) {
|
|
329
|
+
/** @type {SbomResult[]} */
|
|
330
|
+
const results = [];
|
|
331
|
+
if (!continueOnError) {
|
|
332
|
+
for (const manifestPath of manifestPaths) {
|
|
333
|
+
const result = await generateOneSbom(manifestPath, workspaceOpts);
|
|
334
|
+
if (!result.ok) {
|
|
335
|
+
collectedErrors.push({ manifestPath: result.manifestPath, phase: 'sbom', reason: result.reason });
|
|
336
|
+
throw new Error(`${result.manifestPath}: ${result.reason}`);
|
|
337
|
+
}
|
|
338
|
+
results.push(result);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const limit = pLimit(concurrency);
|
|
343
|
+
const settled = await Promise.all(manifestPaths.map(manifestPath => limit(async () => {
|
|
344
|
+
try {
|
|
345
|
+
return await generateOneSbom(manifestPath, workspaceOpts);
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
349
|
+
if (process.env["TRUSTIFY_DA_DEBUG"] === "true") {
|
|
350
|
+
console.log(`Skipping ${manifestPath}: ${msg}`);
|
|
351
|
+
}
|
|
352
|
+
return { ok: false, manifestPath, reason: msg };
|
|
353
|
+
}
|
|
354
|
+
})));
|
|
355
|
+
for (const r of settled) {
|
|
356
|
+
results.push(r);
|
|
357
|
+
if (!r.ok) {
|
|
358
|
+
collectedErrors.push({ manifestPath: r.manifestPath, phase: 'sbom', reason: r.reason });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const sbomByPurl = {};
|
|
363
|
+
for (const r of results) {
|
|
364
|
+
if (r.ok) {
|
|
365
|
+
sbomByPurl[r.purl] = r.sbom;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return sbomByPurl;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Create an Error with optional `batchMetadata` attached.
|
|
372
|
+
* @param {string} message
|
|
373
|
+
* @param {boolean} wantMetadata
|
|
374
|
+
* @param {BatchAnalysisMetadata} [metadata]
|
|
375
|
+
* @returns {Error}
|
|
376
|
+
* @private
|
|
377
|
+
*/
|
|
378
|
+
function batchError(message, wantMetadata, metadata) {
|
|
379
|
+
const err = new Error(message);
|
|
380
|
+
if (wantMetadata && metadata) {
|
|
381
|
+
err.batchMetadata = metadata;
|
|
382
|
+
}
|
|
383
|
+
return err;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Get stack analysis for all workspace packages/crates (batch).
|
|
387
|
+
* Detects ecosystem from workspace root: Cargo (Cargo.toml + Cargo.lock) or JS/TS (package.json + lock file).
|
|
388
|
+
* SBOMs are generated in parallel (see `batchConcurrency`) unless `continueOnError: false` (fail-fast sequential).
|
|
389
|
+
* With `opts.batchMetadata` / `TRUSTIFY_DA_BATCH_METADATA`, returns `{ analysis, metadata }` including validation and SBOM errors.
|
|
390
|
+
*
|
|
391
|
+
* @param {string} workspaceRoot - Path to workspace root (containing lock file and workspace config)
|
|
392
|
+
* @param {boolean} [html=false] - true returns HTML, false returns JSON report
|
|
393
|
+
* @param {Options} [opts={}] - `batchConcurrency`, discovery ignores, `continueOnError` (default true), `batchMetadata` (default false)
|
|
394
|
+
* @returns {Promise<string|Object.<string, import('@trustify-da/trustify-da-api-model/model/v5/AnalysisReport').AnalysisReport>|{ analysis: string|Object.<string, import('@trustify-da/trustify-da-api-model/model/v5/AnalysisReport').AnalysisReport>, metadata: BatchAnalysisMetadata }>}
|
|
395
|
+
* @throws {Error} if workspace root invalid, no manifests found, no packages pass validation, no SBOMs produced, or backend request failed. When `opts.batchMetadata` is set, `error.batchMetadata` may be set on thrown errors.
|
|
396
|
+
*/
|
|
397
|
+
async function stackAnalysisBatch(workspaceRoot, html = false, opts = {}) {
|
|
398
|
+
const theUrl = selectTrustifyDABackend(opts);
|
|
399
|
+
const root = path.resolve(workspaceRoot);
|
|
400
|
+
fs.accessSync(root, fs.constants.R_OK);
|
|
401
|
+
const continueOnError = resolveContinueOnError(opts);
|
|
402
|
+
const wantMetadata = resolveBatchMetadata(opts);
|
|
403
|
+
/** @type {Array<{ manifestPath: string, phase: 'validation' | 'sbom', reason: string }>} */
|
|
404
|
+
const collectedErrors = [];
|
|
405
|
+
const { ecosystem, manifestPaths: discovered } = await detectWorkspaceManifests(root, opts);
|
|
406
|
+
let manifestPaths = discovered;
|
|
407
|
+
if (ecosystem === 'javascript') {
|
|
408
|
+
try {
|
|
409
|
+
const { validPaths } = validateJsManifests(manifestPaths, continueOnError, collectedErrors);
|
|
410
|
+
manifestPaths = validPaths;
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
throw batchError(err.message, wantMetadata, buildBatchAnalysisMetadata(root, ecosystem, 0, 0, collectedErrors));
|
|
414
|
+
}
|
|
415
|
+
if (manifestPaths.length === 0 && discovered.length > 0) {
|
|
416
|
+
const detail = collectedErrors.map(e => `${e.manifestPath}: ${e.reason}`).join('; ');
|
|
417
|
+
throw batchError(`No valid packages after validation at ${root}. ${detail}`, wantMetadata, buildBatchAnalysisMetadata(root, ecosystem, 0, 0, collectedErrors));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (manifestPaths.length === 0) {
|
|
421
|
+
throw new Error(`No workspace manifests found at ${root}. Ensure Cargo.toml+Cargo.lock or package.json+lock file exist.`);
|
|
422
|
+
}
|
|
423
|
+
const workspaceOpts = { ...opts, TRUSTIFY_DA_WORKSPACE_DIR: root };
|
|
424
|
+
const concurrency = resolveBatchConcurrency(opts);
|
|
425
|
+
let sbomByPurl;
|
|
426
|
+
try {
|
|
427
|
+
sbomByPurl = await generateSboms(manifestPaths, workspaceOpts, continueOnError, concurrency, collectedErrors);
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
throw batchError(err.message, wantMetadata, buildBatchAnalysisMetadata(root, ecosystem, manifestPaths.length, 0, collectedErrors));
|
|
431
|
+
}
|
|
432
|
+
if (Object.keys(sbomByPurl).length === 0) {
|
|
433
|
+
throw batchError(`No valid SBOMs produced from ${manifestPaths.length} manifest(s) at ${root}`, wantMetadata, buildBatchAnalysisMetadata(root, ecosystem, manifestPaths.length, 0, collectedErrors));
|
|
434
|
+
}
|
|
435
|
+
const analysisResult = await analysis.requestStackBatch(sbomByPurl, theUrl, html, opts);
|
|
436
|
+
const meta = buildBatchAnalysisMetadata(root, ecosystem, manifestPaths.length, Object.keys(sbomByPurl).length, collectedErrors);
|
|
437
|
+
if (wantMetadata) {
|
|
438
|
+
return { analysis: analysisResult, metadata: meta };
|
|
439
|
+
}
|
|
440
|
+
return analysisResult;
|
|
441
|
+
}
|
|
179
442
|
/**
|
|
180
443
|
* Validates the Exhort token.
|
|
181
444
|
* @param {Options} [opts={}] - Optional parameters, potentially including token override.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* @see https://github.com/guacsec/trustify-dependency-analytics#license-analysis-apiv5licenses
|
|
5
5
|
* @see https://github.com/guacsec/trustify-da-api-spec/blob/main/api/v5/openapi.yaml
|
|
6
6
|
*/
|
|
7
|
+
import { PackageURL } from 'packageurl-js';
|
|
7
8
|
import { selectTrustifyDABackend } from '../index.js';
|
|
8
9
|
import { addProxyAgent, getTokenHeaders } from '../tools.js';
|
|
9
10
|
/**
|
|
@@ -39,6 +40,10 @@ export async function getLicenseDetails(spdxId, opts = {}) {
|
|
|
39
40
|
throw new Error(`Failed to fetch license details: ${err.message}`);
|
|
40
41
|
}
|
|
41
42
|
}
|
|
43
|
+
function normalizePurlString(purl) {
|
|
44
|
+
const parsed = PackageURL.fromString(purl);
|
|
45
|
+
return new PackageURL(parsed.type, parsed.namespace, parsed.name, parsed.version, null, null).toString();
|
|
46
|
+
}
|
|
42
47
|
/**
|
|
43
48
|
* Normalize the LicensesResponse shape (array of LicenseProviderResult) into a map of purl -> license info.
|
|
44
49
|
* Each provider result has { status, summary, packages } where packages is { [purl]: { concluded, evidence } }.
|
|
@@ -53,6 +58,7 @@ export function normalizeLicensesResponse(data, purls = []) {
|
|
|
53
58
|
if (!data || !Array.isArray(data)) {
|
|
54
59
|
return map;
|
|
55
60
|
}
|
|
61
|
+
const normalizedPurlsSet = purls.length > 0 ? new Set(purls.map(normalizePurlString)) : null;
|
|
56
62
|
for (const providerResult of data) {
|
|
57
63
|
const packages = providerResult?.packages;
|
|
58
64
|
if (!packages || typeof packages !== 'object') {
|
|
@@ -64,8 +70,9 @@ export function normalizeLicensesResponse(data, purls = []) {
|
|
|
64
70
|
const expression = concluded?.expression;
|
|
65
71
|
const licenses = identifiers.length > 0 ? identifiers : (expression ? [expression] : []);
|
|
66
72
|
const category = concluded?.category; // PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
const normalizedPurl = normalizePurlString(purl);
|
|
74
|
+
if (normalizedPurlsSet === null || normalizedPurlsSet.has(normalizedPurl)) {
|
|
75
|
+
map.set(normalizedPurl, { licenses: licenses.filter(Boolean), category });
|
|
69
76
|
}
|
|
70
77
|
}
|
|
71
78
|
// Use first provider that has packages; backend may return multiple (e.g. deps.dev)
|