Extend forgeseal's lockfile parsing and SBOM generation from JS/TS + Python to Go, Rust, and Java/Gradle. The existing architecture — Parser interface, LockfileResult model, BuildPURL() dispatch, ecosystemFromLockfileType(), and mapComponent() — is designed for this. No new go.mod dependencies are required.
- Go: Parse
go.mod(the lockfile in Go's MVS model) +go.sum(integrity hashes) - Rust: Parse
Cargo.lock(TOML, already havepelletier/go-toml/v2) - Java/Gradle: Parse
gradle.lockfile(line-oriented text) - PURL construction for
pkg:golang/,pkg:cargo/,pkg:maven/types - Ecosystem detection and priority ordering in the parser registry
- CycloneDX component mapping with correct external references
- VEX triage via OSV.dev (already ecosystem-agnostic via PURL)
- Snapshot tests for each new ecosystem
- Update
Detect()error message and README
- Maven
pom.xmlparsing (deferred — requires XML property interpolation, parent POM inheritance, transitive resolution; a separate phase) - Invoking
go,cargo, orgradleCLIs (lockfile-only, no build tool dependency) - Workspace/multi-module resolution beyond what the lockfile provides
- New
go.moddependencies
Add to internal/lockfile/model.go:
TypeGoMod LockfileType = "gomod"File: GoModParser implementing Parser interface.
Detection: Filenames go.mod (the parser reads both go.mod and go.sum from the same directory).
Parsing go.mod:
- Line-oriented format with directives:
module,go,require,replace,exclude,retract requireblock (parenthesized or single-line): extractmodule@versionpairs- Track
// indirectcomment to set a metadata flag (notDev, but could be stored as a property — for now, all Go deps are non-dev since Go has no dev dependency concept) - Resolve
replacedirectives: ifreplace old => new, the SBOM should use the replacement module path and version. Both single-line and block forms. - Skip
excludeandretractdirectives - Handle major version suffixes (
/v2,/v3) — these are part of the module path and must appear in PURLs
Parsing go.sum (for integrity hashes):
- Located in same directory as
go.mod - Format:
<module> <version>[/go.mod] <hash>per line - Each module has two entries: one for the module zip (
v1.2.3) and one for thego.modfile (v1.2.3/go.mod) - Use the module zip hash (without
/go.modsuffix) asPackage.Integrity - Hash format is
h1:<base64>(SHA-256 of the module zip); store as-is
Edge cases:
replacewith local path (replace foo => ../local) — skip the replacement (no version available), keep originalreplacewith no version on old side (replace foo => bar v1.2.3) — apply to all versions- Pseudo-versions like
v0.0.0-20240101000000-abcdef123456— parse as normal versions
Add BuildGolangPURL(name, version string) string to internal/sbom/purl.go:
pkg:golang/<module-path>@<version>
- The full module path (e.g.,
github.com/gorilla/mux) becomes the PURL name - Namespace and name are split on the last
/segment: namespace=github.com/gorilla, name=mux - Actually, per the purl spec for golang: the
typeisgolang, thenamespaceis everything except the last path segment,nameis the last segment - Version includes the
vprefix as-is (e.g.,v1.8.0) - Module paths must be lowercased per purl spec (Go module paths are case-sensitive but purl requires lowercase)
Examples:
| Module | PURL |
|---|---|
github.com/gorilla/mux v1.8.0 |
pkg:golang/github.com/gorilla/mux@v1.8.0 |
golang.org/x/sys v0.29.0 |
pkg:golang/golang.org/x/sys@v0.29.0 |
github.com/foo/bar/v2 v2.1.0 |
pkg:golang/github.com/foo/bar/v2@v2.1.0 |
In mapComponent(), for ecosystem "golang":
- External reference URL:
https://pkg.go.dev/<module-path> - Hashes:
h1:prefix maps to SHA-256 (cdx.HashAlgoSHA256)
Add readGoModInfo(dir string) *ProjectInfo to internal/sbom/generator.go:
- Parse
moduledirective fromgo.modfor the project name - Go has no project version in
go.mod— use"0.0.0"as default
Create testdata/lockfiles/gomod/:
go.mod: Module with ~10 dependencies including scoped/indirect/replace directivesgo.sum: Matching integrity hashes- Include a
replacedirective to test resolution - Include a
/v2major version suffix module
Snapshot: testdata/snapshots/gomod.sbom.json
Add to internal/lockfile/model.go:
TypeCargoLock LockfileType = "cargo"File: CargoParser implementing Parser interface.
Detection: Filenames Cargo.lock.
Parsing Cargo.lock:
- TOML file with a flat
[[package]]array - Each package has:
name(string),version(string),source(optional string),checksum(optional string),dependencies(optional array of strings) - Use
pelletier/go-toml/v2(already ingo.mod) for parsing - Use map-based decoding (
map[string]any) to tolerate unknown/future fields, same pattern asuv.goandpdm.go
Package extraction:
- Skip the first
[[package]]entry if itsnamematches the workspace root (has nosourcefield — registry packages always havesource = "registry+https://github.com/rust-lang/crates.io-index") checksumfield is a bare SHA-256 hex string — store asPackage.Integritywithsha256:prefix for consistencydependenciesare strings in format"name"or"name version"or"name version (source)"— extract name (and optionally version) forDependencyRef- Cargo has no dev dependency concept in
Cargo.lock— all resolved deps are included regardless of[dev-dependencies]inCargo.toml. SetDev = falsefor all.
Edge cases:
- Workspace projects: single
Cargo.lockat workspace root, multiple[[package]]entries withoutsource(all are workspace members) — skip all packages without asourcefield - Multiple versions of the same crate: each gets its own
[[package]]entry with a different version — include all sourcefield may be a git URL (git+https://...#commit) or a path — only include packages with registry source or no source filtering (include all, let PURL handle it)
Add BuildCargoPURL(name, version string) string to internal/sbom/purl.go:
pkg:cargo/<name>@<version>
- No namespace (flat package namespace)
- Name used as-is (crate names are lowercase by convention, but preserve original case)
Examples:
| Crate | PURL |
|---|---|
serde 1.0.124 |
pkg:cargo/serde@1.0.124 |
tokio 1.36.0 |
pkg:cargo/tokio@1.36.0 |
In mapComponent(), for ecosystem "cargo":
- External reference URL:
https://crates.io/crates/<name> - Hashes:
sha256:hex string maps tocdx.HashAlgoSHA256
Create testdata/lockfiles/cargo/:
Cargo.lock: ~10 packages including a workspace root (no source), multiple versions of a crate, and git-sourced dependency- Include version 3 format (
version = 3at top)
Snapshot: testdata/snapshots/cargo.sbom.json
Add to internal/lockfile/model.go:
TypeGradleLock LockfileType = "gradle"File: GradleParser implementing Parser interface.
Detection: Filenames gradle.lockfile.
Parsing gradle.lockfile:
- Plain text, one dependency per line
- Format:
<group>:<artifact>:<version>=<configurations> - Lines starting with
#are comments (header comment# This is a Gradle generated file) - Lines containing
empty=at the end mark empty configurations — skip these - The
=<configurations>suffix lists which Gradle configurations include this dep (e.g.,compileClasspath,runtimeClasspath) - Extract
group,artifact,versionfrom the<group>:<artifact>:<version>portion
Dev detection:
- If configurations list contains ONLY test-related configs (
testCompileClasspath,testRuntimeClasspath,testImplementation, etc.) and no production configs (compileClasspath,runtimeClasspath,implementation, etc.), mark asDev = true - Production configs: any config NOT starting with
test(case-insensitive)
Edge cases:
gradle.lockfileis opt-in (dependencyLockingmust be enabled inbuild.gradle) — detection will simply not find the file if not present- Multi-project builds may have per-project lockfiles — for now, only detect
gradle.lockfilein the scanned directory root - Lines may have platform-specific configurations like
compileClasspath,runtimeClasspath— treat the whole CSV as the configurations list
Add BuildMavenPURL(group, artifact, version string) string to internal/sbom/purl.go:
pkg:maven/<groupId>/<artifactId>@<version>
groupIdbecomes the namespaceartifactIdbecomes the name
Examples:
| Dependency | PURL |
|---|---|
org.apache.logging.log4j:log4j-core:2.14.1 |
pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1 |
com.google.guava:guava:32.1.3-jre |
pkg:maven/com.google.guava/guava@32.1.3-jre |
Note: The BuildPURL() dispatcher needs a new path since Gradle packages are identified by group:artifact:version (3-part), not just name:version. The Package.Name field will store group:artifact (colon-separated) for Gradle, and BuildPURL() will split on : for the maven ecosystem.
In mapComponent(), for ecosystem "maven":
- External reference URL:
https://central.sonatype.com/artifact/<group>/<artifact> - Component name: use
artifactportion only for display, withgroupas a property or in the PURL namespace - No integrity hashes available from
gradle.lockfile(Gradle lockfiles don't include checksums)
Create testdata/lockfiles/gradle/:
gradle.lockfile: ~10 dependencies including test-only deps, various configurations
Snapshot: testdata/snapshots/gradle.sbom.json
In internal/lockfile/parser.go, add to registry slice in init():
// Go (after Python, before end)
&GoModParser{},
// Rust
&CargoParser{},
// Java/Gradle
&GradleParser{},Detection priority (updated):
bun.lockb > bun.lock > pnpm-lock.yaml > yarn.lock > package-lock.json >
uv.lock > poetry.lock > pdm.lock > requirements.txt >
go.mod > Cargo.lock > gradle.lockfile
JS/TS > Python > Go > Rust > Java. In practice, a project will only have one ecosystem's lockfiles.
Update ecosystemFromLockfileType() in internal/sbom/generator.go:
case lockfile.TypeGoMod:
return "golang"
case lockfile.TypeCargoLock:
return "cargo"
case lockfile.TypeGradleLock:
return "maven"Update BuildPURL() in internal/sbom/purl.go:
func BuildPURL(name, version, ecosystem string) string {
switch ecosystem {
case "pypi":
return BuildPyPIPURL(name, version)
case "golang":
return BuildGolangPURL(name, version)
case "cargo":
return BuildCargoPURL(name, version)
case "maven":
return BuildMavenPURL(name, version) // name is "group:artifact"
default:
return BuildNPMPURL(name, version)
}
}Update mapComponent() in internal/sbom/cyclonedx.go to handle new ecosystems:
- golang: external ref →
https://pkg.go.dev/..., hash parsing forh1:format - cargo: external ref →
https://crates.io/crates/..., hash parsing forsha256:hex - maven: external ref →
https://central.sonatype.com/artifact/..., parsegroup:artifactfor display name
Update Generate() in internal/sbom/generator.go to try:
pyproject.toml(Python)package.json(JS/TS)go.mod(Go — extractmoduledirective)Cargo.toml(Rust — extract[package].nameand[package].version)settings.gradle/build.gradle(Java — extractrootProject.name)
vex.osv_ecosystemin.forgeseal.yaml.example: document that this is auto-detected from lockfile type and rarely needs manual setting- OSV.dev queries already use PURLs, so no code changes needed for VEX triage — it's automatically ecosystem-agnostic
Update the Detect() error message to include new lockfile names:
no lockfile found in %s; supported: bun.lockb, bun.lock, pnpm-lock.yaml, yarn.lock,
package-lock.json, uv.lock, poetry.lock, pdm.lock, requirements.txt, go.mod,
Cargo.lock, gradle.lockfile
- Update
rootCmd.Longto mention Go, Rust, and Java - Update
action.ymldescription - Update
.goreleaser.yamlbrew description - Update
README.md: add Go/Rust/Gradle to supported lockfiles table, add examples, update architecture section
| Test File | What It Tests |
|---|---|
internal/lockfile/gomod_test.go |
go.mod + go.sum parsing, replace resolution, indirect detection, major version suffixes |
internal/lockfile/cargo_test.go |
Cargo.lock parsing, workspace root skipping, multi-version crates, dependency string parsing |
internal/lockfile/gradle_test.go |
gradle.lockfile parsing, dev detection via configurations, comment skipping |
internal/sbom/purl_test.go |
Add cases for pkg:golang/, pkg:cargo/, pkg:maven/ PURLs |
internal/sbom/generator_test.go |
Add TestGeneratorGenerateGo, TestGeneratorGenerateRust, TestGeneratorGenerateGradle |
internal/sbom/snapshot_test.go |
Snapshot tests auto-discover new testdata/lockfiles/ directories |
- Zero new
go.moddependencies — Go modules uses line-oriented text (no library needed), Cargo.lock uses TOML (pelletier/go-toml/v2already present), Gradle uses plain text (no library needed) - Follow existing patterns — map-based TOML decoding (like
uv.go,pdm.go),Parserinterface withParse(ctx, io.Reader),Type(),Filenames() - Lockfile-only — never invoke
go,cargo, orgradlecommands. If the lockfile doesn't exist, forgeseal reports an error. - Test fixtures in
testdata/— vendor fixture files directly; no network-dependent test setup - Snapshot testing — golden file pattern with
-updateflag, same as existingTestSnapshots
| Phase | Effort | Files Created | Files Modified |
|---|---|---|---|
| Go | 1–2 days | gomod.go, gomod_test.go, testdata, snapshot |
model.go, parser.go, purl.go, cyclonedx.go, generator.go |
| Rust | 1 day | cargo.go, cargo_test.go, testdata, snapshot |
model.go, parser.go, purl.go, cyclonedx.go, generator.go |
| Gradle | 1 day | gradle.go, gradle_test.go, testdata, snapshot |
model.go, parser.go, purl.go, cyclonedx.go, generator.go |
| Integration | 0.5 days | — | parser.go, generator.go, purl_test.go, generator_test.go, README, action.yml |
| Total | 3.5–4.5 days | 9 new files | ~8 modified files |