Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,15 @@ runs:
# tool_result reads a tool's own [PASS]/[FAIL] line (bulwark scan's own report()
# output) — the ground truth for whether that specific tool failed, since a nonzero
# SCAN_EXIT_CODE only says "at least one check failed", not which one.
tool_result() { grep -oE "^\[(PASS|FAIL)\] $2\$" "$1" | tail -1 | sed -E 's/^\[([A-Z]+)\].*/\1/'; }
# The optional "<dir>: " prefix matches how rust.crateLabel and
# golang.moduleLabel qualify a tool name once more than one crate or
# module is discovered ("sdk/wardnet-go: gosec"). Without it the
# anchored pattern silently matches nothing in exactly the multi-module
# repos that need attribution, leaving the comment unable to say which
# tool failed. FAIL anywhere wins: "FAIL" sorts before "PASS", so
# `sort | head -1` reports FAIL if any crate or module failed; taking
# the last line instead would let a passing module mask a failing one.
tool_result() { grep -oE "^\[(PASS|FAIL)\] (.*: )?$2\$" "$1" | sed -E 's/^\[([A-Z]+)\].*/\1/' | sort | head -1; }

# Max bytes of raw tool output to inline per section. GitHub caps a PR
# comment at 65536 chars, and the full logs (multi-thousand-line cargo /
Expand Down
6 changes: 5 additions & 1 deletion cmd/bulwark/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ func newScanCmd() *cobra.Command {
if !cfg.Go.Enabled {
continue
}
results = append(results, golang.Check(ctx, dir)...)
goResults, err := golang.Check(ctx, dir, cfg.Go.Exclude)
if err != nil {
return err
}
results = append(results, goResults...)
}
}
if cfg.Semgrep.Enabled {
Expand Down
52 changes: 52 additions & 0 deletions internal/detect/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,58 @@ func TSPackageDirs(root string, exclude []string) ([]string, error) {
return dirs, nil
}

// GoModuleDirs returns every directory under root containing a go.mod, so
// each Go module can be scanned independently. Directories named in exclude
// (in addition to the built-in defaults) are skipped, matching TSPackageDirs.
//
// Unlike RustCrateDirs, there is no ancestor/member relationship to resolve:
// a nested go.mod starts a genuinely separate module and is *excluded* from
// its parent's package graph, so `./...` at an ancestor would never reach it.
// Every go.mod is therefore its own scan root. A `go.work` file does not
// change this — it affects local resolution, not module boundaries, and
// gosec/govulncheck still need to run inside each module.
func GoModuleDirs(root string, exclude []string) ([]string, error) {
skip := skipSet(exclude)
var dirs []string
var visit func(dir string) error
visit = func(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range entries {
if !e.IsDir() && e.Name() == "go.mod" {
dirs = append(dirs, dir)
break
}
}
for _, e := range entries {
if e.IsDir() && !skip[e.Name()] && !goIgnoredDir(e.Name()) {
if err := visit(filepath.Join(dir, e.Name())); err != nil {
return err
}
}
}
return nil
}
if err := visit(root); err != nil {
return nil, err
}
return dirs, nil
}

// goIgnoredDir reports whether the Go toolchain itself ignores a directory
// when resolving packages: "testdata", and any name beginning with "." or "_".
//
// Honouring this matters because a go.mod under testdata/ is a fixture, not a
// project. Such modules are routinely unbuildable on purpose — no go.sum, or
// deliberately pinned to a vulnerable dependency to exercise a scanner. The
// parent's `./...` never compiles them, so treating one as a real scan root
// would fail the whole run on code that is not shipped.
func goIgnoredDir(name string) bool {
return name == "testdata" || strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_")
}

// RustCrateDirs returns every directory under root that is the effective
// root of an independent Cargo invocation: each directory containing a
// Cargo.toml, except a nested Cargo.toml whose nearest Cargo.toml ancestor
Expand Down
105 changes: 105 additions & 0 deletions internal/detect/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,108 @@ func TestRustCrateDirsDefaultSkipDirsHonored(t *testing.T) {
t.Fatalf("got %v, want none", dirs)
}
}

func TestGoModuleDirsSingleModuleAtRoot(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "go.mod"), "module example.com/foo\n")

