Skip to content

Commit ebb819f

Browse files
authored
Add Go module (go.mod/go.sum) lockfile support (#15)
Adds GoModParser that parses go.mod require/replace directives and pairs them with go.sum integrity hashes. Produces pkg:golang/ PURLs with pkg.go.dev external references and SHA-256 hashes from h1: entries. Closes #11
1 parent 56b291f commit ebb819f

12 files changed

Lines changed: 732 additions & 6 deletions

File tree

internal/lockfile/gomod.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package lockfile
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"context"
7+
"fmt"
8+
"io"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
)
13+
14+
// GoModParser handles Go modules (go.mod + go.sum).
15+
// Implements FileParser because go.sum lives alongside go.mod on disk.
16+
type GoModParser struct{}
17+
18+
func (p *GoModParser) Type() LockfileType { return TypeGoMod }
19+
func (p *GoModParser) Filenames() []string { return []string{"go.mod"} }
20+
21+
// Parse parses go.mod from a reader. Integrity hashes are not populated
22+
// because go.sum is a separate file; use ParseFile for full results.
23+
func (p *GoModParser) Parse(ctx context.Context, r io.Reader) (*LockfileResult, error) {
24+
data, err := io.ReadAll(r)
25+
if err != nil {
26+
return nil, err
27+
}
28+
pkgs, err := parseGoMod(data)
29+
if err != nil {
30+
return nil, err
31+
}
32+
return &LockfileResult{Type: TypeGoMod, Packages: pkgs}, nil
33+
}
34+
35+
// ParseFile parses go.mod and the sibling go.sum to populate integrity hashes.
36+
func (p *GoModParser) ParseFile(ctx context.Context, path string) (*LockfileResult, error) {
37+
data, err := os.ReadFile(path)
38+
if err != nil {
39+
return nil, fmt.Errorf("reading go.mod: %w", err)
40+
}
41+
pkgs, err := parseGoMod(data)
42+
if err != nil {
43+
return nil, err
44+
}
45+
46+
// Attempt to read go.sum in the same directory.
47+
sumPath := filepath.Join(filepath.Dir(path), "go.sum")
48+
if sumData, err := os.ReadFile(sumPath); err == nil {
49+
hashes := parseGoSum(sumData)
50+
for i := range pkgs {
51+
key := pkgs[i].Name + " " + pkgs[i].Version
52+
if h, ok := hashes[key]; ok {
53+
pkgs[i].Integrity = h
54+
}
55+
}
56+
}
57+
58+
return &LockfileResult{Type: TypeGoMod, Packages: pkgs}, nil
59+
}
60+
61+
type goModRequire struct {
62+
path string
63+
version string
64+
}
65+
66+
type goModReplace struct {
67+
oldPath string
68+
oldVersion string // may be empty (matches any version)
69+
newPath string
70+
newVersion string // empty for local path replacements
71+
}
72+
73+
func parseGoMod(data []byte) ([]Package, error) {
74+
scanner := bufio.NewScanner(bytes.NewReader(data))
75+
// Support larger go.mod files (some are big).
76+
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
77+
78+
var requires []goModRequire
79+
var replaces []goModReplace
80+
81+
inRequire := false
82+
inReplace := false
83+
84+
for scanner.Scan() {
85+
raw := scanner.Text()
86+
line := stripGoModComment(raw)
87+
line = strings.TrimSpace(line)
88+
if line == "" {
89+
continue
90+
}
91+
92+
if inRequire {
93+
if line == ")" {
94+
inRequire = false
95+
continue
96+
}
97+
if req, ok := parseGoModRequireLine(line); ok {
98+
requires = append(requires, req)
99+
}
100+
continue
101+
}
102+
if inReplace {
103+
if line == ")" {
104+
inReplace = false
105+
continue
106+
}
107+
if rep, ok := parseGoModReplaceLine(line); ok {
108+
replaces = append(replaces, rep)
109+
}
110+
continue
111+
}
112+
113+
switch {
114+
case strings.HasPrefix(line, "require ("):
115+
inRequire = true
116+
case strings.HasPrefix(line, "require "):
117+
if req, ok := parseGoModRequireLine(strings.TrimPrefix(line, "require ")); ok {
118+
requires = append(requires, req)
119+
}
120+
case strings.HasPrefix(line, "replace ("):
121+
inReplace = true
122+
case strings.HasPrefix(line, "replace "):
123+
if rep, ok := parseGoModReplaceLine(strings.TrimPrefix(line, "replace ")); ok {
124+
replaces = append(replaces, rep)
125+
}
126+
}
127+
}
128+
if err := scanner.Err(); err != nil {
129+
return nil, err
130+
}
131+
132+
// Apply replacements.
133+
result := make([]Package, 0, len(requires))
134+
for _, req := range requires {
135+
path, version := req.path, req.version
136+
for _, rep := range replaces {
137+
if rep.oldPath != path {
138+
continue
139+
}
140+
if rep.oldVersion != "" && rep.oldVersion != version {
141+
continue
142+
}
143+
// Skip local path replacements (no version on new side).
144+
if rep.newVersion == "" {
145+
continue
146+
}
147+
path = rep.newPath
148+
version = rep.newVersion
149+
break
150+
}
151+
result = append(result, Package{Name: path, Version: version})
152+
}
153+
return result, nil
154+
}
155+
156+
// stripGoModComment removes // ... comments from a line, preserving content before.
157+
func stripGoModComment(line string) string {
158+
if idx := strings.Index(line, "//"); idx >= 0 {
159+
return line[:idx]
160+
}
161+
return line
162+
}
163+
164+
// parseGoModRequireLine parses e.g. `github.com/foo/bar v1.2.3`.
165+
func parseGoModRequireLine(line string) (goModRequire, bool) {
166+
fields := strings.Fields(line)
167+
if len(fields) < 2 {
168+
return goModRequire{}, false
169+
}
170+
return goModRequire{path: fields[0], version: fields[1]}, true
171+
}
172+
173+
// parseGoModReplaceLine parses replace directive lines:
174+
//
175+
// old => new v1.2.3
176+
// old v1.0.0 => new v1.2.3
177+
// old => ../local/path
178+
func parseGoModReplaceLine(line string) (goModReplace, bool) {
179+
parts := strings.SplitN(line, "=>", 2)
180+
if len(parts) != 2 {
181+
return goModReplace{}, false
182+
}
183+
left := strings.Fields(strings.TrimSpace(parts[0]))
184+
right := strings.Fields(strings.TrimSpace(parts[1]))
185+
if len(left) == 0 || len(right) == 0 {
186+
return goModReplace{}, false
187+
}
188+
189+
rep := goModReplace{oldPath: left[0]}
190+
if len(left) >= 2 {
191+
rep.oldVersion = left[1]
192+
}
193+
rep.newPath = right[0]
194+
if len(right) >= 2 {
195+
rep.newVersion = right[1]
196+
}
197+
return rep, true
198+
}
199+
200+
// parseGoSum parses go.sum and returns a map of "<module> <version>" -> hash.
201+
// Only the module-zip hashes (not the /go.mod lines) are used.
202+
func parseGoSum(data []byte) map[string]string {
203+
out := make(map[string]string)
204+
scanner := bufio.NewScanner(bytes.NewReader(data))
205+
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
206+
for scanner.Scan() {
207+
fields := strings.Fields(scanner.Text())
208+
if len(fields) != 3 {
209+
continue
210+
}
211+
path, version, hash := fields[0], fields[1], fields[2]
212+
if strings.HasSuffix(version, "/go.mod") {
213+
continue
214+
}
215+
out[path+" "+version] = hash
216+
}
217+
return out
218+
}

