Skip to content

Commit 513adba

Browse files
Merge pull request #411 from AikidoSec/zen-go-detect-drift
Add version drift check to zen-go CLI
2 parents bc806ad + b8892cc commit 513adba

4 files changed

Lines changed: 279 additions & 1 deletion

File tree

cmd/zen-go/cmd_toolexec_compile.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/AikidoSec/firewall-go/cmd/zen-go/internal/importcfg"
1414
"github.com/AikidoSec/firewall-go/cmd/zen-go/internal/instrumentor"
15+
"github.com/AikidoSec/firewall-go/cmd/zen-go/internal/rules"
1516
)
1617

1718
func toolexecCompileCommand(stdout io.Writer, stderr io.Writer, tool string, toolArgs []string) error {
@@ -37,6 +38,10 @@ func toolexecCompileCommand(stdout io.Writer, stderr io.Writer, tool string, too
3738
return err
3839
}
3940

41+
if err := checkVersionSync(toolArgs); err != nil {
42+
return err
43+
}
44+
4045
// These are the arguments that we want to pass through to the compiler
4146
// If we modify a file, we need to pass the modified file to the compiler instead of the original file
4247
newArgs, allAddedImports, allLinkDeps, err := instrumentFiles(stderr, toolArgs, pkgPath, objdir)
@@ -231,6 +236,26 @@ func checkZenToolFileIncluded(pkgPath string, toolArgs []string, stderr io.Write
231236
return nil
232237
}
233238

239+
func checkVersionSync(toolArgs []string) error {
240+
var sourceDir string
241+
for _, arg := range toolArgs {
242+
if strings.HasSuffix(arg, ".go") {
243+
sourceDir = filepath.Dir(arg)
244+
break
245+
}
246+
}
247+
if sourceDir == "" {
248+
return nil
249+
}
250+
251+
gomodPath := rules.FindGoMod(sourceDir)
252+
if gomodPath == "" {
253+
return nil
254+
}
255+
256+
return rules.CheckModuleVersionSync(gomodPath)
257+
}
258+
234259
func writeTempFile(origPath string, content []byte, objdir string) (string, error) {
235260
// Write to objdir/zen-go/src/
236261
dir := filepath.Join(objdir, "zen-go", "src")

cmd/zen-go/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ require (
3434
github.com/tklauser/go-sysconf v0.3.16 // indirect
3535
github.com/tklauser/numcpus v0.11.0 // indirect
3636
github.com/yusufpapurcu/wmi v1.2.4 // indirect
37-
golang.org/x/mod v0.32.0 // indirect
37+
golang.org/x/mod v0.32.0
3838
golang.org/x/sync v0.19.0 // indirect
3939
golang.org/x/sys v0.40.0 // indirect
4040
golang.org/x/text v0.18.0 // indirect
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package rules
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
9+
"golang.org/x/mod/modfile"
10+
)
11+
12+
const aikidoMainModule = "github.com/AikidoSec/firewall-go"
13+
14+
// FindGoMod walks up from startDir looking for a go.mod file.
15+
// Returns the path if found, or empty string if not found.
16+
func FindGoMod(startDir string) string {
17+
dir := startDir
18+
for {
19+
candidate := filepath.Join(dir, "go.mod")
20+
if _, err := os.Stat(candidate); err == nil {
21+
return candidate
22+
}
23+
parent := filepath.Dir(dir)
24+
if parent == dir {
25+
return ""
26+
}
27+
dir = parent
28+
}
29+
}
30+
31+
// CheckModuleVersionSync parses the go.mod at gomodPath and returns an error if
32+
// any github.com/AikidoSec/firewall-go instrumentation submodule is at a different
33+
// version than the main github.com/AikidoSec/firewall-go module.
34+
//
35+
// Returns nil if the main module is not required (not a firewall-go project)
36+
// or if all versions are aligned.
37+
func CheckModuleVersionSync(gomodPath string) error {
38+
// #nosec G304 - gomodPath is derived from the project source directory
39+
data, err := os.ReadFile(gomodPath)
40+
if err != nil {
41+
return nil
42+
}
43+
44+
f, err := modfile.Parse(gomodPath, data, func(_, version string) (string, error) {
45+
return version, nil
46+
})
47+
if err != nil {
48+
return nil
49+
}
50+
51+
var mainVersion string
52+
for _, req := range f.Require {
53+
if req.Mod.Path == aikidoMainModule {
54+
mainVersion = req.Mod.Version
55+
break
56+
}
57+
}
58+
59+
if mainVersion == "" {
60+
return nil
61+
}
62+
63+
replaced := make(map[string]bool)
64+
for _, r := range f.Replace {
65+
replaced[r.Old.Path] = true
66+
}
67+
68+
if replaced[aikidoMainModule] {
69+
return nil
70+
}
71+
72+
type mismatch struct{ path, version string }
73+
var mismatches []mismatch
74+
75+
for _, req := range f.Require {
76+
if strings.HasPrefix(req.Mod.Path, aikidoMainModule+"/") && !replaced[req.Mod.Path] && req.Mod.Version != mainVersion {
77+
mismatches = append(mismatches, mismatch{req.Mod.Path, req.Mod.Version})
78+
}
79+
}
80+
81+
if len(mismatches) == 0 {
82+
return nil
83+
}
84+
85+
var fixes []string
86+
for _, m := range mismatches {
87+
fixes = append(fixes, m.path+"@"+mainVersion)
88+
}
89+
90+
var details []string
91+
for _, m := range mismatches {
92+
details = append(details, fmt.Sprintf(" %s is at %s, expected %s", m.path, m.version, mainVersion))
93+
}
94+
95+
return fmt.Errorf(
96+
"zen-go: instrumentation package version mismatch (%s is at %s):\n%s\nrun: go get %s",
97+
aikidoMainModule,
98+
mainVersion,
99+
strings.Join(details, "\n"),
100+
strings.Join(fixes, " "),
101+
)
102+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package rules
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func writeGoMod(t *testing.T, dir, content string) string {
13+
t.Helper()
14+
path := filepath.Join(dir, "go.mod")
15+
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
16+
return path
17+
}
18+
19+
func TestCheckModuleVersionSync_Aligned(t *testing.T) {
20+
dir := t.TempDir()
21+
path := writeGoMod(t, dir, `module example.com/app
22+
23+
go 1.22
24+
25+
require (
26+
github.com/AikidoSec/firewall-go v1.2.0
27+
github.com/AikidoSec/firewall-go/instrumentation/sources/gin-gonic/gin v1.2.0
28+
github.com/AikidoSec/firewall-go/instrumentation/sinks/pgx.v5 v1.2.0
29+
)
30+
`)
31+
require.NoError(t, CheckModuleVersionSync(path))
32+
}
33+
34+
func TestCheckModuleVersionSync_Mismatch(t *testing.T) {
35+
dir := t.TempDir()
36+
path := writeGoMod(t, dir, `module example.com/app
37+
38+
go 1.22
39+
40+
require (
41+
github.com/AikidoSec/firewall-go v1.2.0
42+
github.com/AikidoSec/firewall-go/instrumentation/sources/gin-gonic/gin v1.1.1
43+
)
44+
`)
45+
err := CheckModuleVersionSync(path)
46+
require.Error(t, err)
47+
assert.Contains(t, err.Error(), "version mismatch")
48+
assert.Contains(t, err.Error(), "v1.2.0")
49+
assert.Contains(t, err.Error(), "gin is at v1.1.1")
50+
assert.Contains(t, err.Error(), "go get")
51+
assert.Contains(t, err.Error(), "gin@v1.2.0")
52+
}
53+
54+
func TestCheckModuleVersionSync_MultipleMismatches(t *testing.T) {
55+
dir := t.TempDir()
56+
path := writeGoMod(t, dir, `module example.com/app
57+
58+
go 1.22
59+
60+
require (
61+
github.com/AikidoSec/firewall-go v1.2.0
62+
github.com/AikidoSec/firewall-go/instrumentation/sources/gin-gonic/gin v1.1.1
63+
github.com/AikidoSec/firewall-go/instrumentation/sinks/pgx.v5 v1.0.0
64+
)
65+
`)
66+
err := CheckModuleVersionSync(path)
67+
require.Error(t, err)
68+
assert.Contains(t, err.Error(), "gin is at v1.1.1")
69+
assert.Contains(t, err.Error(), "pgx.v5 is at v1.0.0")
70+
assert.Contains(t, err.Error(), "gin@v1.2.0")
71+
assert.Contains(t, err.Error(), "pgx.v5@v1.2.0")
72+
}
73+
74+
func TestCheckModuleVersionSync_NoFirewallModule(t *testing.T) {
75+
dir := t.TempDir()
76+
path := writeGoMod(t, dir, `module example.com/app
77+
78+
go 1.22
79+
80+
require (
81+
github.com/gin-gonic/gin v1.9.0
82+
)
83+
`)
84+
require.NoError(t, CheckModuleVersionSync(path))
85+
}
86+
87+
func TestCheckModuleVersionSync_MainModuleOnly(t *testing.T) {
88+
dir := t.TempDir()
89+
path := writeGoMod(t, dir, `module example.com/app
90+
91+
go 1.22
92+
93+
require (
94+
github.com/AikidoSec/firewall-go v1.2.0
95+
)
96+
`)
97+
require.NoError(t, CheckModuleVersionSync(path))
98+
}
99+
100+
func TestCheckModuleVersionSync_UnreadableFile(t *testing.T) {
101+
require.NoError(t, CheckModuleVersionSync("/nonexistent/go.mod"))
102+
}
103+
104+
func TestCheckModuleVersionSync_ReplacedSubmodule(t *testing.T) {
105+
dir := t.TempDir()
106+
path := writeGoMod(t, dir, `module example.com/app
107+
108+
go 1.22
109+
110+
require (
111+
github.com/AikidoSec/firewall-go v1.2.0
112+
github.com/AikidoSec/firewall-go/instrumentation/sources/gin-gonic/gin v0.0.0-00010101000000-000000000000
113+
)
114+
115+
replace github.com/AikidoSec/firewall-go/instrumentation/sources/gin-gonic/gin => ../../instrumentation/sources/gin-gonic/gin
116+
`)
117+
require.NoError(t, CheckModuleVersionSync(path))
118+
}
119+
120+
func TestCheckModuleVersionSync_ReplacedMainModule(t *testing.T) {
121+
dir := t.TempDir()
122+
path := writeGoMod(t, dir, `module example.com/app
123+
124+
go 1.22
125+
126+
require (
127+
github.com/AikidoSec/firewall-go v1.2.0
128+
github.com/AikidoSec/firewall-go/instrumentation/sources/gin-gonic/gin v1.1.1
129+
)
130+
131+
replace github.com/AikidoSec/firewall-go => ../../
132+
`)
133+
require.NoError(t, CheckModuleVersionSync(path))
134+
}
135+
136+
func TestFindGoMod_Found(t *testing.T) {
137+
root := t.TempDir()
138+
gomodPath := filepath.Join(root, "go.mod")
139+
require.NoError(t, os.WriteFile(gomodPath, []byte("module example.com/app\n"), 0o644))
140+
141+
subdir := filepath.Join(root, "pkg", "handlers")
142+
require.NoError(t, os.MkdirAll(subdir, 0o755))
143+
144+
assert.Equal(t, gomodPath, FindGoMod(subdir))
145+
assert.Equal(t, gomodPath, FindGoMod(root))
146+
}
147+
148+
func TestFindGoMod_NotFound(t *testing.T) {
149+
dir := t.TempDir()
150+
assert.Equal(t, "", FindGoMod(dir))
151+
}

0 commit comments

Comments
 (0)