dirs, err := GoModuleDirs(root, nil)
if err != nil {
t.Fatal(err)
}
if got := sorted(dirs); len(got) != 1 || got[0] != root {
t.Fatalf("got %v, want [%s]", got, root)
}
}

// The regression this whole function exists for: a scan root that holds no
// go.mod itself, with the real modules in subdirectories. Running the tools
// at the root made govulncheck exit "no go.mod file".
func TestGoModuleDirsModulesInSubdirectories(t *testing.T) {
root := t.TempDir()
sdk := filepath.Join(root, "sdk", "wardnet-go")
cli := filepath.Join(root, "wctl")
writeFile(t, filepath.Join(sdk, "go.mod"), "module wardnet.network/go\n")
writeFile(t, filepath.Join(cli, "go.mod"), "module example.com/wctl\n")
writeFile(t, filepath.Join(root, "daemon", "src", "main.rs"), "fn main() {}\n")

dirs, err := GoModuleDirs(root, nil)
if err != nil {
t.Fatal(err)
}
got := sorted(dirs)
want := sorted([]string{sdk, cli})
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("got %v, want %v", got, want)
}
}

// A nested go.mod is a separate module excluded from its parent's package
// graph, so `./...` at the parent never reaches it — both must be returned.
// This is the point of difference from RustCrateDirs, where a member crate is
// deliberately folded into its workspace root.
func TestGoModuleDirsNestedModuleIsIndependent(t *testing.T) {
root := t.TempDir()
nested := filepath.Join(root, "tools", "gen")
writeFile(t, filepath.Join(root, "go.mod"), "module example.com/outer\n")
writeFile(t, filepath.Join(nested, "go.mod"), "module example.com/outer/tools/gen\n")

dirs, err := GoModuleDirs(root, nil)
if err != nil {
t.Fatal(err)
}
got := sorted(dirs)
want := sorted([]string{root, nested})
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("got %v, want %v", got, want)
}
}

func TestGoModuleDirsRespectsExclude(t *testing.T) {
root := t.TempDir()
keep := filepath.Join(root, "keep")
writeFile(t, filepath.Join(keep, "go.mod"), "module example.com/keep\n")
writeFile(t, filepath.Join(root, "legacy", "go.mod"), "module example.com/legacy\n")

dirs, err := GoModuleDirs(root, []string{"legacy"})
if err != nil {
t.Fatal(err)
}
if got := sorted(dirs); len(got) != 1 || got[0] != keep {
t.Fatalf("got %v, want [%s]", got, keep)
}
}

func TestGoModuleDirsDefaultSkipDirsHonored(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "go.mod"), "module example.com/foo\n")
writeFile(t, filepath.Join(root, "vendor", "dep", "go.mod"), "module example.com/dep\n")

dirs, err := GoModuleDirs(root, nil)
if err != nil {
t.Fatal(err)
}
if got := sorted(dirs); len(got) != 1 || got[0] != root {
t.Fatalf("got %v, want [%s]", got, root)
}
}

// A go.mod under testdata/ is a fixture, not a project — often deliberately
// unbuildable or pinned to a vulnerable dependency to exercise a scanner. Go's
// own package loading ignores testdata, "_"- and "."-prefixed directories, and
// discovery has to agree or the scan fails on code that never ships.
func TestGoModuleDirsSkipsGoIgnoredDirs(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "go.mod"), "module example.com/foo\n")
writeFile(t, filepath.Join(root, "testdata", "broken", "go.mod"), "module example.com/fixture\n")
writeFile(t, filepath.Join(root, "_scratch", "go.mod"), "module example.com/scratch\n")
writeFile(t, filepath.Join(root, ".tools", "go.mod"), "module example.com/tools\n")

dirs, err := GoModuleDirs(root, nil)
if err != nil {
t.Fatal(err)
}
if got := sorted(dirs); len(got) != 1 || got[0] != root {
t.Fatalf("got %v, want [%s]", got, root)
}
}
66 changes: 58 additions & 8 deletions internal/golang/golang.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Package golang runs gosec and govulncheck against a Go module. Both are
// Package golang runs gosec and govulncheck against every Go module found
// under the scan root. Both are
// installed via `go install` into a bulwark-managed, version-keyed bin
// directory (never trusting whatever gosec/govulncheck might already be on
// PATH) — the same "pin the exact toolchain, don't reuse ambient installs"
Expand All @@ -11,6 +12,7 @@ import (
"os"
"path/filepath"

"wardnet/bulwark/internal/detect"
"wardnet/bulwark/internal/executil"
)

