diff --git a/lib/cli/index.js b/lib/cli/index.js index b66316e0aa..eec74e7aa0 100644 --- a/lib/cli/index.js +++ b/lib/cli/index.js @@ -4280,11 +4280,21 @@ export async function createGoBom(path, options) { } if (gomodFiles.length) { let shouldManuallyParse = false; + // Sort go.mod files by depth (shallowest first) to prioritize root modules + const sortedGomodFiles = gomodFiles.sort((a, b) => { + const relativePathA = relative(path, a); + const relativePathB = relative(path, b); + const depthA = relativePathA.split("/").length; + const depthB = relativePathB.split("/").length; + return depthA - depthB; + }); + + let rootParentComponent = null; // Use the go list -deps and go mod why commands to generate a good quality BOM for non-docker invocations if ( !hasAnyProjectType(["docker", "oci", "container", "os"], options, false) ) { - for (const f of gomodFiles) { + for (const f of sortedGomodFiles) { const basePath = dirname(f); // Ignore vendor packages if ( @@ -4334,13 +4344,23 @@ export async function createGoBom(path, options) { if (retMap.pkgList?.length) { pkgList = pkgList.concat(retMap.pkgList); } - // We treat the main module as our parent + // Prioritize the shallowest module as the root component if ( retMap.parentComponent && Object.keys(retMap.parentComponent).length ) { - parentComponent = retMap.parentComponent; - parentComponent.type = "application"; + if (!rootParentComponent) { + // First (shallowest) module becomes the root + rootParentComponent = retMap.parentComponent; + rootParentComponent.type = "application"; + parentComponent = rootParentComponent; + } else { + // Subsequent modules become subcomponents + if (!parentComponent.components) { + parentComponent.components = []; + } + parentComponent.components.push(retMap.parentComponent); + } } if (DEBUG_MODE) { console.log("Executing go mod graph in", basePath); @@ -4426,11 +4446,14 @@ export async function createGoBom(path, options) { parentComponent, ); } - // Retain the parent component hierarchy + // Retain the parent component hierarchy, prioritizing the shallowest module if (Object.keys(retMap.parentComponent).length) { - if (gomodFiles.length === 1) { - parentComponent = retMap.parentComponent; + if (!rootParentComponent) { + // First (shallowest) module becomes the root + rootParentComponent = retMap.parentComponent; + parentComponent = rootParentComponent; } else { + // Subsequent modules become subcomponents parentComponent.components = parentComponent.components || []; parentComponent.components.push(retMap.parentComponent); } @@ -4456,7 +4479,7 @@ export async function createGoBom(path, options) { dependencies, parentComponent, src: path, - filename: gomodFiles.join(", "), + filename: sortedGomodFiles.join(", "), }); } } @@ -4468,7 +4491,7 @@ export async function createGoBom(path, options) { "Manually parsing go.mod files. The resultant BOM would be incomplete.", ); } - for (const f of gomodFiles) { + for (const f of sortedGomodFiles) { if (DEBUG_MODE) { console.log(`Parsing ${f}`); } @@ -4477,11 +4500,14 @@ export async function createGoBom(path, options) { if (retMap?.pkgList?.length) { pkgList = pkgList.concat(retMap.pkgList); } - // Retain the parent component hierarchy + // Retain the parent component hierarchy, prioritizing the shallowest module if (Object.keys(retMap.parentComponent).length) { - if (gomodFiles.length === 1) { - parentComponent = retMap.parentComponent; + if (!rootParentComponent) { + // First (shallowest) module becomes the root + rootParentComponent = retMap.parentComponent; + parentComponent = rootParentComponent; } else { + // Subsequent modules become subcomponents parentComponent.components = parentComponent.components || []; parentComponent.components.push(retMap.parentComponent); } @@ -4506,7 +4532,7 @@ export async function createGoBom(path, options) { src: path, dependencies, parentComponent, - filename: gomodFiles.join(", "), + filename: sortedGomodFiles.join(", "), }); } if (gopkgLockFiles.length) { diff --git a/lib/helpers/utils.test.js b/lib/helpers/utils.test.js index 2f763bbedc..8f9cee8d09 100644 --- a/lib/helpers/utils.test.js +++ b/lib/helpers/utils.test.js @@ -1635,6 +1635,64 @@ test("parse go mod why dependencies", () => { expect(pkg_name).toBeUndefined(); }); +test("multimodule go.mod file ordering", async () => { + // Test that simulates the file ordering logic from createGoBom + const mockPath = "/workspace/project"; + const mockGomodFiles = [ + "/workspace/project/deep/nested/go.mod", + "/workspace/project/go.mod", + "/workspace/project/submodule/go.mod", + ]; + + // Sort files by depth (shallowest first) - this is the fix we implemented + const sortedFiles = mockGomodFiles.sort((a, b) => { + const relativePathA = a.replace(`${mockPath}/`, ""); + const relativePathB = b.replace(`${mockPath}/`, ""); + const depthA = relativePathA.split("/").length; + const depthB = relativePathB.split("/").length; + return depthA - depthB; + }); + + // The root go.mod should be first (shallowest) + expect(sortedFiles[0]).toEqual("/workspace/project/go.mod"); + expect(sortedFiles[1]).toEqual("/workspace/project/submodule/go.mod"); + expect(sortedFiles[2]).toEqual("/workspace/project/deep/nested/go.mod"); +}); + +test("parseGoModData for multiple modules with root priority", async () => { + // Test parsing multiple go.mod files to ensure proper component hierarchy + const rootModData = readFileSync("./test/data/multimodule-root.mod", { + encoding: "utf-8", + }); + const subModData = readFileSync("./test/data/multimodule-sub.mod", { + encoding: "utf-8", + }); + const deepModData = readFileSync("./test/data/multimodule-deep.mod", { + encoding: "utf-8", + }); + + const rootResult = await parseGoModData(rootModData, {}); + const subResult = await parseGoModData(subModData, {}); + const deepResult = await parseGoModData(deepModData, {}); + + // Root module should be identified correctly + expect(rootResult.parentComponent.name).toEqual( + "github.com/example/root-project", + ); + expect(rootResult.parentComponent.type).toEqual("application"); + + // Sub modules should also be parsed correctly + expect(subResult.parentComponent.name).toEqual( + "github.com/example/root-project/submodule", + ); + expect(deepResult.parentComponent.name).toEqual( + "github.com/example/root-project/deep/nested", + ); + + // In the fixed logic, the root should take priority over sub-modules + // This test verifies the parsing works correctly for each individual module +}, 10000); + test("parseGopkgData", async () => { let dep_list = await parseGopkgData(null); expect(dep_list).toEqual([]); diff --git a/test/data/multimodule-deep.mod b/test/data/multimodule-deep.mod new file mode 100644 index 0000000000..c91880afa5 --- /dev/null +++ b/test/data/multimodule-deep.mod @@ -0,0 +1,7 @@ +module github.com/example/root-project/deep/nested + +go 1.21 + +require ( + github.com/stretchr/testify v1.8.0 +) diff --git a/test/data/multimodule-root.mod b/test/data/multimodule-root.mod new file mode 100644 index 0000000000..2e9a027193 --- /dev/null +++ b/test/data/multimodule-root.mod @@ -0,0 +1,10 @@ +module github.com/example/root-project + +go 1.21 + +require ( + github.com/gorilla/mux v1.8.0 + github.com/example/root-project/submodule v0.0.0 +) + +replace github.com/example/root-project/submodule => ./submodule diff --git a/test/data/multimodule-sub.mod b/test/data/multimodule-sub.mod new file mode 100644 index 0000000000..4de435b6e0 --- /dev/null +++ b/test/data/multimodule-sub.mod @@ -0,0 +1,7 @@ +module github.com/example/root-project/submodule + +go 1.21 + +require ( + github.com/pkg/errors v0.9.1 +)