@trustify-da/trustify-da-javascript-client 0.3.0-ea.be56bee → 0.3.0-ea.c300d9f

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 (59) hide show
  1. package/README.md +198 -10
  2. package/dist/package.json +7 -6
  3. package/dist/src/analysis.d.ts +7 -0
  4. package/dist/src/analysis.js +32 -14
  5. package/dist/src/cli.js +188 -6
  6. package/dist/src/config.d.ts +120 -0
  7. package/dist/src/config.js +260 -0
  8. package/dist/src/cyclone_dx_sbom.d.ts +13 -0
  9. package/dist/src/cyclone_dx_sbom.js +39 -1
  10. package/dist/src/index.d.ts +9 -4
  11. package/dist/src/index.js +18 -29
  12. package/dist/src/package_version.d.ts +8 -0
  13. package/dist/src/package_version.js +31 -0
  14. package/dist/src/providers/base_java.d.ts +53 -0
  15. package/dist/src/providers/base_java.js +64 -17
  16. package/dist/src/providers/base_javascript.d.ts +54 -0
  17. package/dist/src/providers/base_javascript.js +106 -4
  18. package/dist/src/providers/containerfile_parser.d.ts +1 -0
  19. package/dist/src/providers/containerfile_parser.js +4 -0
  20. package/dist/src/providers/golang_gomodules.js +49 -7
  21. package/dist/src/providers/java_gradle.d.ts +48 -0
  22. package/dist/src/providers/java_gradle.js +196 -18
  23. package/dist/src/providers/java_maven.d.ts +28 -8
  24. package/dist/src/providers/java_maven.js +104 -10
  25. package/dist/src/providers/javascript_bun.d.ts +12 -0
  26. package/dist/src/providers/javascript_bun.js +42 -1
  27. package/dist/src/providers/javascript_npm.d.ts +13 -0
  28. package/dist/src/providers/javascript_npm.js +40 -1
  29. package/dist/src/providers/javascript_pnpm.d.ts +13 -0
  30. package/dist/src/providers/javascript_pnpm.js +47 -1
  31. package/dist/src/providers/javascript_yarn.d.ts +12 -0
  32. package/dist/src/providers/javascript_yarn.js +75 -2
  33. package/dist/src/providers/manifest.js +6 -3
  34. package/dist/src/providers/oci_dockerfile.d.ts +7 -5
  35. package/dist/src/providers/oci_dockerfile.js +92 -19
  36. package/dist/src/providers/processors/yarn_berry_processor.d.ts +5 -1
  37. package/dist/src/providers/processors/yarn_berry_processor.js +3 -2
  38. package/dist/src/providers/processors/yarn_classic_processor.d.ts +5 -1
  39. package/dist/src/providers/processors/yarn_classic_processor.js +8 -6
  40. package/dist/src/providers/python_controller.js +4 -1
  41. package/dist/src/providers/requirements_parser.js +1 -1
  42. package/dist/src/providers/rust_cargo.js +70 -29
  43. package/dist/src/remediate.d.ts +120 -0
  44. package/dist/src/remediate.js +253 -0
  45. package/dist/src/remediation.d.ts +57 -0
  46. package/dist/src/remediation.js +436 -0
  47. package/dist/src/remediation_report.d.ts +40 -0
  48. package/dist/src/remediation_report.js +159 -0
  49. package/dist/src/sbom.d.ts +11 -0
  50. package/dist/src/sbom.js +10 -0
  51. package/dist/src/tools.d.ts +9 -11
  52. package/dist/src/tools.js +34 -13
  53. package/dist/src/updaters/maven_updater.d.ts +58 -0
  54. package/dist/src/updaters/maven_updater.js +345 -0
  55. package/dist/src/updaters/toml_updater.d.ts +62 -0
  56. package/dist/src/updaters/toml_updater.js +276 -0
  57. package/dist/src/workspace.d.ts +2 -1
  58. package/dist/src/workspace.js +2 -1
  59. package/package.json +8 -7
package/dist/src/cli.js CHANGED
@@ -3,8 +3,45 @@ import fs from 'node:fs';
3
3
  import * as path from "path";
4
4
  import yargs from 'yargs';
5
5
  import { hideBin } from 'yargs/helpers';
6
+ import { resolveConfig } from './config.js';
6
7
  import { getProjectLicense, getLicenseDetails } from './license/index.js';
