@trustify-da/trustify-da-javascript-client 0.3.0-ea.f2c4df7 → 0.3.0-ea.f2d5d72

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.
Files changed (44) hide show
  1. package/README.md +151 -13
  2. package/dist/package.json +10 -2
  3. package/dist/src/analysis.d.ts +16 -0
  4. package/dist/src/analysis.js +50 -2
  5. package/dist/src/batch_opts.d.ts +24 -0
  6. package/dist/src/batch_opts.js +35 -0
  7. package/dist/src/cli.js +121 -3
  8. package/dist/src/cyclone_dx_sbom.d.ts +7 -0
  9. package/dist/src/cyclone_dx_sbom.js +16 -1
  10. package/dist/src/index.d.ts +64 -1
  11. package/dist/src/index.js +267 -4
  12. package/dist/src/license/licenses_api.js +9 -2
  13. package/dist/src/license/project_license.d.ts +0 -8
  14. package/dist/src/license/project_license.js +0 -11
  15. package/dist/src/provider.d.ts +6 -3
  16. package/dist/src/provider.js +12 -5
  17. package/dist/src/providers/base_javascript.d.ts +19 -3
  18. package/dist/src/providers/base_javascript.js +99 -18
  19. package/dist/src/providers/base_pyproject.d.ts +147 -0
  20. package/dist/src/providers/base_pyproject.js +279 -0
  21. package/dist/src/providers/golang_gomodules.d.ts +12 -12
  22. package/dist/src/providers/golang_gomodules.js +100 -111
  23. package/dist/src/providers/gomod_parser.d.ts +4 -0
  24. package/dist/src/providers/gomod_parser.js +16 -0
  25. package/dist/src/providers/javascript_pnpm.d.ts +1 -1
  26. package/dist/src/providers/javascript_pnpm.js +2 -2
  27. package/dist/src/providers/manifest.d.ts +2 -0
  28. package/dist/src/providers/manifest.js +22 -4
  29. package/dist/src/providers/processors/yarn_berry_processor.js +82 -3
  30. package/dist/src/providers/python_pip.js +1 -1
  31. package/dist/src/providers/python_poetry.d.ts +42 -0
  32. package/dist/src/providers/python_poetry.js +146 -0
  33. package/dist/src/providers/python_uv.d.ts +26 -0
  34. package/dist/src/providers/python_uv.js +118 -0
  35. package/dist/src/providers/requirements_parser.js +4 -3
  36. package/dist/src/providers/rust_cargo.d.ts +52 -0
  37. package/dist/src/providers/rust_cargo.js +614 -0
  38. package/dist/src/providers/tree-sitter-gomod.wasm +0 -0
  39. package/dist/src/providers/tree-sitter-requirements.wasm +0 -0
  40. package/dist/src/sbom.d.ts +7 -0
  41. package/dist/src/sbom.js +9 -0
  42. package/dist/src/workspace.d.ts +61 -0
  43. package/dist/src/workspace.js +256 -0
  44. package/package.json +11 -3
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
- let res = await client.componentAnalysis(manifestName);
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() {
@@ -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
- [key: string]: string | undefined;
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
- * [key: string]: string | undefined,
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
- if (purls.length === 0 || purls.includes(purl)) {
68
- map.set(purl, { licenses: licenses.filter(Boolean), category });
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)