Feat/support SBOM generation#291
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an SBOM generation workflow to sbomasm via a new generate sbom subcommand, building CycloneDX or SPDX SBOM outputs from distributed component manifest files plus artifact metadata.
Changes:
- Introduces
pkg/generate/gsbom(artifact config loader, recursive input discovery, JSON/CSV parsing, merge/dedup, tag filtering, dependency graph, serializers). - Adds CLI subcommands under
generate:configandsbom, with flags for inputs, recursion, tags, output, and format. - Implements CycloneDX + SPDX JSON serialization for the generated BOM model.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/generate/gsbom/serializer_spdx.go | SPDX document + package/relationship construction and JSON output |
| pkg/generate/gsbom/serializer_cdx.go | CycloneDX BOM construction (metadata/components/dependencies) and JSON output |
| pkg/generate/gsbom/parser.go | Component manifest parsing for JSON + CSV with schema marker validation |
| pkg/generate/gsbom/merge.go | Merge + dedup helpers; name@version keying |
| pkg/generate/gsbom/input_collector.go | Collects explicit inputs and optionally discovers manifests via recursion |
| pkg/generate/gsbom/gsbom.go | Orchestrates end-to-end SBOM generation pipeline |
| pkg/generate/gsbom/filter.go | Tag include/exclude filtering for components |
| pkg/generate/gsbom/dependency.go | Dependency graph construction from dependency-of references |
| pkg/generate/gsbom/config.go | Loads .artifact-metadata.yaml into an internal artifact model |
| pkg/generate/gsbom/builder.go | Assembles final BOM model (artifact + components + dependency edges) |
| cmd/generate.go | Converts generate into a parent command with subcommands |
| cmd/generate_sbom.go | Implements sbomasm generate sbom command + flags |
| cmd/generate_config.go | Implements sbomasm generate config command |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for i, col := range columns { | ||
| colIndex[col] = i | ||
| } | ||
|
|
There was a problem hiding this comment.
parseCSV builds colIndex from the CSV header but never validates required columns are present. If the CSV is missing/typo’d (e.g., "name"/"version"), lookups like colIndex["name"] will default to 0 and can silently map the wrong column or later cause index-out-of-range panics. Add explicit checks that required headers exist before reading records and return a clear error listing missing columns.
| requiredColumns := []string{ | |
| "name", | |
| "version", | |
| "type", | |
| "license", | |
| "purl", | |
| "cpe", | |
| "dependency_of", | |
| "tags", | |
| } | |
| var missingColumns []string | |
| for _, col := range requiredColumns { | |
| if _, ok := colIndex[col]; !ok { | |
| missingColumns = append(missingColumns, col) | |
| } | |
| } | |
| if len(missingColumns) > 0 { | |
| return nil, fmt.Errorf("missing required columns: %s", strings.Join(missingColumns, ", ")) | |
| } |
| c := Component{ | ||
| Name: record[colIndex["name"]], | ||
| Version: record[colIndex["version"]], | ||
| Type: record[colIndex["type"]], | ||
| License: record[colIndex["license"]], | ||
| PURL: record[colIndex["purl"]], | ||
| CPE: record[colIndex["cpe"]], | ||
| } |
There was a problem hiding this comment.
parseCSV indexes into record[...] assuming each row has all expected columns. A short/malformed row will panic with "index out of range". Consider validating len(record) against the max required column index (or using a helper to safely fetch fields) and returning a parsing error with row context instead of panicking.
| // dependency_of | ||
| if v := record[colIndex["dependency_of"]]; v != "" { | ||
| c.DependencyOf = strings.Split(v, ",") | ||
| } | ||
|
|
||
| // tags | ||
| if v := record[colIndex["tags"]]; v != "" { | ||
| c.Tags = strings.Split(v, ",") | ||
| } |
There was a problem hiding this comment.
When splitting CSV fields like dependency_of and tags on commas, values aren’t TrimSpace’d. This makes common inputs like "libtls@3.9.0, libfoo@1.2.3" fail dependency resolution / tag matching due to leading spaces. Trim whitespace for each entry and drop empty strings after splitting.
| if doc.Schema != "interlynk/component-manifest/v1" { | ||
| return nil, fmt.Errorf("invalid schema") | ||
| } |
There was a problem hiding this comment.
The schema validation error "invalid schema" doesn’t include the schema value that was found or the expected value, which makes debugging harder. Include both (and ideally the file path) in the returned error message.
| for _, c := range components { | ||
| childKey := componentKey(c) | ||
|
|
||
| // Case 1: has dependency-of | ||
| if len(c.DependencyOf) > 0 { | ||
| for _, parentRef := range c.DependencyOf { | ||
|
|
||
| // Check if parent exists | ||
| if _, ok := compMap[parentRef]; !ok { | ||
| warnings = append(warnings, | ||
| fmt.Errorf("missing dependency reference: %s → %s", childKey, parentRef)) | ||
| continue | ||
| } | ||
|
|
||
| // parent -> child | ||
| graph.Edges[parentRef] = append(graph.Edges[parentRef], childKey) | ||
| } | ||
| } else { | ||
| // Case 2: top-level (handled later in SBOM builder) | ||
| continue | ||
| } |
There was a problem hiding this comment.
If a component has DependencyOf entries but all of them are missing from compMap, this function only emits warnings and the component becomes disconnected: it won’t be treated as top-level (len(DependencyOf)!=0) and no edge is added. Consider either (a) treating components with no valid parents as top-level, or (b) returning an error so the generated SBOM doesn’t silently omit dependency relationships for that component.
| // 1. Add explicit inputs FIRST | ||
| files = append(files, params.InputFiles...) | ||
|
|
||
| // 2. Handle recurse | ||
| if params.RecursePath != "" { | ||
| err := filepath.Walk(params.RecursePath, func(path string, info os.FileInfo, err error) error { | ||
| if err != nil { | ||
| warnings = append(warnings, fmt.Errorf("error accessing path %s: %v", path, err)) | ||
| return nil | ||
| } | ||
|
|
||
| if info.IsDir() { | ||
| return nil | ||
| } | ||
|
|
||
| // Match filename | ||
| if info.Name() == params.Filename { | ||
| files = append(files, path) | ||
| } |
There was a problem hiding this comment.
CollectInputFiles can return duplicate paths when the same file is provided via --input and also discovered via --recurse (or if inputs repeat). That leads to redundant parsing and can inflate duplicate-component warnings. Consider de-duplicating file paths (e.g., by absolute/cleaned path) before returning.
| "fmt" | ||
| "os" | ||
|
|
||
| "go.yaml.in/yaml/v2" |
There was a problem hiding this comment.
This file imports go.yaml.in/yaml/v2 while the rest of the repo uses gopkg.in/yaml.v2 (e.g., pkg/assemble/config.go:33). Using a different YAML module increases dependency surface and can cause subtle behavior differences. Prefer the same YAML package already used elsewhere unless there’s a specific need.
| "go.yaml.in/yaml/v2" | |
| "gopkg.in/yaml.v2" |
| generateSbomCmd.Flags().StringP("config", "c", ".artifact-metadata.yaml", "artifact metadata config file") | ||
| generateSbomCmd.Flags().StringSliceP("input", "i", []string{}, "component input files") | ||
| generateSbomCmd.Flags().StringP("output", "o", "", "output SBOM file (default stdout)") | ||
| generateSbomCmd.Flags().StringSliceP("tags", "t", []string{}, "include components with these tags") | ||
| generateSbomCmd.Flags().StringSlice("exclude-tags", []string{}, "exclude components with these tags") | ||
| generateSbomCmd.Flags().String("format", "cyclonedx(default)", "output format (cyclonedx|spdx)") | ||
| generateSbomCmd.Flags().StringP("recurse", "r", "", "recursively discover component files") | ||
| generateSbomCmd.Flags().String("filename", ".components.json", "filename for recursive discovery") |
There was a problem hiding this comment.
The --format flag default is "cyclonedx(default)", but the help text advertises accepted values "cyclonedx|spdx". This makes the default not match the documented set and can confuse users/scripts. Prefer defaulting to "cyclonedx" and (optionally) validate the flag value to error on unknown formats instead of silently falling back.
| var generateCmd = &cobra.Command{ | ||
| Use: "generate", | ||
| Short: "Generate a sample config file for assembling sboms", | ||
| Long: `The generate command will generate a sample config file for assembling sboms. | ||
| Example: | ||
| $ sbomasm generate > config.yaml | ||
|
|
||
| Please fill in all the fields that are known. Unknown fields can be left blank.`, | ||
| Args: cobra.NoArgs, | ||
| SilenceUsage: true, | ||
| Run: func(cmd *cobra.Command, args []string) { | ||
| fmt.Printf("%s", assemble.DefaultConfigYaml()) | ||
| }, | ||
| $ sbomasm generate config | ||
| $ sbomasm generate sbom -r . -o device-firmware-2.1.0.cdx.json | ||
| $ sbomasm generate sbom \ | ||
| -i .components.json \ | ||
| -i libs/libmqtt/.components.json \ | ||
| -i src/cjson/.components.json \ | ||
| -i src/miniz/.components.json \ | ||
| -o device-firmware-2.1.0.cdx.json | ||
| `, |
There was a problem hiding this comment.
generateCmd’s Short/Long text still describes only generating a sample assemble config, but the command now has subcommands (config, sbom). Update the help text to reflect the new structure and avoid misleading CLI users.
| var generateConfigCmd = &cobra.Command{ | ||
| Use: "config", | ||
| Short: "Generate artifact metadata config", | ||
| Run: func(cmd *cobra.Command, args []string) { | ||
| fmt.Printf("%s", assemble.DefaultConfigYaml()) | ||
| }, |
There was a problem hiding this comment.
generate config is described as generating artifact metadata, but it prints assemble.DefaultConfigYaml(), which includes unrelated sections (output/assemble) not used by gsbom’s .artifact-metadata.yaml parser. Consider generating a minimal artifact-metadata template (app section only) to match the command’s intent and the PR description.
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
Signed-off-by: Vivek Kumar Sahu <vivekkumarsahu650@gmail.com>
closes #289
This PR adds the following changes:
sbomsub-command of generatesbomasm generate sbomdependency-ofresolution and dependency graph constructiondependency-ofbydepends-onand acoordingly changed it's functionalitysbomasm generate configcommandsbomasm generate componentscommandstrictmodeTODO:
Demo as per current implmentation:
$ ./sbomasm generate sbom \ -i .components.json \ -i libs/libmqtt/.components.json \ -i src/cjson/.components.json \ -i src/miniz/.components.json \ -o device-firmware-2.1.0.spdx.json --format spdxo/p of
device-firmware-2.1.0.spdx.json:{ "spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", "name": "device-firmware", "documentNamespace": "https://spdx.org/spdxdocs/device-firmware-b7da2ce2-3af5-4a24-a06c-dd0a6033a129", "creationInfo": { "creators": [ "Tool: sbomasm-v2.0.0-20260413160043-47ab5f6b3c99+dirty" ], "created": "2026-04-13T16:13:23Z" }, "packages": [ { "name": "device-firmware", "SPDXID": "SPDXRef-device-firmware-2.1.0", "versionInfo": "2.1.0", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "MIT", "copyrightText": "Copyright 2026 Acme Corp", "description": "Main firmware for Acme IoT gateway", "externalRefs": [ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": "pkg:generic/acme/device-firmware@2.1.0" } ] }, { "name": "libtls", "SPDXID": "SPDXRef-libtls-3.9.0", "versionInfo": "3.9.0", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "checksums": [ { "algorithm": "SHA-256", "checksumValue": "e3b0c44..." } ], "licenseConcluded": "ISC", "externalRefs": [ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": "pkg:generic/openbsd/libtls@3.9.0" } ] }, { "name": "libgui", "SPDXID": "SPDXRef-libgui-2.0.0", "versionInfo": "2.0.0", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "checksums": [ { "algorithm": "SHA-256", "checksumValue": "abc123..." } ], "licenseConcluded": "MIT", "externalRefs": [ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": "pkg:generic/lvgl/libgui@2.0.0" } ] }, { "name": "libmqtt", "SPDXID": "SPDXRef-libmqtt-4.3.0", "versionInfo": "4.3.0", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "checksums": [ { "algorithm": "SHA-256", "checksumValue": "9f86d08..." } ], "licenseConcluded": "EPL-2.0", "externalRefs": [ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": "pkg:generic/acme/libmqtt@4.3.0" } ] }, { "name": "cjson", "SPDXID": "SPDXRef-cjson-1.7.17", "versionInfo": "1.7.17", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "checksums": [ { "algorithm": "SHA-256", "checksumValue": "d2735c2..." } ], "licenseConcluded": "MIT", "externalRefs": [ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": "pkg:github/DaveGamble/cJSON@1.7.17" } ] }, { "name": "miniz", "SPDXID": "SPDXRef-miniz-3.0.2", "versionInfo": "3.0.2", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "checksums": [ { "algorithm": "SHA-256", "checksumValue": "f81bc5a..." } ], "licenseConcluded": "MIT", "externalRefs": [ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": "pkg:github/richgel999/miniz@3.0.2" } ] } ], "relationships": [ { "spdxElementId": "SPDXRef-DOCUMENT", "relatedSpdxElement": "SPDXRef-device-firmware-2.1.0", "relationshipType": "DESCRIBES" }, { "spdxElementId": "SPDXRef-device-firmware-2.1.0", "relatedSpdxElement": "SPDXRef-libgui-2.0.0", "relationshipType": "DEPENDS_ON" }, { "spdxElementId": "SPDXRef-device-firmware-2.1.0", "relatedSpdxElement": "SPDXRef-libmqtt-4.3.0", "relationshipType": "DEPENDS_ON" }, { "spdxElementId": "SPDXRef-device-firmware-2.1.0", "relatedSpdxElement": "SPDXRef-cjson-1.7.17", "relationshipType": "DEPENDS_ON" }, { "spdxElementId": "SPDXRef-device-firmware-2.1.0", "relatedSpdxElement": "SPDXRef-miniz-3.0.2", "relationshipType": "DEPENDS_ON" }, { "spdxElementId": "SPDXRef-libmqtt-4.3.0", "relatedSpdxElement": "SPDXRef-libtls-3.9.0", "relationshipType": "DEPENDS_ON" } ] }and for cyclonedx:
$ ./sbomasm generate sbom \ -i .components.json \ -i libs/libmqtt/.components.json \ -i src/cjson/.components.json \ -i src/miniz/.components.json \ -o device-firmware-2.1.0.cdx.jsonANd o/p of
device-firmware-2.1.0.cdx.json:{ "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", "bomFormat": "CycloneDX", "specVersion": "1.6", "serialNumber": "urn:uuid:17fa1318-7393-4de1-bffc-68daa69f3ea3", "version": 1, "metadata": { "timestamp": "2026-04-13T16:13:28Z", "tools": { "components": [ { "type": "application", "supplier": { "name": "Interlynk", "url": [ "https://interlynk.io" ], "contact": [ { "email": "support@interlynk.io" } ] }, "name": "sbomasm", "version": "v2.0.0-20260413160043-47ab5f6b3c99+dirty", "description": "sbomasm: The Complete SBOM Management Toolkit", "licenses": [ { "license": { "id": "Apache-2.0" } } ] } ] }, "component": { "bom-ref": "pkg:generic/acme/device-firmware@2.1.0", "type": "firmware", "supplier": { "name": "Acme Corp", "contact": [ { "email": "engineering@acme.com" } ] }, "authors": [ { "name": "Jane Doe", "email": "jane@acme.com" } ], "name": "device-firmware", "version": "2.1.0", "description": "Main firmware for Acme IoT gateway", "licenses": [ { "license": { "id": "MIT" } } ], "copyright": "Copyright 2026 Acme Corp", "cpe": "cpe:2.3:a:acme:device-firmware:2.1.0:*:*:*:*:*:*:*", "purl": "pkg:generic/acme/device-firmware@2.1.0" } }, "components": [ { "bom-ref": "pkg:generic/openbsd/libtls@3.9.0", "type": "library", "supplier": { "name": "OpenBSD" }, "name": "libtls", "version": "3.9.0", "hashes": [ { "alg": "SHA-256", "content": "e3b0c44..." } ], "licenses": [ { "license": { "id": "ISC" } } ], "purl": "pkg:generic/openbsd/libtls@3.9.0" }, { "bom-ref": "pkg:generic/lvgl/libgui@2.0.0", "type": "library", "supplier": { "name": "LVGL" }, "name": "libgui", "version": "2.0.0", "hashes": [ { "alg": "SHA-256", "content": "abc123..." } ], "licenses": [ { "license": { "id": "MIT" } } ], "purl": "pkg:generic/lvgl/libgui@2.0.0" }, { "bom-ref": "pkg:generic/acme/libmqtt@4.3.0", "type": "library", "supplier": { "name": "Acme Corp" }, "name": "libmqtt", "version": "4.3.0", "hashes": [ { "alg": "SHA-256", "content": "9f86d08..." } ], "licenses": [ { "license": { "id": "EPL-2.0" } } ], "purl": "pkg:generic/acme/libmqtt@4.3.0" }, { "bom-ref": "pkg:github/DaveGamble/cJSON@1.7.17", "type": "library", "supplier": { "name": "Dave Gamble" }, "name": "cjson", "version": "1.7.17", "hashes": [ { "alg": "SHA-256", "content": "d2735c2..." } ], "licenses": [ { "license": { "id": "MIT" } } ], "purl": "pkg:github/DaveGamble/cJSON@1.7.17" }, { "bom-ref": "pkg:github/richgel999/miniz@3.0.2", "type": "library", "supplier": { "name": "Rich Geldreich" }, "name": "miniz", "version": "3.0.2", "hashes": [ { "alg": "SHA-256", "content": "f81bc5a..." } ], "licenses": [ { "license": { "id": "MIT" } } ], "purl": "pkg:github/richgel999/miniz@3.0.2" } ], "dependencies": [ { "ref": "pkg:generic/acme/libmqtt@4.3.0", "dependsOn": [ "pkg:generic/openbsd/libtls@3.9.0" ] }, { "ref": "pkg:generic/acme/device-firmware@2.1.0", "dependsOn": [ "pkg:generic/lvgl/libgui@2.0.0", "pkg:generic/acme/libmqtt@4.3.0", "pkg:github/DaveGamble/cJSON@1.7.17", "pkg:github/richgel999/miniz@3.0.2" ] } ] }