8
+ import { runRemediation } from './remediate.js';
9
+ import { generateReport } from './remediation_report.js';
7
10
  import client, { selectTrustifyDABackend, generateSbom } from './index.js';
11
+ /**
12
+ * Builds a yargs middleware that loads `.trustify-da.yml` (discovered by walking
13
+ * up from the command's target path, or cwd when no path is available) and fills `--providers`, `--sources`, and
14
+ * `--group-by` with config file values when they were not supplied on the CLI or
15
+ * via environment variables. Precedence: CLI flag > env var > config file > default.
16
+ * @param {string} [pathKey] - positional argument holding the target path; defaults to the current working directory
17
+ * @param {{ groupBy?: boolean }} [options={}] - set `groupBy` when the command exposes `--group-by`
18
+ * @returns {(args: object) => object} yargs middleware
19
+ */
20
+ function configMiddleware(pathKey, options = {}) {
21
+ return args => {
22
+ const startPath = pathKey === undefined ? process.cwd() : args[pathKey];
23
+ const envVars = options.groupBy
24
+ ? process.env
25
+ : Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== 'TRUSTIFY_DA_GROUP_BY'));
26
+ const merged = resolveConfig(startPath, { backendUrl: args.backendUrl, providers: args.providers, sources: args.sources, groupBy: args['group-by'] }, envVars);
27
+ if (merged.backendUrlSource === 'file' && process.env.TRUSTIFY_DA_TOKEN) {
28
+ throw new Error('Refusing to send TRUSTIFY_DA_TOKEN to a backend selected by project configuration. Set --backend-url or TRUSTIFY_DA_BACKEND_URL to explicitly trust it.');
29
+ }
30
+ if (args.providers !== undefined || merged.providers.length) {
31
+ args.providers = merged.providers.join(',');
32
+ }
33
+ if (args.sources !== undefined || merged.sources.length) {
34
+ args.sources = merged.sources.join(',');
35
+ }
36
+ if (merged.backendUrl != null) {
37
+ args.backendUrl = merged.backendUrl;
38
+ }
39
+ if (options.groupBy) {
40
+ args['group-by'] = merged.groupBy;
41
+ }
42
+ return args;
43
+ };
44
+ }
8
45
  // command for component analysis take manifest type and content
9
46
  const component = {
10
47
  command: 'component </path/to/manifest>',
@@ -19,11 +56,32 @@ const component = {
19
56
  desc: 'Workspace root directory (for monorepos; lock file is expected here)',
20
57
  type: 'string',
21
58
  normalize: true,
59
+ },
60
+ backendUrl: {
61
+ desc: 'Trustify DA backend URL (env: TRUSTIFY_DA_BACKEND_URL)',
62
+ type: 'string',
63
+ },
64
+ providers: {
65
+ desc: 'Comma-separated list of vulnerability providers (env: TRUSTIFY_DA_PROVIDERS)',
66
+ type: 'string',
67
+ },
68
+ sources: {
69
+ desc: 'Comma-separated list of vulnerability sources (env: TRUSTIFY_DA_SOURCES)',
70
+ type: 'string',
22
71
  }
23
- }),
72
+ }).middleware(configMiddleware('/path/to/manifest')),
24
73
  handler: async (args) => {
25
74
  let manifestName = args['/path/to/manifest'];
26
75
  const opts = args.workspaceDir ? { TRUSTIFY_DA_WORKSPACE_DIR: args.workspaceDir } : {};
76
+ if (args.backendUrl !== undefined) {
77
+ opts.TRUSTIFY_DA_BACKEND_URL = args.backendUrl;
78
+ }
79
+ if (args.providers !== undefined) {
80
+ opts.TRUSTIFY_DA_PROVIDERS = args.providers;
81
+ }
82
+ if (args.sources !== undefined) {
83
+ opts.TRUSTIFY_DA_SOURCES = args.sources;
84
+ }
27
85
  let res = await client.componentAnalysis(manifestName, opts);
28
86
  console.log(JSON.stringify(res, null, 2));
29
87
  }
@@ -72,8 +130,20 @@ const image = {
72
130
  desc: 'For JSON report, get only the \'summary\'',
73
131
  type: 'boolean',
74
132
  conflicts: 'html'
133
+ },
134
+ backendUrl: {
135
+ desc: 'Trustify DA backend URL (env: TRUSTIFY_DA_BACKEND_URL)',
136
+ type: 'string',
137
+ },
138
+ providers: {
139
+ desc: 'Comma-separated list of vulnerability providers (env: TRUSTIFY_DA_PROVIDERS)',
140
+ type: 'string',
141
+ },
142
+ sources: {
143
+ desc: 'Comma-separated list of vulnerability sources (env: TRUSTIFY_DA_SOURCES)',
144
+ type: 'string',
75
145
  }
