@trustify-da/trustify-da-javascript-client 0.3.0-ea.38515a7 → 0.3.0-ea.3aa2054

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/dist/src/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import fs from 'node:fs';
2
3
  import * as path from "path";
3
4
  import yargs from 'yargs';
4
5
  import { hideBin } from 'yargs/helpers';
5
6
  import { getProjectLicense, getLicenseDetails } from './license/index.js';
6
- import client, { selectTrustifyDABackend } from './index.js';
7
+ import client, { selectTrustifyDABackend, generateSbom } from './index.js';
7
8
  // command for component analysis take manifest type and content
8
9
  const component = {
9
10
  command: 'component </path/to/manifest>',
@@ -325,15 +326,63 @@ const license = {
325
326
  console.log(JSON.stringify(output, null, 2));
326
327
  }
327
328
  };
329
+ const sbom = {
330
+ command: 'sbom </path/to/manifest> [--output]',
331
+ desc: 'generate a CycloneDX SBOM from a manifest file',
332
+ builder: yargs => yargs.positional('/path/to/manifest', {
333
+ desc: 'manifest path for SBOM generation',
334
+ type: 'string',
335
+ normalize: true,
336
+ }).options({
337
+ output: {
338
+ alias: 'o',
339
+ desc: 'Write SBOM JSON to a file instead of stdout',
340
+ type: 'string',
341
+ normalize: true,
342
+ },
343
+ workspaceDir: {
344
+ alias: 'w',
345
+ desc: 'Workspace root directory (for monorepos; lock file is expected here)',
346
+ type: 'string',
347
+ normalize: true,
348
+ }
349
+ }),
350
+ handler: async (args) => {
351
+ let manifest = args['/path/to/manifest'];
352
+ const opts = args.workspaceDir ? { TRUSTIFY_DA_WORKSPACE_DIR: args.workspaceDir } : {};
353
+ let result;
354
+ try {
355
+ result = await generateSbom(manifest, opts);
356
+ }
357
+ catch (err) {
358
+ console.error(JSON.stringify({ error: `Failed to generate SBOM: ${err.message}` }, null, 2));
359
+ process.exit(1);
360
+ }
361
+ const json = JSON.stringify(result, null, 2);
362
+ if (args.output) {
363
+ try {
364
+ fs.writeFileSync(args.output, json);
365
+ }
366
+ catch (err) {
367
+ console.error(JSON.stringify({ error: `Failed to write output file: ${err.message}` }, null, 2));
368
+ process.exit(1);
369
+ }
370
+ }
371
+ else {
372
+ console.log(json);
373
+ }
374
+ }
375
+ };
328
376
  // parse and invoke the command
329
377
  yargs(hideBin(process.argv))
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}`)
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}`)
331
379
  .command(stack)
332
380
  .command(stackBatch)
333
381
  .command(component)
334
382
  .command(image)
335
383
  .command(validateToken)
336
384
  .command(license)
385
+ .command(sbom)
337
386
  .scriptName('')
338
387
  .version(false)
339
388
  .demandCommand(1)
@@ -13,6 +13,15 @@ export function selectTrustifyDABackend(opts?: {
13
13
  TRUSTIFY_DA_DEBUG?: string | undefined;
14
14
  TRUSTIFY_DA_BACKEND_URL?: string | undefined;
15
15
  }): string;
16
+ /**
17
+ * Generate a CycloneDX SBOM from a manifest file. No backend HTTP request is made.
18
+ *
19
+ * @param {string} manifestPath - path to the manifest file (e.g. pom.xml, package.json)
20
+ * @param {Options} [opts={}] - optional options (e.g. workspace dir, tool paths)
21
+ * @returns {Promise<object>} parsed CycloneDX SBOM JSON object
22
+ * @throws {Error} if the manifest is unsupported or SBOM generation fails
23
+ */
24
+ export function generateSbom(manifestPath: string, opts?: Options): Promise<object>;
16
25
  export { parseImageRef } from "./oci_image/utils.js";
17
26
  export { ImageRef } from "./oci_image/images.js";
