diff --git a/src/index.js b/src/index.js index 24d2ed26..138157ef 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ import { getCustom } from "./tools.js"; import { resolveBatchMetadata, resolveContinueOnError } from './batch_opts.js' import { discoverMavenModules } from './providers/java_maven.js' import { discoverGradleSubprojects } from './providers/java_gradle.js' +import { discoverGoWorkspaceModules } from './providers/golang_gomodules.js' import { discoverWorkspaceCrates, discoverWorkspacePackages, @@ -27,6 +28,7 @@ export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnal export { discoverMavenModules, discoverGradleSubprojects, + discoverGoWorkspaceModules, discoverWorkspacePackages, discoverWorkspaceCrates, validatePackageJson, @@ -323,7 +325,7 @@ async function generateOneSbom(manifestPath, workspaceOpts) { * * @param {string} root - Resolved workspace root * @param {Options} opts - * @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'gradle' | 'unknown', manifestPaths: string[] }>} + * @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'gradle' | 'gomodules' | 'unknown', manifestPaths: string[] }>} * @private */ async function detectWorkspaceManifests(root, opts) { @@ -352,6 +354,13 @@ async function detectWorkspaceManifests(root, opts) { } } + if (fs.existsSync(path.join(root, 'go.work'))) { + const manifestPaths = await discoverGoWorkspaceModules(root, opts) + if (manifestPaths.length > 0) { + return { ecosystem: 'gomodules', manifestPaths } + } + } + const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml')) || fs.existsSync(path.join(root, 'yarn.lock')) || fs.existsSync(path.join(root, 'package-lock.json')) @@ -547,7 +556,7 @@ async function stackAnalysisBatch(workspaceRoot, html = false, opts = {}) { } if (manifestPaths.length === 0) { - throw new Error(`No workspace manifests found at ${root}. Ensure Cargo.toml+Cargo.lock or package.json+lock file exist.`) + throw new Error(`No workspace manifests found at ${root}. Ensure a supported workspace root exists (Cargo.toml+Cargo.lock, go.work, or package.json+lock file).`) } const workspaceOpts = { ...opts, TRUSTIFY_DA_WORKSPACE_DIR: root } diff --git a/src/providers/golang_gomodules.js b/src/providers/golang_gomodules.js index 850f1e2f..0ac3be3c 100644 --- a/src/providers/golang_gomodules.js +++ b/src/providers/golang_gomodules.js @@ -6,6 +6,7 @@ import { PackageURL } from 'packageurl-js' import { readLicenseFile } from '../license/license_utils.js' import Sbom from '../sbom.js' import { getCustom, getCustomPath, invokeCommand } from "../tools.js"; +import { filterManifestPathsByDiscoveryIgnore, resolveWorkspaceDiscoveryIgnore } from '../workspace.js' import { getParser, getRequireQuery } from './gomod_parser.js' @@ -427,3 +428,56 @@ function getLineSeparatorGolang() { let reg = /\n|\r\n/ return reg } + +/** + * Discover all go.mod manifest paths in a Go workspace. + * Uses `go work edit -json` to get workspace members. + * + * @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain go.work) + * @param {import('../index.js').Options} [opts={}] + * @returns {Promise} Paths to go.mod files (absolute) + */ +export async function discoverGoWorkspaceModules(workspaceRoot, opts = {}) { + const root = path.resolve(workspaceRoot) + const goWork = path.join(root, 'go.work') + + if (!fs.existsSync(goWork)) { + return [] + } + + const goBin = getCustomPath('go', opts) + let output + try { + output = invokeCommand(goBin, ['work', 'edit', '-json', goWork], { cwd: root }) + } catch { + return [] + } + + let workspace + try { + workspace = JSON.parse(output.toString().trim()) + } catch { + return [] + } + + const useEntries = workspace.Use || [] + if (useEntries.length === 0) { + return [] + } + + const manifestPaths = [] + for (const entry of useEntries) { + const diskPath = entry.DiskPath + if (!diskPath) { + continue + } + const moduleDir = path.resolve(root, diskPath) + const goMod = path.join(moduleDir, 'go.mod') + if (fs.existsSync(goMod)) { + manifestPaths.push(goMod) + } + } + + const ignorePatterns = resolveWorkspaceDiscoveryIgnore(opts) + return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) +} diff --git a/test/providers/tst_manifests/golang/go_workspace/go.work b/test/providers/tst_manifests/golang/go_workspace/go.work new file mode 100644 index 00000000..fd2b5974 --- /dev/null +++ b/test/providers/tst_manifests/golang/go_workspace/go.work @@ -0,0 +1,6 @@ +go 1.22 + +use ( + ./module-a + ./module-b +) diff --git a/test/providers/tst_manifests/golang/go_workspace/module-a/go.mod b/test/providers/tst_manifests/golang/go_workspace/module-a/go.mod new file mode 100644 index 00000000..9d0ee8b3 --- /dev/null +++ b/test/providers/tst_manifests/golang/go_workspace/module-a/go.mod @@ -0,0 +1,3 @@ +module example.com/module-a + +go 1.22 diff --git a/test/providers/tst_manifests/golang/go_workspace/module-b/go.mod b/test/providers/tst_manifests/golang/go_workspace/module-b/go.mod new file mode 100644 index 00000000..670b00fd --- /dev/null +++ b/test/providers/tst_manifests/golang/go_workspace/module-b/go.mod @@ -0,0 +1,3 @@ +module example.com/module-b + +go 1.22 diff --git a/test/providers/tst_manifests/golang/go_workspace_empty/go.work b/test/providers/tst_manifests/golang/go_workspace_empty/go.work new file mode 100644 index 00000000..233b1008 --- /dev/null +++ b/test/providers/tst_manifests/golang/go_workspace_empty/go.work @@ -0,0 +1 @@ +go 1.22 diff --git a/test/providers/tst_manifests/golang/go_workspace_missing_module/existing/go.mod b/test/providers/tst_manifests/golang/go_workspace_missing_module/existing/go.mod new file mode 100644 index 00000000..7fe9faa2 --- /dev/null +++ b/test/providers/tst_manifests/golang/go_workspace_missing_module/existing/go.mod @@ -0,0 +1,3 @@ +module example.com/existing + +go 1.22 diff --git a/test/providers/tst_manifests/golang/go_workspace_missing_module/go.work b/test/providers/tst_manifests/golang/go_workspace_missing_module/go.work new file mode 100644 index 00000000..5eaa2341 --- /dev/null +++ b/test/providers/tst_manifests/golang/go_workspace_missing_module/go.work @@ -0,0 +1,6 @@ +go 1.22 + +use ( + ./existing + ./nonexistent +) diff --git a/test/providers/tst_manifests/golang/go_workspace_nested/go.work b/test/providers/tst_manifests/golang/go_workspace_nested/go.work new file mode 100644 index 00000000..50fc223c --- /dev/null +++ b/test/providers/tst_manifests/golang/go_workspace_nested/go.work @@ -0,0 +1,6 @@ +go 1.22 + +use ( + ./libs/core + ./libs/util +) diff --git a/test/providers/tst_manifests/golang/go_workspace_nested/libs/core/go.mod b/test/providers/tst_manifests/golang/go_workspace_nested/libs/core/go.mod new file mode 100644 index 00000000..2bf082f2 --- /dev/null +++ b/test/providers/tst_manifests/golang/go_workspace_nested/libs/core/go.mod @@ -0,0 +1,3 @@ +module example.com/libs/core + +go 1.22 diff --git a/test/providers/tst_manifests/golang/go_workspace_nested/libs/util/go.mod b/test/providers/tst_manifests/golang/go_workspace_nested/libs/util/go.mod new file mode 100644 index 00000000..3954c84d --- /dev/null +++ b/test/providers/tst_manifests/golang/go_workspace_nested/libs/util/go.mod @@ -0,0 +1,3 @@ +module example.com/libs/util + +go 1.22 diff --git a/test/providers/tst_manifests/golang/go_workspace_single/go.work b/test/providers/tst_manifests/golang/go_workspace_single/go.work new file mode 100644 index 00000000..241141be --- /dev/null +++ b/test/providers/tst_manifests/golang/go_workspace_single/go.work @@ -0,0 +1,3 @@ +go 1.22 + +use ./mymod diff --git a/test/providers/tst_manifests/golang/go_workspace_single/mymod/go.mod b/test/providers/tst_manifests/golang/go_workspace_single/mymod/go.mod new file mode 100644 index 00000000..f8019326 --- /dev/null +++ b/test/providers/tst_manifests/golang/go_workspace_single/mymod/go.mod @@ -0,0 +1,3 @@ +module example.com/mymod + +go 1.22 diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index a75b20e5..673111a6 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -4,6 +4,7 @@ import path from 'node:path' import { expect } from 'chai' import esmock from 'esmock' +import { discoverGoWorkspaceModules } from '../../src/providers/golang_gomodules.js' import { discoverGradleSubprojects } from '../../src/providers/java_gradle.js' import { discoverMavenModules } from '../../src/providers/java_maven.js' import { @@ -267,6 +268,7 @@ suite('discoverMavenModules', () => { expect(result).to.be.an('array') expect(result).to.have.lengthOf(0) }) + test('returns root pom only when mvn reports no modules', async () => { const root = path.resolve('test/providers/tst_manifests/maven/maven_no_modules') const result = await discoverMavenModules(root) @@ -320,3 +322,84 @@ suite('discoverMavenModules', () => { expect(result.some(p => p.includes('module-b'))).to.be.false }).timeout(40000) }) + +suite('discoverGoWorkspaceModules', () => { + test('returns empty when no go.work at root', async () => { + const result = await discoverGoWorkspaceModules('test/providers/tst_manifests/npm') + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(0) + }) + + test('discovers modules from go.work with two modules', async () => { + const root = path.resolve('test/providers/tst_manifests/golang/go_workspace') + const result = await discoverGoWorkspaceModules(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(2) + expect(result.every(p => p.endsWith('go.mod'))).to.be.true + expect(result.some(p => p.includes('module-a'))).to.be.true + expect(result.some(p => p.includes('module-b'))).to.be.true + }) + + test('discovers modules from nested directories', async () => { + const root = path.resolve('test/providers/tst_manifests/golang/go_workspace_nested') + const result = await discoverGoWorkspaceModules(root) + expect(result).to.have.lengthOf(2) + expect(result.some(p => p.includes(path.join('libs', 'core', 'go.mod')))).to.be.true + expect(result.some(p => p.includes(path.join('libs', 'util', 'go.mod')))).to.be.true + }) + + test('discovers single module', async () => { + const root = path.resolve('test/providers/tst_manifests/golang/go_workspace_single') + const result = await discoverGoWorkspaceModules(root) + expect(result).to.have.lengthOf(1) + expect(result[0]).to.include(path.join('mymod', 'go.mod')) + }) + + test('skips modules whose directory does not exist', async () => { + const root = path.resolve('test/providers/tst_manifests/golang/go_workspace_missing_module') + const result = await discoverGoWorkspaceModules(root) + expect(result).to.have.lengthOf(1) + expect(result[0]).to.include(path.join('existing', 'go.mod')) + }) + + test('returns empty when go command fails', async () => { + const root = path.resolve('test/providers/tst_manifests/golang/go_workspace') + const { discoverGoWorkspaceModules: discoverMocked } = await esmock('../../src/providers/golang_gomodules.js', { + '../../src/tools.js': { + getCustom: () => null, + getCustomPath: () => 'go', + invokeCommand: () => { throw new Error('go not found') }, + }, + }) + const result = await discoverMocked(root) + expect(result).to.have.lengthOf(0) + }) + + test('returns empty when go output is invalid JSON', async () => { + const root = path.resolve('test/providers/tst_manifests/golang/go_workspace') + const { discoverGoWorkspaceModules: discoverMocked } = await esmock('../../src/providers/golang_gomodules.js', { + '../../src/tools.js': { + getCustom: () => null, + getCustomPath: () => 'go', + invokeCommand: () => Buffer.from('not json'), + }, + }) + const result = await discoverMocked(root) + expect(result).to.have.lengthOf(0) + }) + + test('returns empty when Use is null (no use directives)', async () => { + const root = path.resolve('test/providers/tst_manifests/golang/go_workspace_empty') + const result = await discoverGoWorkspaceModules(root) + expect(result).to.have.lengthOf(0) + }) + + test('applies ignore patterns to discovered modules', async () => { + const root = path.resolve('test/providers/tst_manifests/golang/go_workspace_nested') + const result = await discoverGoWorkspaceModules(root, { + workspaceDiscoveryIgnore: ['**/util/**'], + }) + expect(result.some(p => p.includes(path.join('libs', 'core', 'go.mod')))).to.be.true + expect(result.some(p => p.includes(path.join('libs', 'util', 'go.mod')))).to.be.false + }) +})