76
- }),
146
+ }).middleware(configMiddleware()),
77
147
  handler: async (args) => {
78
148
  let imageRefs = args['image-refs'];
79
149
  if (!Array.isArray(imageRefs)) {
@@ -81,7 +151,17 @@ const image = {
81
151
  }
82
152
  let html = args['html'];
83
153
  let summary = args['summary'];
84
- let res = await client.imageAnalysis(imageRefs, html);
154
+ const opts = {};
155
+ if (args.backendUrl !== undefined) {
156
+ opts.TRUSTIFY_DA_BACKEND_URL = args.backendUrl;
157
+ }
158
+ if (args.providers !== undefined) {
159
+ opts.TRUSTIFY_DA_PROVIDERS = args.providers;
160
+ }
161
+ if (args.sources !== undefined) {
162
+ opts.TRUSTIFY_DA_SOURCES = args.sources;
163
+ }
164
+ let res = await client.imageAnalysis(imageRefs, html, opts);
85
165
  if (summary && !html) {
86
166
  let summaries = {};
87
167
  for (let [imageRef, report] of Object.entries(res)) {
@@ -132,13 +212,34 @@ const stack = {
132
212
  desc: 'Workspace root directory (for monorepos; lock file is expected here)',
133
213
  type: 'string',
134
214
  normalize: true,
215
+ },
216
+ backendUrl: {
217
+ desc: 'Trustify DA backend URL (env: TRUSTIFY_DA_BACKEND_URL)',
218
+ type: 'string',
219
+ },
220
+ providers: {
221
+ desc: 'Comma-separated list of vulnerability providers (env: TRUSTIFY_DA_PROVIDERS)',
222
+ type: 'string',
223
+ },
224
+ sources: {
225
+ desc: 'Comma-separated list of vulnerability sources (env: TRUSTIFY_DA_SOURCES)',
226
+ type: 'string',
135
227
  }
136
- }),
228
+ }).middleware(configMiddleware('/path/to/manifest')),
137
229
  handler: async (args) => {
138
230
  let manifest = args['/path/to/manifest'];
139
231
  let html = args['html'];
140
232
  let summary = args['summary'];
141
233
  const opts = args.workspaceDir ? { TRUSTIFY_DA_WORKSPACE_DIR: args.workspaceDir } : {};
234
+ if (args.backendUrl !== undefined) {
235
+ opts.TRUSTIFY_DA_BACKEND_URL = args.backendUrl;
236
+ }
237
+ if (args.providers !== undefined) {
238
+ opts.TRUSTIFY_DA_PROVIDERS = args.providers;
239
+ }
240
+ if (args.sources !== undefined) {
241
+ opts.TRUSTIFY_DA_SOURCES = args.sources;
242
+ }
142
243
  let theProvidersSummary = new Map();
143
244
  let theProvidersObject = {};
144
245
  let res = await client.stackAnalysis(manifest, html, opts);
@@ -201,13 +302,28 @@ const stackBatch = {
201
302
  desc: 'Stop on first invalid package.json or SBOM error (env: TRUSTIFY_DA_CONTINUE_ON_ERROR=false)',
202
303
  type: 'boolean',
203
304
  default: false,
305
+ },
306
+ backendUrl: {
307
+ desc: 'Trustify DA backend URL (env: TRUSTIFY_DA_BACKEND_URL)',
308
+ type: 'string',
309
+ },
310
+ providers: {
311
+ desc: 'Comma-separated list of vulnerability providers (env: TRUSTIFY_DA_PROVIDERS)',
312
+ type: 'string',
313
+ },
314
+ sources: {
315
+ desc: 'Comma-separated list of vulnerability sources (env: TRUSTIFY_DA_SOURCES)',
316
+ type: 'string',
204
317
  }
205
- }),
318
+ }).middleware(configMiddleware('/path/to/workspace-root')),
206
319
  handler: async (args) => {
207
320
  const workspaceRoot = args['/path/to/workspace-root'];
208
321
  const html = args['html'];
209
322
  const summary = args['summary'];
210
323
  const opts = {};
324
+ if (args.backendUrl !== undefined) {
325
+ opts.TRUSTIFY_DA_BACKEND_URL = args.backendUrl;
326
+ }
211
327
  if (args.concurrency != null) {
212
328
  opts.batchConcurrency = args.concurrency;
213
329
  }
@@ -221,6 +337,12 @@ const stackBatch = {
221
337
  if (args.failFast) {
222
338
  opts.continueOnError = false;
223
339
  }
340
+ if (args.providers !== undefined) {
341
+ opts.TRUSTIFY_DA_PROVIDERS = args.providers;
342
+ }
343
+ if (args.sources !== undefined) {
344
+ opts.TRUSTIFY_DA_SOURCES = args.sources;
345
+ }
224
346
  let res = await client.stackAnalysisBatch(workspaceRoot, html, opts);
225
347
  const batchAnalysis = res && typeof res === 'object' && res != null && 'analysis' in res ? res.analysis : res;
226
348
  if (summary && !html && typeof batchAnalysis === 'object') {
@@ -373,9 +495,68 @@ const sbom = {
373
495
  }
374
496
  }
375
497
  };
498
+ const remediate = {
499
+ command: 'remediate <path>',
500
+ desc: 'Scan and apply vulnerability remediations to manifest files',
501
+ builder: yargs => yargs.positional('path', {
502
+ desc: 'Path to manifest file or directory',
503
+ type: 'string',
504
+ normalize: true,
505
+ }).options({
506
+ 'dry-run': {
507
+ alias: 'd',
508
+ type: 'boolean',
509
+ desc: 'Preview changes without modifying files',
510
+ },
511
+ providers: {
512
+ desc: 'Comma-separated list of vulnerability providers (env: TRUSTIFY_DA_PROVIDERS)',
513
+ type: 'string',
514
+ },
515
+ sources: {
516
+ desc: 'Comma-separated list of vulnerability sources (env: TRUSTIFY_DA_SOURCES)',
517
+ type: 'string',
518
+ },
519
+ 'group-by': {
520
+ type: 'string',
521
+ choices: ['dependency', 'bundle'],
522
+ desc: 'Report grouping strategy (default: dependency)',
523
+ },
524
+ backendUrl: {
525
+ desc: 'Trustify DA backend URL (env: TRUSTIFY_DA_BACKEND_URL)',
526
+ type: 'string',
527
+ },
528
+ }).middleware(configMiddleware('path', { groupBy: true })),
529
+ handler: async (args) => {
530
+ try {
531
+ const result = await runRemediation(args.path, {
532
+ dryRun: args['dry-run'],
533
+ providers: args.providers,
534
+ sources: args.sources,
535
+ backendUrl: args.backendUrl,
536
+ });
537
+ if (result.remediations.length === 0 && result.manifests.length === 0) {
538
+ console.log('No supported manifest files found.');
539
+ process.exit(result.exitCode);
540
+ }
541
+ if (!args['dry-run'] && result.appliedFiles.length > 0) {
542
+ console.log(`Updated ${result.appliedFiles.length} file(s):`);
543
+ for (const file of result.appliedFiles) {
544
+ console.log(` ${file}`);
545
+ }
546
+ console.log('');
547
+ }
548
+ console.log(generateReport(result.remediations, { groupBy: args['group-by'], dryRun: args['dry-run'] }));
549
+ process.exit(result.exitCode);
550
+ }
551
+ catch (err) {
552
+ console.error(err.message);
553
+ process.exit(1);
554
+ }
555
+ }
556
+ };
376
557
  // parse and invoke the command
377
558
  yargs(hideBin(process.argv))
378
- .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|sbom}`)
559
+ .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|sbom|remediate}`)
379
560
  .command(stack)