18
27
  declare namespace _default {
@@ -21,6 +30,7 @@ declare namespace _default {
21
30
  export { stackAnalysisBatch };
22
31
  export { imageAnalysis };
23
32
  export { validateToken };
33
+ export { generateSbom };
24
34
  }
25
35
  export default _default;
26
36
  export type Options = {
package/dist/src/index.js CHANGED
@@ -12,7 +12,7 @@ import * as url from 'url';
12
12
  export { parseImageRef } from "./oci_image/utils.js";
13
13
  export { ImageRef } from "./oci_image/images.js";
14
14
  export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDetails, licensesFromReport, normalizeLicensesResponse, runLicenseCheck, getCompatibility } from "./license/index.js";
15
- export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken };
15
+ export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken, generateSbom };
16
16
  export { discoverWorkspacePackages, discoverWorkspaceCrates, validatePackageJson, resolveWorkspaceDiscoveryIgnore, filterManifestPathsByDiscoveryIgnore, resolveContinueOnError, resolveBatchMetadata, };
17
17
  /**
18
18
  * @typedef {{
@@ -237,6 +237,22 @@ function buildBatchAnalysisMetadata(root, ecosystem, totalSbomAttempts, successf
237
237
  errors: [...errors],
238
238
  };
239
239
  }
240
+ /**
241
+ * Generate a CycloneDX SBOM from a manifest file. No backend HTTP request is made.
242
+ *
243
+ * @param {string} manifestPath - path to the manifest file (e.g. pom.xml, package.json)
244
+ * @param {Options} [opts={}] - optional options (e.g. workspace dir, tool paths)
245
+ * @returns {Promise<object>} parsed CycloneDX SBOM JSON object
246
+ * @throws {Error} if the manifest is unsupported or SBOM generation fails
247
+ */
248
+ export async function generateSbom(manifestPath, opts = {}) {
249
+ fs.accessSync(manifestPath, fs.constants.R_OK);
250
+ const result = await generateOneSbom(manifestPath, opts);
251
+ if (!result.ok) {
252
+ throw new Error(`Failed to generate SBOM for ${result.manifestPath}: ${result.reason}`);
253
+ }
254
+ return result.sbom;
255
+ }
240
256
  /**
241
257
  * @typedef {{ ok: true, purl: string, sbom: object } | { ok: false, manifestPath: string, reason: string }} SbomResult
242
258
  */