internal/lockfile/gomod_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package lockfile
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
func TestGoModParser(t *testing.T) {
9+
parser := &GoModParser{}
10+
result, err := parser.ParseFile(context.Background(), "../../testdata/lockfiles/gomod/go.mod")
11+
if err != nil {
12+
t.Fatal(err)
13+
}
14+
15+
if result.Type != TypeGoMod {
16+
t.Errorf("expected type %s, got %s", TypeGoMod, result.Type)
17+
}
18+
19+
if len(result.Packages) != 10 {
20+
t.Fatalf("expected 10 packages, got %d", len(result.Packages))
21+
}
22+
23+
found := make(map[string]Package)
24+
for _, pkg := range result.Packages {
25+
found[pkg.Name] = pkg
26+
}
27+
28+
// Direct dep.
29+
if pkg, ok := found["github.com/gorilla/mux"]; !ok {
30+
t.Error("missing github.com/gorilla/mux")
31+
} else if pkg.Version != "v1.8.1" {
32+
t.Errorf("gorilla/mux version: got %s, want v1.8.1", pkg.Version)
33+
}
34+
35+
// /v2-style major version module.
36+
if pkg, ok := found["github.com/go-chi/chi/v5"]; !ok {
37+
t.Error("missing github.com/go-chi/chi/v5")
38+
} else if pkg.Version != "v5.0.12" {
39+
t.Errorf("chi/v5 version: got %s, want v5.0.12", pkg.Version)
40+
}
41+
42+
// Indirect dep should still be included.
43+
if _, ok := found["github.com/davecgh/go-spew"]; !ok {
44+
t.Error("missing indirect dep github.com/davecgh/go-spew")
45+
}
46+
47+
// Replace directive: golang.org/x/sys should be bumped to v0.30.0.
48+
if pkg, ok := found["golang.org/x/sys"]; !ok {
49+
t.Error("missing golang.org/x/sys (replacement)")
50+
} else if pkg.Version != "v0.30.0" {
51+
t.Errorf("golang.org/x/sys: replace not applied, got version %s, want v0.30.0", pkg.Version)
52+
}
53+
54+
// Integrity hash should be populated from go.sum using the replaced version.
55+
if pkg := found["golang.org/x/sys"]; pkg.Integrity == "" {
56+
t.Error("golang.org/x/sys missing integrity hash from go.sum")
57+
}
58+
if pkg := found["github.com/gorilla/mux"]; pkg.Integrity == "" {
59+
t.Error("gorilla/mux missing integrity hash from go.sum")
60+
}
61+
}
62+
63+
func TestGoModParserReplace(t *testing.T) {
64+
input := []byte(`module example.com/test
65+
66+
go 1.22
67+
68+
require golang.org/x/old v0.1.0
69+
70+
replace golang.org/x/old => golang.org/x/new v0.5.0
71+
`)
72+
pkgs, err := parseGoMod(input)
73+
if err != nil {
74+
t.Fatal(err)
75+
}
76+
if len(pkgs) != 1 {
77+
t.Fatalf("expected 1 package, got %d", len(pkgs))
78+
}
79+
if pkgs[0].Name != "golang.org/x/new" || pkgs[0].Version != "v0.5.0" {
80+
t.Errorf("replace not applied: got %+v", pkgs[0])
81+
}
82+
}
83+
84+
func TestGoModParserReplaceLocalPath(t *testing.T) {
85+
input := []byte(`module example.com/test
86+
87+
require example.com/foo v1.0.0
88+
89+
replace example.com/foo => ../local
90+
`)
91+
pkgs, err := parseGoMod(input)
92+
if err != nil {
93+
t.Fatal(err)
94+
}
95+
if len(pkgs) != 1 {
96+
t.Fatalf("expected 1 package, got %d", len(pkgs))
97+
}
98+
// Local-path replacement skipped; original kept.
99+
if pkgs[0].Name != "example.com/foo" || pkgs[0].Version != "v1.0.0" {
100+
t.Errorf("local replace should be skipped: got %+v", pkgs[0])
101+
}
102+
}
103+
104+
func TestParseGoModRequireLine(t *testing.T) {
105+
req, ok := parseGoModRequireLine("github.com/foo/bar v1.2.3")
106+
if !ok {
107+
t.Fatal("expected ok")
108+
}
109+
if req.path != "github.com/foo/bar" || req.version != "v1.2.3" {
110+
t.Errorf("unexpected: %+v", req)
111+
}
112+
113+
if _, ok := parseGoModRequireLine("single-token"); ok {
114+
t.Error("expected failure on single token")
115+
}
116+
}
117+
118+
func TestGoModParserType(t *testing.T) {
119+
p := &GoModParser{}
120+
if p.Type() != TypeGoMod {
121+
t.Errorf("expected %s, got %s", TypeGoMod, p.Type())
122+
}
123+
if fnames := p.Filenames(); len(fnames) != 1 || fnames[0] != "go.mod" {
124+
t.Errorf("unexpected filenames: %v", fnames)
125+
}
126+
}