380
561
  .command(stackBatch)
381
562
  .command(component)
@@ -383,6 +564,7 @@ yargs(hideBin(process.argv))
383
564
  .command(validateToken)
384
565
  .command(license)
385
566
  .command(sbom)
567
+ .command(remediate)
386
568
  .scriptName('')
387
569
  .version(false)
388
570
  .demandCommand(1)
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Discovers and parses `.trustify-da.yml` by walking up from `startPath`.
3
+ * A missing config file is not an error — an empty object is returned so callers
4
+ * can fall back to defaults.
5
+ * @param {string} [startPath] - manifest file or directory to start the search from
6
+ * @returns {FileConfig} the parsed config object, or `{}` when no file is found
7
+ * @throws {Error} when the config file exists but contains malformed YAML
8
+ */
9
+ export function loadConfig(startPath?: string): FileConfig;
10
+ /**
11
+ * Merges config sources with precedence: CLI flag > environment variable
12
+ * (`TRUSTIFY_DA_*`) > config file > hardcoded default. Providers and sources are
13
+ * normalized to arrays regardless of whether the source used a YAML array or a
14
+ * comma-separated string.
15
+ * @param {FileConfig} [fileConfig={}] - values parsed from `.trustify-da.yml`
16
+ * @param {{ providers?: string, sources?: string, groupBy?: string, backendUrl?: string }} [cliFlags={}]
17
+ * @param {{ [key: string]: string | undefined }} [envVars={}] - typically `process.env`
18
+ * @returns {ResolvedConfig} the resolved, typed config object
19
+ */
20
+ export function mergeConfig(fileConfig?: FileConfig, cliFlags?: {
21
+ providers?: string;
22
+ sources?: string;
23
+ groupBy?: string;
24
+ backendUrl?: string;
25
+ }, envVars?: {
26
+ [key: string]: string | undefined;
27
+ }): ResolvedConfig;
28
+ /**
29
+ * Loads and merges the project configuration for a target path.
30
+ * @param {string} [startPath] - manifest file or directory to start discovery from
31
+ * @param {{ providers?: string, sources?: string, groupBy?: string, backendUrl?: string }} [cliFlags={}] - explicit options
32
+ * @param {{ [key: string]: string | undefined }} [envVars={}] - typically `process.env`
33
+ * @returns {ResolvedConfig} the resolved, typed config object
34
+ */
35
+ export function resolveConfig(startPath?: string, cliFlags?: {
36
+ providers?: string;
37
+ sources?: string;
38
+ groupBy?: string;
39
+ backendUrl?: string;
40
+ }, envVars?: {
41
+ [key: string]: string | undefined;
42
+ }): ResolvedConfig;
43
+ /**
44
+ * @typedef {{
45
+ * 'group-by'?: 'dependency' | 'bundle',
46
+ * exclude?: string[],
47
+ * labels?: string[],
48
+ * 'branch-prefix'?: string,
49
+ * [key: string]: unknown
50
+ * }} RemediationConfig
51
+ */
52
+ /** @typedef {{ critical?: number, high?: number, 'license-conflicts'?: number, [key: string]: unknown }} FailOnConfig */
53
+ /** @typedef {{ 'fail-on'?: FailOnConfig, [key: string]: unknown }} CheckConfig */
54
+ /** @typedef {{ format?: string, targets?: string[], [key: string]: unknown }} SbomConfig */
55
+ /**
56
+ * @typedef {{
57
+ * 'backend-url'?: string,
58
+ * providers?: string[] | string,
59
+ * sources?: string[] | string,
60
+ * remediation?: RemediationConfig,
61
+ * check?: CheckConfig,
62
+ * sbom?: SbomConfig,
63
+ * [key: string]: unknown
64
+ * }} FileConfig
65
+ */
66
+ /**
67
+ * @typedef {{
68
+ * backendUrl: string | null,
69
+ * backendUrlSource: 'cli' | 'environment' | 'file' | 'default',
70
+ * providers: string[],
71
+ * sources: string[],
72
+ * groupBy: string,
73
+ * remediation: object,
74
+ * check: object,
75
+ * sbom: object
76
+ * }} ResolvedConfig
77
+ */
78
+ /** Config file names discovered by walking up the directory tree, in precedence order. */
79
+ export const CONFIG_FILENAMES: string[];
80
+ export type RemediationConfig = {
81
+ "group-by"?: "dependency" | "bundle";
82
+ exclude?: string[];
83
+ labels?: string[];
84
+ "branch-prefix"?: string;
85
+ [key: string]: unknown;
86
+ };
87
+ export type FailOnConfig = {
88
+ critical?: number;
89
+ high?: number;
90
+ "license-conflicts"?: number;
91
+ [key: string]: unknown;
92
+ };
93
+ export type CheckConfig = {
94
+ "fail-on"?: FailOnConfig;
95
+ [key: string]: unknown;
96
+ };
97
+ export type SbomConfig = {
98
+ format?: string;
99
+ targets?: string[];
100
+ [key: string]: unknown;
101
+ };
102
+ export type FileConfig = {
103
+ "backend-url"?: string;
104
+ providers?: string[] | string;
105
+ sources?: string[] | string;
106
+ remediation?: RemediationConfig;
107
+ check?: CheckConfig;
108
+ sbom?: SbomConfig;
109
+ [key: string]: unknown;
110
+ };
111
+ export type ResolvedConfig = {
112
+ backendUrl: string | null;
113
+ backendUrlSource: "cli" | "environment" | "file" | "default";
114
+ providers: string[];
115
+ sources: string[];
116
+ groupBy: string;
117
+ remediation: object;
118
+ check: object;
119
+ sbom: object;
120
+ };
@@ -0,0 +1,260 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { load as yamlLoad } from 'js-yaml';
4
+ /**
5
+ * @typedef {{
6
+ * 'group-by'?: 'dependency' | 'bundle',
7
+ * exclude?: string[],
8
+ * labels?: string[],
9
+ * 'branch-prefix'?: string,
10
+ * [key: string]: unknown
11
+ * }} RemediationConfig
12
+ */
13
+ /** @typedef {{ critical?: number, high?: number, 'license-conflicts'?: number, [key: string]: unknown }} FailOnConfig */
14
+ /** @typedef {{ 'fail-on'?: FailOnConfig, [key: string]: unknown }} CheckConfig */
15
+ /** @typedef {{ format?: string, targets?: string[], [key: string]: unknown }} SbomConfig */
16
+ /**
17
+ * @typedef {{
18
+ * 'backend-url'?: string,
19
+ * providers?: string[] | string,
20
+ * sources?: string[] | string,
21
+ * remediation?: RemediationConfig,
22
+ * check?: CheckConfig,
23
+ * sbom?: SbomConfig,
24
+ * [key: string]: unknown
25
+ * }} FileConfig
26
+ */
27
+ /**
28
+ * @typedef {{
29
+ * backendUrl: string | null,
30
+ * backendUrlSource: 'cli' | 'environment' | 'file' | 'default',
31
+ * providers: string[],
32
+ * sources: string[],
33
+ * groupBy: string,
34
+ * remediation: object,
35
+ * check: object,
36
+ * sbom: object
37
+ * }} ResolvedConfig
38
+ */
39
+ /** Config file names discovered by walking up the directory tree, in precedence order. */
40
+ export const CONFIG_FILENAMES = ['.trustify-da.yml', '.trustify-da.yaml'];
41
+ function isMapping(value) {
42
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
43
+ return false;
44
+ }
45
+ const prototype = Object.getPrototypeOf(value);
46
+ return prototype === Object.prototype || prototype == null;
47
+ }
48
+ function assertMapping(value, field, source) {
49
+ if (!isMapping(value)) {
50
+ throw new Error(`Invalid config file ${source}: ${field} must be a mapping`);
51
+ }
52
+ }
53
+ function assertString(value, field, source) {
54
+ if (typeof value !== 'string') {
55
+ throw new Error(`Invalid config file ${source}: ${field} must be a string`);
56
+ }
57
+ }
58
+ function assertStringArray(value, field, source) {
59
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
60
+ throw new Error(`Invalid config file ${source}: ${field} must be an array of strings`);
61
+ }
62
+ }
63
+ function assertStringOrArray(value, field, source) {
64
+ if (typeof value !== 'string') {
65
+ assertStringArray(value, field, source);
66
+ }
67
+ }
68
+ /**
69
+ * Validates all fields in the initial configuration schema. Unknown fields are preserved.
70
+ * @param {unknown} config
71
+ * @param {string} [source]
72
+ * @returns {FileConfig}
73
+ */
74
+ function validateConfig(config, source = '<config>') {
75
+ assertMapping(config, 'root', source);
76
+ if (config['backend-url'] !== undefined) {
77
+ assertString(config['backend-url'], 'backend-url', source);
78
+ }
79
+ if (config.providers !== undefined) {
80
+ assertStringOrArray(config.providers, 'providers', source);
81
+ }
82
+ if (config.sources !== undefined) {
83
+ assertStringOrArray(config.sources, 'sources', source);
84
+ }
85
+ if (config.remediation !== undefined) {
86
+ assertMapping(config.remediation, 'remediation', source);
87
+ const remediation = config.remediation;
88
+ if (remediation['group-by'] !== undefined && !['dependency', 'bundle'].includes(remediation['group-by'])) {
89
+ throw new Error(`Invalid config file ${source}: remediation.group-by must be dependency or bundle`);
90
+ }
91
+ if (remediation.exclude !== undefined) {
92
+ assertStringArray(remediation.exclude, 'remediation.exclude', source);
93
+ }
94
+ if (remediation.labels !== undefined) {
95
+ assertStringArray(remediation.labels, 'remediation.labels', source);
96
+ }
97
+ if (remediation['branch-prefix'] !== undefined) {
98
+ assertString(remediation['branch-prefix'], 'remediation.branch-prefix', source);
99
+ }
100
+ }
101
+ if (config.check !== undefined) {
102
+ assertMapping(config.check, 'check', source);
103
+ if (config.check['fail-on'] !== undefined) {
104
+ assertMapping(config.check['fail-on'], 'check.fail-on', source);
105
+ for (const field of ['critical', 'high', 'license-conflicts']) {
106
+ const value = config.check['fail-on'][field];
107
+ if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value))) {
108
+ throw new Error(`Invalid config file ${source}: check.fail-on.${field} must be a finite number`);
109
+ }
110
+ }
111
+ }
112
+ }
113
+ if (config.sbom !== undefined) {
114
+ assertMapping(config.sbom, 'sbom', source);
115
+ if (config.sbom.format !== undefined) {
116
+ assertString(config.sbom.format, 'sbom.format', source);
117
+ }
118
+ if (config.sbom.targets !== undefined) {
119
+ assertStringArray(config.sbom.targets, 'sbom.targets', source);
120
+ }
121
+ }
122
+ return config;
123
+ }
124
+ /**
125
+ * Walks up from `startPath` looking for a `.trustify-da.yml` (or `.yaml`) file,
126
+ * similar to how `.eslintrc` discovery works. If `startPath` points at a file,
127
+ * discovery begins in its containing directory. Within a directory, `.yml` takes
128
+ * precedence over `.yaml`.
129
+ * @param {string} startPath - manifest file or directory to start the search from
130
+ * @returns {string | null} absolute path to the config file, or null if none found
131
+ */
132
+ function findConfigFile(startPath) {
133
+ let dir = path.resolve(startPath || '.');
134
+ try {
135
+ if (fs.statSync(dir).isFile()) {
136
+ dir = path.dirname(dir);
137
+ }
138
+ }
139
+ catch {
140
+ // startPath may not exist yet — walk up from its resolved location anyway
141
+ }
142
+ // Walk up until the filesystem root (where dirname(dir) === dir)
143
+ for (;;) {
144
+ for (const name of CONFIG_FILENAMES) {
145
+ const candidate = path.join(dir, name);
146
+ try {
147
+ if (fs.statSync(candidate).isFile()) {
148
+ return candidate;
149
+ }
150
+ }
151
+ catch {
152
+ // Ignore inaccessible or disappearing candidates and continue discovery.
153
+ }
154
+ }
155
+ const parent = path.dirname(dir);
156
+ if (parent === dir) {
157
+ return null;
158
+ }
159
+ dir = parent;
160
+ }
161
+ }
162
+ /**
163
+ * Discovers and parses `.trustify-da.yml` by walking up from `startPath`.
164
+ * A missing config file is not an error — an empty object is returned so callers
165
+ * can fall back to defaults.
166
+ * @param {string} [startPath] - manifest file or directory to start the search from
167
+ * @returns {FileConfig} the parsed config object, or `{}` when no file is found
168
+ * @throws {Error} when the config file exists but contains malformed YAML
169
+ */
170
+ export function loadConfig(startPath) {
171
+ const configPath = findConfigFile(startPath);
172
+ if (!configPath) {
173
+ return {};
174
+ }
175
+ const content = fs.readFileSync(configPath, 'utf-8');
176
+ let doc;
177
+ try {
178
+ doc = yamlLoad(content);
179
+ }
180
+ catch (err) {
181
+ throw new Error(`Failed to parse config file ${configPath}: ${err.message}`);
182
+ }
183
+ return doc == null ? {} : validateConfig(doc, configPath);
184
+ }
185
+ /**
186
+ * Normalizes a providers/sources value to an array of trimmed strings. Accepts
187
+ * either a YAML array (`[redhat, osv]`) or a comma-separated string (`redhat,osv`).
188
+ * @param {string[] | string | undefined | null} value
189
+ * @returns {string[]}
190
+ */
191
+ function toArray(value) {
192
+ if (Array.isArray(value)) {
193
+ return value.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim());
194
+ }
195
+ if (typeof value === 'string') {
196
+ return value.split(',').map(v => v.trim()).filter(Boolean);
197
+ }
198
+ return [];
199
+ }
200
+ /**
201
+ * Returns the first argument that is neither undefined nor null.
202
+ * @param {...unknown} values
203
+ * @returns {unknown}
204
+ */
205
+ function first(...values) {
206
+ return values.find(v => v != null);
207
+ }
208
+ /**
209
+ * Merges config sources with precedence: CLI flag > environment variable
210
+ * (`TRUSTIFY_DA_*`) > config file > hardcoded default. Providers and sources are
211
+ * normalized to arrays regardless of whether the source used a YAML array or a
212
+ * comma-separated string.
213
+ * @param {FileConfig} [fileConfig={}] - values parsed from `.trustify-da.yml`
214
+ * @param {{ providers?: string, sources?: string, groupBy?: string, backendUrl?: string }} [cliFlags={}]
215
+ * @param {{ [key: string]: string | undefined }} [envVars={}] - typically `process.env`
216
+ * @returns {ResolvedConfig} the resolved, typed config object
217
+ */
218
+ export function mergeConfig(fileConfig = {}, cliFlags = {}, envVars = {}) {
219
+ const file = validateConfig(fileConfig || {});
220
+ const cli = cliFlags || {};
221
+ const env = envVars || {};
222
+ let backendUrl = null;
223
+ let backendUrlSource = 'default';
224
+ if (cli.backendUrl != null) {
225
+ backendUrl = cli.backendUrl;
226
+ backendUrlSource = 'cli';
227
+ }
228
+ else if (env.TRUSTIFY_DA_BACKEND_URL != null) {
229
+ backendUrl = env.TRUSTIFY_DA_BACKEND_URL;
230
+ backendUrlSource = 'environment';
231
+ }
232
+ else if (file['backend-url'] != null) {
233
+ backendUrl = file['backend-url'];
234
+ backendUrlSource = 'file';
235
+ }
236
+ const groupBy = first(cli.groupBy, env.TRUSTIFY_DA_GROUP_BY, file.remediation?.['group-by']) ?? 'dependency';
237
+ if (!['dependency', 'bundle'].includes(groupBy)) {
238
+ throw new Error(`Invalid group-by value "${groupBy}". Expected dependency or bundle.`);
239
+ }
240
+ return {
241
+ backendUrl,
242
+ backendUrlSource,
243
+ providers: toArray(first(cli.providers, env.TRUSTIFY_DA_PROVIDERS, file.providers)),
244
+ sources: toArray(first(cli.sources, env.TRUSTIFY_DA_SOURCES, file.sources)),
245
+ groupBy,
246
+ remediation: file.remediation ?? {},
247
+ check: file.check ?? {},
248
+ sbom: file.sbom ?? {},
249
+ };
250
+ }
251
+ /**
252
+ * Loads and merges the project configuration for a target path.
253
+ * @param {string} [startPath] - manifest file or directory to start discovery from
254
+ * @param {{ providers?: string, sources?: string, groupBy?: string, backendUrl?: string }} [cliFlags={}] - explicit options
255
+ * @param {{ [key: string]: string | undefined }} [envVars={}] - typically `process.env`
256
+ * @returns {ResolvedConfig} the resolved, typed config object
257
+ */
258
+ export function resolveConfig(startPath, cliFlags = {}, envVars = {}) {
259
+ return mergeConfig(loadConfig(startPath), cliFlags, envVars);
260
+ }
@@ -26,6 +26,19 @@ export default class CycloneDxSbom {
26
26
  alg: string;
27
27
  content: string;
28
28
  }>): CycloneDxSbom;
29
+ /**
30
+ * Attach hashes to already-added components by matching their PURL. This is a
31
+ * post-processing step so ecosystem-specific hash sources (e.g. Maven reading
32
+ * the local .m2 cache) can enrich the SBOM without threading their concern
33
+ * through the shared dependency-tree parser. Components without a matching
34
+ * entry, or whose hashes are already set, are left untouched.
35
+ * @param {Map<string, Array<{alg: string, content: string}>>} hashMap - PURL→hashes map
36
+ * @return {CycloneDxSbom} the updated SBOM
37
+ */
38
+ attachHashes(hashMap: Map<string, Array<{
39
+ alg: string;
40
+ content: string;
41
+ }>>): CycloneDxSbom;
29
42
  /** @param {{}} opts - various options, settings and configuration of application.
30
43
  * @return String CycloneDx Sbom json object in a string format
31
44
  */