Expand All @@ -24,23 +26,71 @@ const (
govulncheckPkg = "golang.org/x/vuln/cmd/govulncheck@" + govulncheckVersion
)

// Check runs gosec and govulncheck against the Go module rooted at dir.
func Check(ctx context.Context, dir string) []executil.Result {
// Check runs gosec and govulncheck against every Go module under root,
// skipping any directory named in exclude.
//
// Both tools are module-scoped: govulncheck exits with "no go.mod file" when
// run anywhere but a module root, so running once at the scan root only ever
// worked when the module happened to sit exactly there. A monorepo that keeps
// its Go modules in subdirectories got a hard failure from govulncheck and a
// misleading pass from gosec, which walks a tree happily without a module.
func Check(ctx context.Context, root string, exclude []string) ([]executil.Result, error) {
modDirs, err := detect.GoModuleDirs(root, exclude)
if err != nil {
return nil, err
}
if len(modDirs) == 0 {
return nil, nil
}

multi := len(modDirs) > 1
var results []executil.Result
for _, dir := range modDirs {
results = append(results, checkModule(ctx, dir, moduleLabel(root, dir, multi))...)
}
return results, nil
}

// moduleLabel mirrors rust.crateLabel: a "<relative dir>: " prefix, applied
// only when more than one module was discovered, so single-module output keeps
// the bare tool names. Relative to root rather than absolute, so results do not
// carry a machine-specific path, and prefixed rather than suffixed so the
// action's `^\[(PASS|FAIL)\] <label>?<tool>$` parsing keeps working.
func moduleLabel(root, dir string, multi bool) string {
if !multi {
return ""
}
rel, err := filepath.Rel(root, dir)
if err != nil || rel == "." {
return ""
}
return rel + ": "
}

// checkModule runs both tools inside a single module directory.
func checkModule(ctx context.Context, dir, label string) []executil.Result {
var results []executil.Result

if bin, err := ensure(ctx, "gosec", gosecVersion, gosecPkg); err != nil {
results = append(results, executil.Result{Name: "gosec", Err: err})
results = append(results, executil.Result{Name: label + "gosec", Err: err})
} else {
r := executil.Run(ctx, dir, bin, "./...")
r.Name = "gosec"
// -exclude-generated skips files carrying the standard
// "Code generated ... DO NOT EDIT." header. Findings there are not
// actionable: the only fix is to change the generator or its input,
// and a `#nosec` annotation would be erased by the next regeneration.
// This matches how generated code is already treated elsewhere in the
// pipeline — golangci-lint's `exclusions: generated` and semgrep's own
// generated-file skip.
r := executil.Run(ctx, dir, bin, "-exclude-generated", "./...")
r.Name = label + "gosec"
results = append(results, r)
}

if bin, err := ensure(ctx, "govulncheck", govulncheckVersion, govulncheckPkg); err != nil {
results = append(results, executil.Result{Name: "govulncheck", Err: err})
results = append(results, executil.Result{Name: label + "govulncheck", Err: err})
} else {
r := executil.Run(ctx, dir, bin, "./...")
r.Name = "govulncheck"
r.Name = label + "govulncheck"
results = append(results, r)
}

Expand Down
29 changes: 29 additions & 0 deletions internal/golang/golang_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package golang

import "testing"

func TestModuleLabel(t *testing.T) {
tests := []struct {
name string
root string
dir string
multi bool
want string
}{
// A single module keeps the bare tool names, so existing output and
// anything parsing it are unaffected by this file's existence.
{"single module is unlabelled", "/repo", "/repo/wctl", false, ""},
{"module at the root is unlabelled", "/repo", "/repo", true, ""},
// Relative, not absolute: an absolute path would put the runner's
// workspace directory into output that gets compared across runs.
{"relative to root", "/repo", "/repo/wctl", true, "wctl: "},
{"nested relative to root", "/repo", "/repo/source/sdk/wardnet-go", true, "source/sdk/wardnet-go: "},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := moduleLabel(tc.root, tc.dir, tc.multi); got != tc.want {
t.Fatalf("moduleLabel(%q, %q, %v) = %q, want %q", tc.root, tc.dir, tc.multi, got, tc.want)
}
})
}
}