internal/lockfile/model.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const (
1414
TypePoetry LockfileType = "poetry"
1515
TypePDM LockfileType = "pdm"
1616
TypeUV LockfileType = "uv"
17+
TypeGoMod LockfileType = "gomod"
1718
)
1819

1920
// Package represents a resolved dependency from a lockfile.

internal/lockfile/parser.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ func init() {
4747
&PoetryParser{},
4848
&PDMParser{},
4949
&RequirementsTxtParser{},
50+
// Go
51+
&GoModParser{},
5052
}
5153
}
5254

internal/sbom/cyclonedx.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ func mapComponent(pkg lockfile.Package, ecosystem string) cdx.Component {
2828

2929
// External references
3030
var registryURL string
31-
if ecosystem == "pypi" {
31+
switch ecosystem {
32+
case "pypi":
3233
registryURL = "https://pypi.org/project/" + normalizePyPIName(pkg.Name) + "/"
33-
} else {
34+
case "golang":
35+
registryURL = "https://pkg.go.dev/" + pkg.Name
36+
default:
3437
registryURL = npmRegistryURL(pkg.Name)
3538
}
3639

@@ -73,6 +76,12 @@ func parseIntegrityHashes(integrity string) *[]cdx.Hash {
7376
Algorithm: cdx.HashAlgoSHA256,
7477
Value: val,
7578
})
79+
} else if strings.HasPrefix(part, "h1:") {
80+
// Go module hash: h1:<base64-sha256>
81+
hashes = append(hashes, cdx.Hash{
82+
Algorithm: cdx.HashAlgoSHA256,
83+
Value: strings.TrimPrefix(part, "h1:"),
84+
})
7685
} else if strings.HasPrefix(part, "sha1-") {
7786
hashes = append(hashes, cdx.Hash{
7887
Algorithm: cdx.HashAlgoSHA1,

0 commit comments

Comments
 (0)