diff --git a/testing/fipsscan/README.md b/testing/fipsscan/README.md new file mode 100644 index 00000000..80d2578e --- /dev/null +++ b/testing/fipsscan/README.md @@ -0,0 +1,174 @@ +# fipsscan + +`fipsscan` is a Go testing helper for auditing import-policy compliance of Go binaries. It scans a module's dependency tree for imports of forbidden packages and reports violations against a caller-supplied allowlist. + +The package carries **no build constraints** and ships **no default forbidden-package list** — both are the caller's responsibility. This makes it reusable for any import-policy audit, not just FIPS. + +## Naming convention + +> **Recommendation:** use the filename `fips_compliance_test.go` and the test function `TestFIPSCompliance` in every module that adopts this package. + +Consistent naming makes it easy to audit coverage across the Elastic ecosystem: + +``` +# find all modules that have a FIPS compliance test +grep -rl 'TestFIPSCompliance' . + +# run only the FIPS compliance test across all modules in a monorepo +go test -tags requirefips -run TestFIPSCompliance ./... +``` + +It also makes CI configuration uniform: a single `-run TestFIPSCompliance` flag works everywhere without per-module customisation. + +## Quick start + +For most modules, `CheckModule` is all you need. Create `fips_compliance_test.go`: + +```go +//go:build requirefips + +package fips_test + +import ( + "testing" + + "github.com/elastic/elastic-agent-libs/testing/fipsscan" +) + +// forbiddenPkgs lists import-path prefixes that are not permitted in this +// module's dependency tree. Adjust to match your project's FIPS policy. +var forbiddenPkgs = []string{ + "golang.org/x/crypto/", + "github.com/jcmturner/gokrb5/", + "github.com/cloudflare/circl/", +} + +// skipBinaries lists non-shipped binaries excluded from the FIPS scan. +var skipBinaries = []string{ + // "github.com/elastic/myagent/dev-tools/cmd/sometool", +} + +func TestFIPSCompliance(t *testing.T) { + fipsscan.CheckModule(t, + []string{"./..."}, + skipBinaries, + forbiddenPkgs, + []string{"requirefips"}, + map[string]map[string][]fipsscan.KnownViolation{ + // Outer key: binary entry-point import path, a module-root prefix + // that matches any binary under it, or "" for all binaries. + // E.g. "github.com/elastic/myagent" matches both + // "github.com/elastic/myagent/cmd/agent" and + // "github.com/elastic/myagent/cmd/other". + // Inner key: component — a package path or module-root prefix that + // must be reachable from the binary AND able to reach the importer. + // Use a specific component (e.g. "receiver/kafkareceiver") to + // document which component owns the violation. List the same + // (Importer, Imported) under multiple components when several are + // affected by the same shared library. + // Use "" to match any violation within that binary. + "github.com/elastic/myagent": { + // Importer is optional: "" matches any package inside the component. + // Imported supports module-root prefixes (trailing slash stripped automatically). + "github.com/elastic/gokrb5/v8": { + {Imported: "github.com/jcmturner/aescts", Reason: "Elastic gokrb5 fork depends on jcmturner aescts for AES-CBC-CTS"}, + {Imported: "golang.org/x/crypto/md4", Reason: "Kerberos RC4-HMAC requires MD4; no FIPS-approved substitute"}, + }, + "github.com/twmb/franz-go": { + {Imported: "golang.org/x/crypto/pbkdf2", Reason: "Kafka SCRAM SASL key derivation uses PBKDF2; x/crypto not FIPS-certified"}, + }, + }, + }, + ) +} +``` + +Run with: + +``` +go test -tags requirefips ./... +``` + +`CheckModule` scans all packages and their transitive dependencies — binaries and libraries alike — so it works the same way regardless of module type. + +## Bootstrapping the known-violations map + +1. Call `CheckModule` with an empty map (`map[string]map[string][]fipsscan.KnownViolation{}`). +2. The test output lists every `NEW violation` with its full import chain, e.g.: + ``` + cmd/kafkareceiver + -> otel-contrib/receiver/kafkareceiver + -> otel-contrib/internal/kafka ← Importer + -> gokrb5/v8/client ← Imported (forbidden) + ``` +3. Choose a component key — any package or prefix in the chain from the binary down to the Importer (not the forbidden package itself): + | Component key | Meaning | + |---|---| + | `""` | matches any chain in this binary | + | `"cmd/kafkareceiver"` | binary-level — don't need to know which package does the import | + | `"otel-contrib"` | external module root — groups all violations from that module | + | `"otel-contrib/receiver/kafkareceiver"` | specific receiver package | + | `"otel-contrib/internal/kafka"` | exact direct importer | +4. Add a `Reason` explaining why the dependency cannot be replaced. +5. From that point on CI enforces the contract automatically. + +## Advanced usage + +`CheckModule` covers most cases. Use the lower-level functions when you need to build a custom reporting pipeline: + +```go +// all packages in the module (Binary not set on violations) +violations, importGraph := fipsscan.Scan(t, "./...", forbiddenPkgs, []string{"requirefips"}) + +// only package main entry points (fatals if none found; Binary is set on each violation) +violations, importGraph := fipsscan.ScanBinaries(t, []string{"./cmd/agent", "./cmd/other"}, nil, forbiddenPkgs, []string{"requirefips"}) + +for _, v := range violations { + chain := fipsscan.ShortestChain(v.Binary, v.Importer, importGraph) + fmt.Println(fipsscan.FormatChain(append(chain, v.Imported))) +} +``` + +## API reference + +``` +CheckModule(t, patterns, skipBinaries, forbiddenPkgs, tags, knownViolations) + Scans all packages matching patterns and their transitive dependencies. + skipBinaries lists binary import paths to exclude (dev tools, scripts, + non-shipped assets). forbiddenPkgs is the complete list of import-path + prefixes to flag as violations — the caller owns this list entirely. + tags is the list of build tags passed to go list (e.g. []string{"requirefips"}); + pass nil or an empty slice for no tags. + knownViolations is map[binary]map[component][]KnownViolation: + - outer key: binary entry-point path, a module-root prefix (matches any + binary whose import path starts with it), or "" for all binaries + - inner key: component — a package path or module-root prefix that must + be reachable from the binary AND able to reach the violating importer; + "" matches any violation within that binary + A single violation can match multiple component keys simultaneously. List + the same (Importer, Imported) pair under each component that transitively + imports the forbidden package — all matched entries are suppressed, none + are flagged stale as long as the violation persists. + Stale detection: an entry is flagged stale when the binary was scanned, + the component can still reach the importer, but the (Importer, Imported) + pair is no longer a violation. Entries whose component is no longer + reachable from the binary, or whose component can no longer reach the + importer, are silently skipped. t.Errorf on new violations or stale entries. + +Scan(t, pkg, forbiddenPkgs, tags) + Scans pkg and its full dependency tree. Returns ([]Violation, importGraph). + Binary is not set on violations. + +ScanBinaries(t, patterns, skipBinaries, forbiddenPkgs, tags) + Discovers all package main entries matching patterns, scans the combined + dependency tree in one go list pass. skipBinaries lists binary import + paths to exclude. Sets Binary on each violation. Fatals if no binaries + are found. + +ShortestChain(from, to, importGraph) []string + BFS shortest path through the import graph. Useful for building custom + violation reporters. + +FormatChain(chain []string) string + Formats an import chain for human-readable output. +``` diff --git a/testing/fipsscan/fipsscan.go b/testing/fipsscan/fipsscan.go index 490e370a..f589e348 100644 --- a/testing/fipsscan/fipsscan.go +++ b/testing/fipsscan/fipsscan.go @@ -15,47 +15,84 @@ // specific language governing permissions and limitations // under the License. -//go:build requirefips - -// Package fipsscan provides helpers for auditing FIPS 140-3 compliance of Go -// binaries by scanning their dependency trees for forbidden (non-FIPS) imports. -// Only crypto/* stdlib packages are covered by Go's certified FIPS 140-3 module -// (GOFIPS140=v1.0.0); other crypto implementations are potential violations. +// Package fipsscan provides helpers for auditing import-policy compliance of Go +// binaries by scanning their dependency trees for forbidden imports. +// Callers supply the forbidden-package prefixes and known-violations allowlist; +// the package itself is policy-agnostic and carries no build constraints. package fipsscan import ( + "bytes" "encoding/json" "errors" + "os" "os/exec" + "path/filepath" "strings" "testing" ) -// XCrypto is the import path prefix for golang.org/x/crypto, which is not -// covered by Go's certified FIPS 140-3 module. Use as the forbiddenPkgs entry -// for standard FIPS compliance checks. -const XCrypto = "golang.org/x/crypto/" - // Violation is a forbidden import discovered in the dependency tree. type Violation struct { - Importer string // package that imports the forbidden package + Binary string // binary entry point; empty for library modules + Importer string // package that directly imports the forbidden package Imported string // forbidden package being imported } +// KnownViolation documents an accepted import exception for use in knownViolations maps. +// +// Both Importer and Imported support three forms: +// - "" (empty): wildcard — matches anything in that position. +// Importer "" matches any intermediate package; Imported "" matches any forbidden package. +// - exact path: matches only that specific package. +// - module-root prefix: "github.com/foo/bar" matches "github.com/foo/bar" itself +// and any subpackage "github.com/foo/bar/baz". +// +// Use prefix or wildcard to collapse many per-subpackage entries into one. For example, +// {Imported: "github.com/jcmturner/gokrb5/v8"} matches violations from any gokrb5 subpackage. +type KnownViolation struct { + Importer string + Imported string + Reason string // why this violation is acceptable; shown in test output +} + type goListPackage struct { ImportPath string + Name string Imports []string } -// Scan runs `go list -json -deps -tags requirefips ` and returns all -// packages that directly import golang.org/x/crypto or any prefix in -// extraForbiddenPkgs, along with the full import graph. Packages whose own -// path matches a forbidden prefix are skipped (avoids flagging internal refs). -// Calls t.Fatalf on subprocess or parse errors. -func Scan(t testing.TB, pkg string, extraForbiddenPkgs []string) ([]Violation, map[string][]string) { +// moduleRoot walks up from the working directory to find the nearest go.mod +// and returns its containing directory. go test sets cwd to the test package +// directory, so relative patterns like ./... need to run from the module root. +func moduleRoot(t testing.TB) string { t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("finding module root: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("finding module root: go.mod not found starting from %s", dir) + } + dir = parent + } +} - cmd := exec.CommandContext(t.Context(), "go", "list", "-json", "-deps", "-tags", "requirefips", pkg) +// goListPackages runs go list -json -deps from the module root and decodes the output. +func goListPackages(t testing.TB, patterns []string, tags []string) []goListPackage { + t.Helper() + base := []string{"list", "-json", "-deps"} + if len(tags) > 0 { + base = append(base, "-tags", strings.Join(tags, ",")) + } + args := append(base, patterns...) + cmd := exec.CommandContext(t.Context(), "go", args...) + cmd.Dir = moduleRoot(t) out, err := cmd.Output() if err != nil { var exitErr *exec.ExitError @@ -64,39 +101,142 @@ func Scan(t testing.TB, pkg string, extraForbiddenPkgs []string) ([]Violation, m } t.Fatalf("go list failed: %v", err) } - - forbidden := append([]string{XCrypto}, extraForbiddenPkgs...) - - importGraph := make(map[string][]string) - var violations []Violation - - dec := json.NewDecoder(strings.NewReader(string(out))) + var pkgs []goListPackage + dec := json.NewDecoder(bytes.NewReader(out)) for dec.More() { var p goListPackage if err := dec.Decode(&p); err != nil { t.Fatalf("parsing go list output: %v", err) } + pkgs = append(pkgs, p) + } + return pkgs +} + +// scanBinaries runs a single go list pass over all patterns, attributes each +// violation to its binary entry point, and returns the binaries found. +// When no binaries are present (library module) it falls back to flat violation +// detection with no Binary set. +func scanBinaries(t testing.TB, patterns []string, skipBinaries []string, forbiddenPkgs []string, tags []string) ([]Violation, map[string][]string, []string) { + t.Helper() + + pkgs := goListPackages(t, patterns, tags) + + skip := make(map[string]bool, len(skipBinaries)) + for _, s := range skipBinaries { + skip[s] = true + } + + importGraph := make(map[string][]string, len(pkgs)) + var mains []string + + for _, p := range pkgs { + if p.Name == "main" && !skip[p.ImportPath] { + mains = append(mains, p.ImportPath) + } importGraph[p.ImportPath] = p.Imports + } + + if len(mains) == 0 { + return findViolations(importGraph, forbiddenPkgs), importGraph, nil + } + + // Per-binary BFS attribution so each violation carries the entry point + // that pulls it in. + seen := make(map[string]bool) + var violations []Violation + for _, bin := range mains { + for pkg := range bfsReachable(bin, importGraph) { + if isForbidden(pkg, forbiddenPkgs) { + continue + } + for _, imp := range importGraph[pkg] { + if isForbidden(imp, forbiddenPkgs) { + if key := bin + "\x00" + pkg + "\x00" + imp; !seen[key] { + seen[key] = true + violations = append(violations, Violation{Binary: bin, Importer: pkg, Imported: imp}) + } + } + } + } + } + return violations, importGraph, mains +} + +// bfsReachable returns the set of all packages reachable from from (inclusive). +func bfsReachable(from string, importGraph map[string][]string) map[string]bool { + reachable := map[string]bool{from: true} + queue := []string{from} + for len(queue) > 0 { + pkg := queue[0] + queue = queue[1:] + for _, dep := range importGraph[pkg] { + if !reachable[dep] { + reachable[dep] = true + queue = append(queue, dep) + } + } + } + return reachable +} - if isForbidden(p.ImportPath, forbidden) { +// findViolations returns all (Importer, Imported) pairs in importGraph where +// Importer is not itself forbidden but directly imports a forbidden package. +func findViolations(importGraph map[string][]string, forbidden []string) []Violation { + var violations []Violation + for pkg, imports := range importGraph { + if isForbidden(pkg, forbidden) { continue } - for _, imp := range p.Imports { + for _, imp := range imports { if isForbidden(imp, forbidden) { - violations = append(violations, Violation{ - Importer: p.ImportPath, - Imported: imp, - }) + violations = append(violations, Violation{Importer: pkg, Imported: imp}) } } } + return violations +} +// ScanBinaries scans all patterns, attributes each violation to its binary entry +// point (Violation.Binary), and returns all violations and the merged import graph. +// Binaries whose import path appears in skipBinaries are excluded from the scan. +// Calls t.Fatalf if no binaries are found or on subprocess/parse errors. +func ScanBinaries(t testing.TB, patterns []string, skipBinaries []string, forbiddenPkgs []string, tags []string) ([]Violation, map[string][]string) { + t.Helper() + violations, importGraph, mains := scanBinaries(t, patterns, skipBinaries, forbiddenPkgs, tags) + if len(mains) == 0 { + t.Fatalf("ScanBinaries: no package main found matching %v", patterns) + } return violations, importGraph } +// Scan scans pkg and its full dependency tree and returns all violations and the +// import graph. Calls t.Fatalf on subprocess or parse errors. +func Scan(t testing.TB, pkg string, forbiddenPkgs []string, tags []string) ([]Violation, map[string][]string) { + t.Helper() + pkgs := goListPackages(t, []string{pkg}, tags) + importGraph := make(map[string][]string, len(pkgs)) + for _, p := range pkgs { + importGraph[p.ImportPath] = p.Imports + } + return findViolations(importGraph, forbiddenPkgs), importGraph +} + +// matchesBinaryKey reports whether a binary key matches an actual binary import +// path. The key may be an exact import path, a module-root prefix, or "" which +// matches any binary. +func matchesBinaryKey(key, binary string) bool { + if key == "" { + return true + } + bare := strings.TrimRight(key, "/") + return binary == bare || strings.HasPrefix(binary, bare+"/") +} + func isForbidden(pkg string, forbiddenPkgs []string) bool { for _, prefix := range forbiddenPkgs { - if strings.HasPrefix(pkg, prefix) { + bare := strings.TrimRight(prefix, "/") + if pkg == bare || strings.HasPrefix(pkg, bare+"/") { return true } } @@ -160,44 +300,308 @@ func FormatChain(chain []string) string { return strings.Join(parts, "\n") } -// CheckViolations scans binaryPkg for golang.org/x/crypto imports and any -// additional prefixes in extraForbiddenPkgs, attributes each violation to the -// first-hop component from rootPkg, and reports unknown violations or stale -// knownViolations entries via t.Errorf. -func CheckViolations(t testing.TB, binaryPkg, rootPkg string, extraForbiddenPkgs []string, knownViolations map[string]string) { +// CheckModule scans all packages and their transitive dependencies matching +// patterns and reports unknown violations or stale knownViolations entries via +// t.Errorf. Works for both binary and library modules. +// +// knownViolations is a two-level map: +// - Outer key: binary entry-point import path, or "" to match all binaries. +// May be a module-root prefix (e.g. "github.com/elastic/myagent") that +// matches any binary whose import path starts with that prefix. +// - Inner key: component — a package path or module-root prefix that must be +// reachable from the binary AND able to reach the violating importer in the +// import graph. Use "" to match any violation within that binary. +// +// A single violation can match multiple component keys simultaneously: if both +// "fbreceiver" and "azureauthextension" can transitively reach the same +// forbidden importer, listing the same (Importer, Imported) pair under both +// component keys causes both to be suppressed and neither to be flagged stale. +// This is the intended way to document violations shared by multiple components. +// +// For a violation like: +// +// cmd/kafkareceiver +// -> otel-contrib/receiver/kafkareceiver +// -> otel-contrib/internal/kafka ← Importer +// -> gokrb5/v8/client ← Imported (forbidden) +// +// Any of the following are valid component keys: +// +// "" — matches any violation in this binary +// "otel-contrib" — the external module root +// "otel-contrib/receiver/kafkareceiver" — the specific receiver package +// "otel-contrib/internal/kafka" — the exact direct importer +// +// Stale detection: an entry is flagged stale when the binary was scanned, the +// component can still reach the importer, but the (Importer, Imported) pair is +// no longer a violation. Entries whose component is no longer reachable from the +// binary, or whose component can no longer reach the importer, are silently +// skipped (dependency removed or path broken). +// +// Binaries in skipBinaries are excluded from the scan and from stale detection. +func CheckModule(t testing.TB, patterns []string, skipBinaries []string, forbiddenPkgs []string, tags []string, knownViolations map[string]map[string][]KnownViolation) { t.Helper() + violations, importGraph, mains := scanBinaries(t, patterns, skipBinaries, forbiddenPkgs, tags) + checkViolations(t, violations, importGraph, mains, knownViolations) +} - violations, importGraph := Scan(t, binaryPkg, extraForbiddenPkgs) +// checkViolations matches violations against knownViolations and reports new +// violations or stale entries via t.Errorf. +func checkViolations(t testing.TB, violations []Violation, importGraph map[string][]string, mains []string, knownViolations map[string]map[string][]KnownViolation) { + t.Helper() - found := make(map[string]bool) + // matched[binKey][compKey][i] tracks whether knownViolations[binKey][compKey][i] was hit. + matched := make(map[string]map[string][]bool) + for binKey, comps := range knownViolations { + matched[binKey] = make(map[string][]bool, len(comps)) + for compKey, kvs := range comps { + matched[binKey][compKey] = make([]bool, len(kvs)) + } + } + + // Pre-compute reachable set per binary for attribution and stale detection. + reachableFrom := make(map[string]map[string]bool, len(mains)) + for _, bin := range mains { + reachableFrom[bin] = bfsReachable(bin, importGraph) + } + + // Pre-compute which packages in importGraph match each component key + // (exact path or module-root prefix). + compPkgs := make(map[string][]string) + for _, comps := range knownViolations { + for compKey := range comps { + if compKey == "" { + continue + } + if _, seen := compPkgs[compKey]; seen { + continue + } + prefix := compKey + "/" + for pkg := range importGraph { + if pkg == compKey || strings.HasPrefix(pkg, prefix) { + compPkgs[compKey] = append(compPkgs[compKey], pkg) + } + } + } + } + + compReach := newReachabilityCache(importGraph) + + // componentCanReach reports whether the component identified by key can + // reach importer via forward edges in the import graph. key "" is a wildcard. + componentCanReach := func(key, importer string) bool { + if key == "" { + return true + } + for _, pkg := range compPkgs[key] { + if compReach.from(pkg)[importer] { + return true + } + } + return false + } + + // importerMatches reports whether kv.Importer matches the actual importer. + // - kv.Importer == "": wildcard, matches any importer. + // - exact path: must equal importer exactly. + // - prefix: "github.com/foo/bar" matches "github.com/foo/bar/baz". + importerMatches := func(kv KnownViolation, importer string) bool { + if kv.Importer == "" { + return true + } + return kv.Importer == importer || strings.HasPrefix(importer, kv.Importer+"/") + } + + // importedMatches reports whether kv.Imported matches the actual imported package. + // - kv.Imported == "": wildcard, matches any forbidden package. + // - exact path or prefix: trailing slash is stripped before matching. + importedMatches := func(kv KnownViolation, imported string) bool { + if kv.Imported == "" { + return true + } + bare := strings.TrimRight(kv.Imported, "/") + return imported == bare || strings.HasPrefix(imported, bare+"/") + } + + // componentCanReachImporter is the stale-detection fast path: returns false + // when the component can provably no longer reach the importer (or any + // subpackage of it), signalling a silent skip instead of a stale error. + // When kv.Importer is empty the check is skipped (we can't narrow to a + // specific importer, so let the matched flag decide). + componentCanReachImporter := func(compKey string, kv KnownViolation) bool { + if compKey == "" || kv.Importer == "" { + return true + } + sub := kv.Importer + "/" + for _, pkg := range compPkgs[compKey] { + reachable := compReach.from(pkg) + if reachable[kv.Importer] { + return true + } + for rPkg := range reachable { + if strings.HasPrefix(rPkg, sub) { + return true + } + } + } + return false + } + + // binKeysForBinary returns all knownViolations keys applicable to a binary: + // the exact path, any module-root prefix key, and always "". + binKeysForBinary := func(binary string) []string { + if binary == "" { + return []string{""} + } + keys := []string{binary, ""} + for key := range knownViolations { + if key == "" || key == binary { + continue + } + if matchesBinaryKey(key, binary) { + keys = append(keys, key) + } + } + return keys + } + + // markKnown finds ALL known entries where the component can reach the + // importer AND is reachable from the binary, and (Importer, Imported) + // matches. Marks each matched entry and returns the full list so the caller + // can log reasons. A single violation can match multiple component keys. + markKnown := func(binary, importer, imported string) ([]KnownViolation, bool) { + var results []KnownViolation + for _, binKey := range binKeysForBinary(binary) { + for compKey, kvs := range knownViolations[binKey] { + if !componentCanReach(compKey, importer) { + continue + } + // The component must also be reachable from the violation's own binary; + // a component reachable only from a different binary must not suppress + // violations here. + if binary != "" && compKey != "" && !isReachableFromAny(compKey, []string{binary}, reachableFrom) { + continue + } + for i, kv := range kvs { + if importerMatches(kv, importer) && importedMatches(kv, imported) { + matched[binKey][compKey][i] = true + results = append(results, kv) + } + } + } + } + return results, len(results) > 0 + } for _, v := range violations { - chain := ShortestChain(rootPkg, v.Importer, importGraph) - if chain == nil { + var chain []string + if v.Binary != "" { + chain = ShortestChain(v.Binary, v.Importer, importGraph) + if chain == nil { + chain = []string{v.Importer} + } + } else { chain = []string{v.Importer} } displayChain := make([]string, len(chain)+1) copy(displayChain, chain) displayChain[len(chain)] = v.Imported - var component string - if len(chain) > 1 { - component = chain[1] + if kvs, ok := markKnown(v.Binary, v.Importer, v.Imported); ok { + for _, kv := range kvs { + t.Logf("known violation (%s):\n%s\n reason: %s", v.Imported, FormatChain(displayChain), kv.Reason) + } } else { - component = chain[0] + t.Errorf("NEW violation — add to knownViolations or remove the dependency:\n%s", FormatChain(displayChain)) } + } - if reason, ok := knownViolations[component]; !ok { - t.Errorf("NEW violation via unknown component — add to knownViolations or remove the dependency:\n -> %s", FormatChain(displayChain)) - } else { - t.Logf("known violation [%s] via %s:\n -> %s\n reason: %s", v.Imported, component, FormatChain(displayChain), reason) - found[component] = true + // binsForKey returns all scanned binaries matching a binary key + // (exact or module-root prefix; "" matches all). + binsForKey := func(key string) []string { + if key == "" { + return mains + } + var result []string + for _, bin := range mains { + if matchesBinaryKey(key, bin) { + result = append(result, bin) + } + } + return result + } + + for binKey, comps := range matched { + binsToCheck := binsForKey(binKey) + // Skip entries whose binary key matched no scanned binary. + if binKey != "" && len(binsToCheck) == 0 { + continue + } + for compKey, hits := range comps { + if compKey != "" { + if len(binsToCheck) > 0 { + // Binary mode: component must be reachable from at least one matching binary. + if !isReachableFromAny(compKey, binsToCheck, reachableFrom) { + continue + } + } else { + // Library mode: no binary entry points. Check that the component + // still exists somewhere in the import graph. + if len(compPkgs[compKey]) == 0 { + continue + } + } + } + for i, hit := range hits { + if !hit { + kv := knownViolations[binKey][compKey][i] + // If the component can no longer reach the importer (or any + // subpackage of it), the import path was broken — skip silently. + if !componentCanReachImporter(compKey, kv) { + continue + } + t.Errorf("stale knownViolations entry (no longer a violation — remove it): binary=%q component=%q importer=%q imported=%q", binKey, compKey, kv.Importer, kv.Imported) + } + } } } +} + +// reachabilityCache lazily computes and caches the reachable-package set for +// any starting package so each BFS runs at most once. +type reachabilityCache struct { + importGraph map[string][]string + cache map[string]map[string]bool +} - for component := range knownViolations { - if !found[component] { - t.Errorf("stale knownViolations entry (component no longer reaches a forbidden package — remove it): %s", component) +func newReachabilityCache(importGraph map[string][]string) *reachabilityCache { + return &reachabilityCache{importGraph: importGraph, cache: make(map[string]map[string]bool)} +} + +// from returns the full set of packages reachable from pkg (inclusive). +func (r *reachabilityCache) from(pkg string) map[string]bool { + if cached, ok := r.cache[pkg]; ok { + return cached + } + result := bfsReachable(pkg, r.importGraph) + r.cache[pkg] = result + return result +} + +// isReachableFromAny reports whether key or any subpackage of key is reachable +// from at least one binary in bins. +func isReachableFromAny(key string, bins []string, reachableFrom map[string]map[string]bool) bool { + prefix := key + "/" + for _, bin := range bins { + reachable := reachableFrom[bin] + if reachable[key] { + return true + } + for pkg := range reachable { + if strings.HasPrefix(pkg, prefix) { + return true + } } } + return false } diff --git a/testing/fipsscan/fipsscan_test.go b/testing/fipsscan/fipsscan_test.go index 2b4f96fc..e080e98e 100644 --- a/testing/fipsscan/fipsscan_test.go +++ b/testing/fipsscan/fipsscan_test.go @@ -15,15 +15,78 @@ // specific language governing permissions and limitations // under the License. -//go:build requirefips - package fipsscan import ( + "fmt" + "os" + "path/filepath" "reflect" + "strings" "testing" ) +func TestModuleRoot(t *testing.T) { + root := moduleRoot(t) + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { + t.Fatalf("moduleRoot returned %q but no go.mod found there: %v", root, err) + } +} + +func TestMatchesBinaryKey(t *testing.T) { + tests := []struct { + key string + binary string + want bool + }{ + // wildcard + {"", "github.com/foo/cmd/agent", true}, + // exact match + {"github.com/foo/cmd/agent", "github.com/foo/cmd/agent", true}, + // module-root prefix + {"github.com/foo", "github.com/foo/cmd/agent", true}, + // prefix with trailing slash in key (normalised) + {"github.com/foo/", "github.com/foo/cmd/agent", true}, + // no false prefix: foobar must not match foo + {"github.com/foo", "github.com/foobar/cmd/agent", false}, + // key longer than binary + {"github.com/foo/cmd/agent/extra", "github.com/foo/cmd/agent", false}, + } + for _, tc := range tests { + got := matchesBinaryKey(tc.key, tc.binary) + if got != tc.want { + t.Errorf("matchesBinaryKey(%q, %q) = %v, want %v", tc.key, tc.binary, got, tc.want) + } + } +} + +func TestIsForbidden(t *testing.T) { + tests := []struct { + pkg string + prefixes []string + want bool + }{ + // exact match without trailing slash + {"golang.org/x/crypto", []string{"golang.org/x/crypto/"}, true}, + // sub-package matched by prefix with trailing slash + {"golang.org/x/crypto/md4", []string{"golang.org/x/crypto/"}, true}, + // sub-package matched by prefix without trailing slash + {"golang.org/x/crypto/md4", []string{"golang.org/x/crypto"}, true}, + // no false prefix: cryptography must not match crypto + {"golang.org/x/cryptography", []string{"golang.org/x/crypto/"}, false}, + // filippo.io prefix + {"filippo.io/edwards25519", []string{"filippo.io/"}, true}, + // not matched + {"github.com/safe/pkg", []string{"golang.org/x/crypto/"}, false}, + } + for _, tc := range tests { + got := isForbidden(tc.pkg, tc.prefixes) + if got != tc.want { + t.Errorf("isForbidden(%q, %v) = %v, want %v", tc.pkg, tc.prefixes, got, tc.want) + } + } +} + func TestShortestChain(t *testing.T) { graph := map[string][]string{ "root": {"a", "b"}, @@ -89,6 +152,58 @@ func TestShortestChain(t *testing.T) { } } +// TestCheckViolationsCrossBinaryAttribution verifies that a component reachable +// only from binary B does not suppress a violation attributed to binary A, +// even when the component can reach the same importer. +func TestCheckViolationsCrossBinaryAttribution(t *testing.T) { + // Import graph: + // cmd/binary-a -> importer -> golang.org/x/crypto/sha256 + // cmd/binary-b -> comp-b -> importer -> golang.org/x/crypto/sha256 + // + // comp-b can reach importer, but comp-b is only reachable from cmd/binary-b. + // A violation attributed to cmd/binary-a must NOT be suppressed by the + // comp-b entry registered under the "" wildcard binary key. + importGraph := map[string][]string{ + "cmd/binary-a": {"importer"}, + "cmd/binary-b": {"comp-b"}, + "comp-b": {"importer"}, + "importer": {"golang.org/x/crypto/sha256"}, + "golang.org/x/crypto/sha256": {}, + } + mains := []string{"cmd/binary-a", "cmd/binary-b"} + violations := []Violation{ + {Binary: "cmd/binary-a", Importer: "importer", Imported: "golang.org/x/crypto/sha256"}, + {Binary: "cmd/binary-b", Importer: "importer", Imported: "golang.org/x/crypto/sha256"}, + } + // comp-b is documented under the wildcard binary key "". + // It can reach importer, but must NOT suppress binary-a's violation. + knownViolations := map[string]map[string][]KnownViolation{ + "": { + "comp-b": {{Reason: "ok for binary-b"}}, + }, + } + + var errors []string + tb := &captureT{T: t, errorf: func(f string, a ...any) { + errors = append(errors, fmt.Sprintf(f, a...)) + }} + checkViolations(tb, violations, importGraph, mains, knownViolations) + + // binary-a's violation must be reported as NEW (comp-b is not reachable from it). + if len(errors) != 1 || !strings.Contains(errors[0], "NEW violation") { + t.Errorf("expected exactly one NEW violation for cmd/binary-a, got %v", errors) + } +} + +// captureT wraps *testing.T and redirects Errorf so tests can inspect failures. +type captureT struct { + *testing.T + errorf func(string, ...any) +} + +func (c *captureT) Errorf(format string, args ...any) { c.errorf(format, args...) } +func (c *captureT) Helper() { c.T.Helper() } + func TestFormatChain(t *testing.T) { tests := []struct { name string