@@ -10,9 +10,29 @@ export default class Base_pyproject {
10
10
  isSupported(manifestName: string): boolean;
11
11
  /**
12
12
  * @param {string} manifestDir
13
+ * @param {Object} [opts={}]
14
+ * @returns {boolean}
15
+ */
16
+ validateLockFile(manifestDir: string, opts?: any): boolean;
17
+ /**
18
+ * Walk up from manifestDir to find the directory containing the lock file.
19
+ * Follows the same pattern as Base_javascript._findLockFileDir().
20
+ * @param {string} manifestDir
21
+ * @param {Object} [opts={}]
22
+ * @returns {string|null}
23
+ * @protected
24
+ */
25
+ protected _findLockFileDir(manifestDir: string, opts?: any): string | null;
26
+ /**
27
+ * Detect workspace root boundaries.
28
+ * Currently only uv has native workspace support ([tool.uv.workspace] in pyproject.toml).
29
+ * Poetry has no workspace/monorepo support (python-poetry/poetry#2270), so each
30
+ * poetry project is treated independently — see Python_poetry._findLockFileDir().
31
+ * @param {string} dir
13
32
  * @returns {boolean}
33
+ * @protected
14
34
  */
15
- validateLockFile(manifestDir: string): boolean;
35
+ protected _isWorkspaceRoot(dir: string): boolean;
16
36
  /**
17
37
  * Read project license from pyproject.toml, with fallback to LICENSE file.
18
38
  * @param {string} manifestPath
@@ -43,13 +63,16 @@ export default class Base_pyproject {
43
63
  protected _cmdName(): string;
44
64
  /**
45
65
  * Resolve dependencies using the tool-specific command and parser.
46
- * @param {string} manifestDir
66
+ *
67
+ * @param {string} manifestDir - directory containing the target pyproject.toml
68
+ * @param {string} workspaceDir - workspace root (where the lock file lives);
69
+ * only used by providers that need workspace-level resolution (e.g. uv)
47
70
  * @param {object} parsed - parsed pyproject.toml
48
71
  * @param {Object} opts
49
72
  * @returns {Promise<DependencyData>}
50
73
  * @protected
51
74
  */
52
- protected _getDependencyData(manifestDir: string, parsed: object, opts: any): Promise<DependencyData>;
75
+ protected _getDependencyData(manifestDir: string, workspaceDir: string, parsed: object, opts: any): Promise<DependencyData>;
53
76
  /**
54
77
  * Canonicalize a Python package name per PEP 503.
55
78
  * @param {string} name
@@ -4,6 +4,7 @@ import { PackageURL } from 'packageurl-js';
4
4
  import { parse as parseToml } from 'smol-toml';
5
5
  import { getLicense } from '../license/license_utils.js';
6
6
  import Sbom from '../sbom.js';
7
+ import { getCustom } from '../tools.js';
7
8
  const ecosystem = 'pip';
8
9
  const IGNORE_MARKERS = ['exhortignore', 'trustify-da-ignore'];
9
10
  const DEFAULT_ROOT_NAME = 'default-pip-root';
@@ -22,10 +23,64 @@ export default class Base_pyproject {
22
23
  }
23
24
  /**
24
25
  * @param {string} manifestDir
26
+ * @param {Object} [opts={}]
25
27
  * @returns {boolean}
26
28
  */
27
- validateLockFile(manifestDir) {
28
- return fs.existsSync(path.join(manifestDir, this._lockFileName()));
29
+ validateLockFile(manifestDir, opts = {}) {
30
+ return this._findLockFileDir(manifestDir, opts) != null;
31
+ }
32
+ /**
33
+ * Walk up from manifestDir to find the directory containing the lock file.
34
+ * Follows the same pattern as Base_javascript._findLockFileDir().
35
+ * @param {string} manifestDir
36
+ * @param {Object} [opts={}]
37
+ * @returns {string|null}
38
+ * @protected
39
+ */
40
+ _findLockFileDir(manifestDir, opts = {}) {
41
+ const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts);
42
+ if (workspaceDir) {
43
+ const dir = path.resolve(workspaceDir);
44
+ return fs.existsSync(path.join(dir, this._lockFileName())) ? dir : null;
45
+ }
46
+ let dir = path.resolve(manifestDir);
47
+ let parent = dir;
48
+ do {
49
+ dir = parent;
50
+ if (fs.existsSync(path.join(dir, this._lockFileName()))) {
51
+ return dir;
52
+ }
53
+ if (this._isWorkspaceRoot(dir)) {
54
+ return null;
55
+ }
56
+ parent = path.dirname(dir);
57
+ } while (parent !== dir);
58
+ return null;
59
+ }
60
+ /**
61
+ * Detect workspace root boundaries.
62
+ * Currently only uv has native workspace support ([tool.uv.workspace] in pyproject.toml).
63
+ * Poetry has no workspace/monorepo support (python-poetry/poetry#2270), so each
64
+ * poetry project is treated independently — see Python_poetry._findLockFileDir().
65
+ * @param {string} dir
66
+ * @returns {boolean}
67
+ * @protected
68
+ */
69
+ _isWorkspaceRoot(dir) {
70
+ const pyprojectPath = path.join(dir, 'pyproject.toml');
71
+ if (!fs.existsSync(pyprojectPath)) {
72
+ return false;
73
+ }
74
+ try {
75
+ const content = parseToml(fs.readFileSync(pyprojectPath, 'utf-8'));
76
+ if (content.tool?.uv?.workspace) {
77
+ return true;
78
+ }
79
+ }
80
+ catch (_) {
81
+ // ignore parse errors
82
+ }
83
+ return false;
29
84
  }
30
85
  /**
31
86
  * Read project license from pyproject.toml, with fallback to LICENSE file.
@@ -91,14 +146,17 @@ export default class Base_pyproject {
91
146
  }
92
147
  /**
93
148
  * Resolve dependencies using the tool-specific command and parser.
94
- * @param {string} manifestDir
149
+ *
150
+ * @param {string} manifestDir - directory containing the target pyproject.toml
151
+ * @param {string} workspaceDir - workspace root (where the lock file lives);
152
+ * only used by providers that need workspace-level resolution (e.g. uv)
95
153
  * @param {object} parsed - parsed pyproject.toml
96
154
  * @param {Object} opts
97
155
  * @returns {Promise<DependencyData>}
98
156
  * @protected
99
157
  */
100
158
  // eslint-disable-next-line no-unused-vars
101
- async _getDependencyData(manifestDir, parsed, opts) {
159
+ async _getDependencyData(manifestDir, workspaceDir, parsed, opts) {
102
160
  throw new TypeError('_getDependencyData must be implemented');
103
161
  }
104
162
  // --- shared helpers ---
@@ -257,7 +315,8 @@ export default class Base_pyproject {
257
315
  let manifestDir = path.dirname(manifest);
258
316
  let content = fs.readFileSync(manifest, 'utf-8');
259
317
  let parsed = parseToml(content);
260
- let { directDeps, graph } = await this._getDependencyData(manifestDir, parsed, opts);
318
+ let workspaceDir = this._findLockFileDir(manifestDir, opts) || manifestDir;
319
+ let { directDeps, graph } = await this._getDependencyData(manifestDir, workspaceDir, parsed, opts);
261
320
  let ignoredDeps = this._getIgnoredDeps(manifest);
262
321
  let dependencies = this._buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive);
263
322
  let sbom = new Sbom();
@@ -1,6 +1,27 @@
1
- import { environmentVariableIsPopulated, getCustomPath, invokeCommand } from '../tools.js';
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { environmentVariableIsPopulated, getCustom, getCustomPath, invokeCommand } from '../tools.js';
2
4
  import Base_pyproject from './base_pyproject.js';
3
5
  export default class Python_poetry extends Base_pyproject {
6
+ /**
7
+ * Poetry has no native workspace/monorepo support (python-poetry/poetry#2270).
8
+ * Each poetry project is treated independently — no lock file walk-up.
9
+ * Running `poetry show` from a parent directory returns the parent's deps, not
10
+ * the sub-package's, so walk-up would produce incorrect SBOMs.
11
+ * @param {string} manifestDir
12
+ * @param {Object} [opts={}]
13
+ * @returns {string|null}
14
+ * @protected
15
+ */
16
+ _findLockFileDir(manifestDir, opts = {}) {
17
+ const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts);
18
+ if (workspaceDir) {
19
+ const dir = path.resolve(workspaceDir);
20
+ return fs.existsSync(path.join(dir, this._lockFileName())) ? dir : null;
21
+ }
22
+ const dir = path.resolve(manifestDir);
23
+ return fs.existsSync(path.join(dir, this._lockFileName())) ? dir : null;
24
+ }
4
25
  /** @returns {string} */
5
26
  _lockFileName() {
6
27
  return 'poetry.lock';
@@ -11,11 +32,13 @@ export default class Python_poetry extends Base_pyproject {
11
32
  }
12
33
  /**
13
34
  * @param {string} manifestDir
35
+ * @param {string} _workspaceDir - unused (poetry has no workspace support)
14
36
  * @param {object} parsed - parsed pyproject.toml
15
37
  * @param {Object} opts
16
38
  * @returns {Promise<{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}>}
17
39
  */
18
- async _getDependencyData(manifestDir, parsed, opts) {
40
+ // eslint-disable-next-line no-unused-vars
41
+ async _getDependencyData(manifestDir, _workspaceDir, parsed, opts) {
19
42
  let treeOutput = this._getPoetryShowTreeOutput(manifestDir, opts);
20
43
  let showAllOutput = this._getPoetryShowAllOutput(manifestDir, opts);
21
44
  let versionMap = this._parsePoetryShowAll(showAllOutput);
@@ -89,7 +112,7 @@ export default class Python_poetry extends Base_pyproject {
89
112
  continue;
90
113
  }
91
114
  // top-level line: "name version description..."
92
- let topMatch = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s+(\S+)\s/);
115
+ let topMatch = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s+(\S+)(?:\s|$)/);
93
116
  if (topMatch) {
94
117
  let name = topMatch[1];
95
118
  let version = topMatch[2];
@@ -12,9 +12,10 @@ export default class Python_uv extends Base_pyproject {
12
12
  *
13
13
  * @param {string} output
14
14
  * @param {string} projectName - canonical project name to identify direct deps
15
+ * @param {string} workspaceDir - workspace root (for resolving editable install paths)
15
16
  * @returns {Promise<{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}>}
16
17
  */
17
- _parseUvExport(output: string, projectName: string): Promise<{
18
+ _parseUvExport(output: string, projectName: string, workspaceDir: string): Promise<{
18
19
  directDeps: string[];
19
20
  graph: Map<string, {
20
21
  name: string;
@@ -1,3 +1,6 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse as parseToml } from 'smol-toml';
1
4
  import { environmentVariableIsPopulated, getCustomPath, invokeCommand } from '../tools.js';
2
5
  import Base_pyproject from './base_pyproject.js';
3
6
  import { getParser, getPinnedVersionQuery } from './requirements_parser.js';
@@ -11,15 +14,16 @@ export default class Python_uv extends Base_pyproject {
11
14
  return 'uv';
12
15
  }
13
16
  /**
14
- * @param {string} manifestDir
17
+ * @param {string} manifestDir - directory containing the target pyproject.toml
18
+ * @param {string} workspaceDir - workspace root (for resolving editable install paths)
15
19
  * @param {object} parsed - parsed pyproject.toml
16
20
  * @param {Object} opts
17
21
  * @returns {Promise<{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}>}
18
22
  */
19
- async _getDependencyData(manifestDir, parsed, opts) {
23
+ async _getDependencyData(manifestDir, workspaceDir, parsed, opts) {
20
24
  let projectName = this._getProjectName(parsed);
21
25
  let uvOutput = this._getUvExportOutput(manifestDir, opts);
22
- return this._parseUvExport(uvOutput, projectName);
26
+ return this._parseUvExport(uvOutput, projectName, workspaceDir);
23
27
  }
24
28
  /**
25
29
  * Get the uv export output, either from env var or by running the command.
@@ -40,9 +44,10 @@ export default class Python_uv extends Base_pyproject {
40
44
  *
41
45
  * @param {string} output
42
46
  * @param {string} projectName - canonical project name to identify direct deps
47
+ * @param {string} workspaceDir - workspace root (for resolving editable install paths)
43
48
  * @returns {Promise<{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}>}
44
49
  */
45
- async _parseUvExport(output, projectName) {
50
+ async _parseUvExport(output, projectName, workspaceDir) {
46
51
  let [parser, pinnedVersionQuery] = await Promise.all([
47
52
  getParser(), getPinnedVersionQuery()
48
53
  ]);
@@ -53,6 +58,29 @@ export default class Python_uv extends Base_pyproject {
53
58
  let currentPkg = null;
54
59
  let collectingVia = false;
55
60
  for (let child of root.children) {
61
+ if (child.type === 'global_opt') {
62
+ let optNode = child.children.find(c => c.type === 'option');
63
+ let pathNode = child.children.find(c => c.type === 'path');
64
+ if (optNode?.text === '-e' && pathNode && workspaceDir) {
65
+ let memberDir = path.resolve(workspaceDir, pathNode.text);
66
+ let memberManifest = path.join(memberDir, 'pyproject.toml');
67
+ if (fs.existsSync(memberManifest)) {
68
+ let memberParsed = parseToml(fs.readFileSync(memberManifest, 'utf-8'));
69
+ let name = memberParsed.project?.name || memberParsed.tool?.poetry?.name;
70
+ let version = memberParsed.project?.version || memberParsed.tool?.poetry?.version;
71
+ if (name && version) {
72
+ let key = this._canonicalize(name);
73
+ currentPkg = { name, version, parents: new Set() };
74
+ packages.set(key, currentPkg);
75
+ collectingVia = false;
76
+ continue;
77
+ }
78
+ }
79
+ }
80
+ currentPkg = null;
81
+ collectingVia = false;
82
+ continue;
83
+ }
56
84
  if (child.type === 'requirement') {
57
85
  let nameNode = child.children.find(c => c.type === 'package');
58
86
  if (!nameNode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trustify-da/trustify-da-javascript-client",
3
- "version": "0.3.0-ea.38515a7",
3
+ "version": "0.3.0-ea.3aa2054",
4
4
  "description": "Code-Ready Dependency Analytics JavaScript API.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/guacsec/trustify-da-javascript-client#README.md",