From 114b30c42ecfc7c240214de35af84c5013d2708b Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 17 Sep 2026 14:49:27 -0400 Subject: [PATCH 01/16] fix(supply-chain): stop lockfile-drift false positives from mis-parsed lockfiles `supply_chain.lockfile-drift` is a fail-level rule, and three parsing defects made it report every dependency in a manifest as missing. Yarn Berry lockfiles parsed to zero packages. `parseYarnLockState` only read the Yarn v1 spelling of the version field (`version "2.6.0"`); Yarn 2+ writes `version: 2.6.0`. It now reads both, splits selectors at the first non-scope `@` so Berry's protocol form (`@linear/sdk@npm:^2.6.0`) and classic npm aliases are read correctly, and treats a non-`npm:` protocol (workspace:, link:, portal:, file:, patch:, git/URL) as present-but-not-a-registry-range rather than as a comparable requirement. pnpm's pre-v6 `/name/1.2.3` key shape is read too; it had the same zero-package failure. A zero-package parse was reported as total drift. Every branch of `parseLockfileState` hardcoded `ok = true`, defeating the `parsedAny` guard that exists to stay silent on a lockfile the parser cannot read. Each parser now decides for itself, and a parser that recognised nothing reports nothing. "Empty" is told apart from "unparsed" by the format's own evidence: npm lockfiles self-identify structurally; Cargo/Poetry/uv count as empty only when every substantive line is a known header key, which a truncated or conflict-marked file fails; yarn, go.sum and bun require either a package or a file holding nothing but comments. Python names were compared without PEP 503 normalisation, so a `pyproject.toml` entry for `Jinja2` or `typing_extensions` never matched the `jinja2` / `typing-extensions` entry Poetry writes. Names are now compared through the normalised form for the Python ecosystem only: npm names are case- and separator-sensitive, and Cargo, Go and Conan record the manifest spelling, so normalising those would match the wrong package instead of fixing anything. On two real repositories this takes `lockfile-drift` from 33 findings to 0 (a Yarn Berry monorepo) and from 2 to 0 (a Poetry backend), while a deliberately stale Berry lockfile still reports exactly the one missing dependency. Co-Authored-By: Claude Opus 5 (1M context) --- docs/checks.md | 6 + .../checks/support/supply_chain_cpp.go | 2 +- .../checks/support/supply_chain_lockfiles.go | 117 +++++- .../support/supply_chain_lockfiles_misc.go | 54 ++- .../support/supply_chain_lockfiles_node.go | 163 +++++++- .../supplychain_lockfile_parsing_test.go | 388 ++++++++++++++++++ 6 files changed, 684 insertions(+), 46 deletions(-) create mode 100644 tests/checks/supplychain_lockfile_parsing_test.go diff --git a/docs/checks.md b/docs/checks.md index 4361a1a8..87f938c3 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -1481,6 +1481,12 @@ Rules: | `supply_chain.cargo.missing-package-license` | Rust / Cargo manifests | always on when `checks.supply_chain` is enabled | | `supply_chain.cargo.non-hermetic-source` | Rust / Cargo manifests | always on when `checks.supply_chain` is enabled | +Notes on lockfile drift precision: +- `lockfile-drift` reports a dependency only when the lockfile was understood. A lockfile that yields no recognizable packages is treated as unparsed and skipped rather than reported as "every dependency is missing"; an empty lockfile is distinguished from an unparsed one by its format's own header (an npm `lockfileVersion`, a Cargo/Poetry/uv header-only file, a file holding nothing but comments). +- `yarn.lock` is read in both the Yarn v1 (`version "1.2.3"`) and Yarn 2+ Berry (`version: 1.2.3`) spellings. Berry protocol selectors are understood: `npm:` ranges are compared against the manifest, while `workspace:`, `link:`, `portal:`, `file:`, `patch:` and git/URL resolutions count as present but are not compared against a registry range. +- `pnpm-lock.yaml` is read in both the `name@1.2.3` (lockfile v6+) and `/name/1.2.3` (v5 and earlier) key shapes. +- Python package names are compared after PEP 503 normalization (lowercase, and each run of `-`, `_` or `.` collapsed to `-`), so a `pyproject.toml` entry for `Jinja2` or `typing_extensions` matches the `jinja2` / `typing-extensions` entry Poetry and uv write. npm, Cargo, Go and Conan names are compared exactly, because in those ecosystems case and separators identify different packages. + Notes on Cargo precision: - `cargo.missing-package-license` looks only at the manifest package metadata (`package.license` in `Cargo.toml`); it does not infer intent from README text or dependency licenses. - `cargo.non-hermetic-source` warns on `path = ...`, `branch = ...`, or `git = ...` without `rev = ...` in dependency specs. A git dependency pinned to an exact `rev` stays silent. diff --git a/internal/codeguard/checks/support/supply_chain_cpp.go b/internal/codeguard/checks/support/supply_chain_cpp.go index d3610f77..a84b171f 100644 --- a/internal/codeguard/checks/support/supply_chain_cpp.go +++ b/internal/codeguard/checks/support/supply_chain_cpp.go @@ -174,5 +174,5 @@ func parseConanLockState(data []byte) (lockfileState, bool) { } addLockfilePackage(state, dep.Name, dep.Version) } - return state, len(state.packages) > 0 + return state, len(state.packages) > 0 || lockfileHasNoContent(data) } diff --git a/internal/codeguard/checks/support/supply_chain_lockfiles.go b/internal/codeguard/checks/support/supply_chain_lockfiles.go index 6d863259..39c48312 100644 --- a/internal/codeguard/checks/support/supply_chain_lockfiles.go +++ b/internal/codeguard/checks/support/supply_chain_lockfiles.go @@ -3,16 +3,29 @@ package support import ( "os" "path/filepath" + "regexp" "strings" "github.com/devr-tools/codeguard/internal/codeguard/core" ) +// lockfileState is the set of packages a lockfile pins. +// +// packages is keyed by the exact spelling the lockfile uses. normalized holds +// the same entries under their PEP 503-normalised name and is consulted only +// for ecosystems whose package names are canonicalised by the resolver (see +// ecosystemNormalizesPackageNames): npm and Cargo names are case- and +// separator-sensitive, so normalising them would silently match distinct +// packages. type lockfileState struct { - packages map[string]map[string]struct{} - selectors map[string]map[string]struct{} + packages map[string]map[string]struct{} + selectors map[string]map[string]struct{} + normalized map[string]map[string]struct{} } +// pythonNameSeparatorPattern matches the runs of separators PEP 503 collapses. +var pythonNameSeparatorPattern = regexp.MustCompile(`[-_.]+`) + func SupplyChainLockfileIssues(root string, manifest core.SupplyChainManifest) []string { if len(manifest.Lockfiles) == 0 { return nil @@ -44,21 +57,33 @@ func SupplyChainLockfileIssues(root string, manifest core.SupplyChainManifest) [ return firstIssues } +// parseLockfileState decodes a lockfile into the set of packages it pins. +// +// The boolean result means "this parser recognised the file", not "the file had +// packages in it". Every branch must report false when it understood nothing: +// a parser that extracted no packages from a file full of content has no basis +// for claiming that every manifest dependency is missing, and emitting a +// fail-level finding per dependency off the back of a parse failure is far +// worse than staying silent. Recognition is therefore established either by a +// structural marker of the format (an npm lockfileVersion, a [[package]] table) +// or by having extracted at least one package; a lockfile whose only content is +// blank lines and comments counts as recognised-and-empty, which is how a +// genuinely dependency-free lockfile still reports drift. func parseLockfileState(path string, data []byte) (lockfileState, bool) { base := strings.ToLower(filepath.Base(path)) switch base { case "go.sum": - return parseGoSumState(data), true + return parseGoSumState(data) case "package-lock.json", "npm-shrinkwrap.json": return parsePackageLockState(data) case "pnpm-lock.yaml": return parsePNPMLockState(data) case "yarn.lock": - return parseYarnLockState(data), true + return parseYarnLockState(data) case "bun.lock": - return parseBunLockState(data), true + return parseBunLockState(data) case "cargo.lock", "poetry.lock", "uv.lock": - return parsePackageBlockLockState(data), true + return parsePackageBlockLockState(data) case "conan.lock": return parseConanLockState(data) default: @@ -72,11 +97,11 @@ func compareManifestToLockfile(manifest core.SupplyChainManifest, lockfile strin if dep.Name == "" { continue } - if !lockfileHasPackage(state, dep.Name) { + if !lockfileHasPackage(state, manifest.Ecosystem, dep.Name) { issues = append(issues, "dependency "+dep.Name+" is not present in "+lockfile) continue } - if exact := exactLockedVersion(manifest, dep); exact != "" && !lockfileHasVersion(state, dep.Name, exact) { + if exact := exactLockedVersion(manifest, dep); exact != "" && !lockfileHasVersion(state, manifest.Ecosystem, dep.Name, exact) { if manifest.Ecosystem == "npm" && lockfileHasSelector(state, dep.Name, dep.Requirement) { continue } @@ -88,8 +113,9 @@ func compareManifestToLockfile(manifest core.SupplyChainManifest, lockfile strin func newLockfileState() lockfileState { return lockfileState{ - packages: make(map[string]map[string]struct{}), - selectors: make(map[string]map[string]struct{}), + packages: make(map[string]map[string]struct{}), + selectors: make(map[string]map[string]struct{}), + normalized: make(map[string]map[string]struct{}), } } @@ -99,11 +125,19 @@ func addLockfilePackage(state lockfileState, name string, version string) { if name == "" { return } - if _, ok := state.packages[name]; !ok { - state.packages[name] = make(map[string]struct{}) + addLockfileVersion(state.packages, name, version) + addLockfileVersion(state.normalized, normalizePythonPackageName(name), version) +} + +func addLockfileVersion(index map[string]map[string]struct{}, name string, version string) { + if name == "" { + return + } + if _, ok := index[name]; !ok { + index[name] = make(map[string]struct{}) } if version != "" { - state.packages[name][version] = struct{}{} + index[name][version] = struct{}{} } } @@ -119,13 +153,27 @@ func addLockfileSelector(state lockfileState, name string, selector string) { state.selectors[name][selector] = struct{}{} } -func lockfileHasPackage(state lockfileState, name string) bool { - _, ok := state.packages[name] +func lockfileHasPackage(state lockfileState, ecosystem string, name string) bool { + if _, ok := state.packages[name]; ok { + return true + } + if !ecosystemNormalizesPackageNames(ecosystem) { + return false + } + _, ok := state.normalized[normalizePythonPackageName(name)] return ok } -func lockfileHasVersion(state lockfileState, name string, version string) bool { - versions, ok := state.packages[name] +func lockfileHasVersion(state lockfileState, ecosystem string, name string, version string) bool { + if versions, ok := state.packages[name]; ok { + if _, ok := versions[version]; ok { + return true + } + } + if !ecosystemNormalizesPackageNames(ecosystem) { + return false + } + versions, ok := state.normalized[normalizePythonPackageName(name)] if !ok { return false } @@ -141,3 +189,38 @@ func lockfileHasSelector(state lockfileState, name string, selector string) bool _, ok = selectors[strings.TrimSpace(selector)] return ok } + +// ecosystemNormalizesPackageNames reports whether the ecosystem's resolver +// rewrites package names into a canonical form when it writes the lockfile, so +// that manifest and lockfile spellings must be compared through that form. +// +// Only Python does. pip, Poetry and uv all record the PEP 503-normalised name +// (`Jinja2` becomes `jinja2`, `typing_extensions` becomes `typing-extensions`) +// while manifests keep the author's spelling. npm names are case-sensitive and +// `-` and `_` name genuinely different packages, and Cargo records the crate +// name exactly as the manifest and the index spell it, so normalising either +// would match the wrong package instead of fixing a false positive. +func ecosystemNormalizesPackageNames(ecosystem string) bool { + return ecosystem == "python" +} + +// normalizePythonPackageName applies PEP 503 name normalisation: lowercase and +// collapse every run of `-`, `_` and `.` into a single `-`. +func normalizePythonPackageName(name string) string { + return pythonNameSeparatorPattern.ReplaceAllString(strings.ToLower(strings.TrimSpace(name)), "-") +} + +// lockfileHasNoContent reports whether a lockfile holds nothing but blank lines +// and comments. It is how a parser tells "this lockfile locks nothing" apart +// from "I could not read this lockfile": the former is recognised and compared, +// the latter is skipped. +func lockfileHasNoContent(data []byte) bool { + for _, rawLine := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { + continue + } + return false + } + return true +} diff --git a/internal/codeguard/checks/support/supply_chain_lockfiles_misc.go b/internal/codeguard/checks/support/supply_chain_lockfiles_misc.go index a87f0ba3..799251a6 100644 --- a/internal/codeguard/checks/support/supply_chain_lockfiles_misc.go +++ b/internal/codeguard/checks/support/supply_chain_lockfiles_misc.go @@ -6,9 +6,19 @@ import ( "strings" ) -var bunCoordinatePattern = regexp.MustCompile(`(?:@[-a-zA-Z0-9_.]+/)?[-a-zA-Z0-9_.]+@[0-9][0-9A-Za-z.+_-]*`) +var ( + bunCoordinatePattern = regexp.MustCompile(`(?:@[-a-zA-Z0-9_.]+/)?[-a-zA-Z0-9_.]+@[0-9][0-9A-Za-z.+_-]*`) + // packageBlockLockHeaderPattern matches the only lines Cargo, Poetry and uv + // write into a lockfile that pins nothing: the format version, uv's + // revision/requires-python, Poetry's [metadata] block, and the explicit + // empty `package = []`. + packageBlockLockHeaderPattern = regexp.MustCompile(`^(?:\[metadata\]|version\s*=\s*\d+|revision\s*=\s*\d+|lock-version\s*=|content-hash\s*=|requires-python\s*=|packages?\s*=\s*\[\s*\])`) +) -func parseGoSumState(data []byte) lockfileState { +// parseGoSumState reads go.sum. The format carries no header, so recognition +// rests on having read at least one `module version hash` line (or on the file +// being empty, which is a real go.sum for a dependency-free module). +func parseGoSumState(data []byte) (lockfileState, bool) { state := newLockfileState() for _, rawLine := range strings.Split(string(data), "\n") { fields := strings.Fields(strings.TrimSpace(rawLine)) @@ -16,14 +26,23 @@ func parseGoSumState(data []byte) lockfileState { addLockfilePackage(state, fields[0], strings.TrimSuffix(fields[1], "/go.mod")) } } - return state + return state, len(state.packages) > 0 || lockfileHasNoContent(data) } -func parsePackageBlockLockState(data []byte) lockfileState { +// parsePackageBlockLockState reads the TOML `[[package]]` table layout shared +// by Cargo.lock, poetry.lock and uv.lock. +// +// A `[[package]]` table is the structural marker that the file is one of these. +// A file with none is treated as empty — rather than unparsed — only when every +// substantive line in it is a known header key, which a truncated or +// conflict-marked lockfile would fail, so a real-but-unreadable lockfile cannot +// slip through as "empty" and be reported as total drift. +func parsePackageBlockLockState(data []byte) (lockfileState, bool) { state := newLockfileState() currentName := "" currentVersion := "" inPackage := false + sawPackageTable := false flush := func() { if currentName != "" { addLockfilePackage(state, currentName, currentVersion) @@ -37,6 +56,7 @@ func parsePackageBlockLockState(data []byte) lockfileState { case line == "[[package]]": flush() inPackage = true + sawPackageTable = true case inPackage && strings.HasPrefix(line, "name") && strings.Contains(line, "="): currentName = parseTOMLAssignmentValue(line) case inPackage && strings.HasPrefix(line, "version") && strings.Contains(line, "="): @@ -47,10 +67,30 @@ func parsePackageBlockLockState(data []byte) lockfileState { } } flush() - return state + return state, sawPackageTable || packageBlockLockHeaderOnly(data) +} + +// packageBlockLockHeaderOnly reports whether the file contains nothing but +// comments and the header keys these lockfile formats emit when they pin no +// packages, which makes it a genuinely empty lockfile rather than one this +// parser failed to read. +func packageBlockLockHeaderOnly(data []byte) bool { + for _, rawLine := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if !packageBlockLockHeaderPattern.MatchString(line) { + return false + } + } + return true } -func parseBunLockState(data []byte) lockfileState { +// parseBunLockState scrapes `name@version` coordinates out of bun.lock (JSONC, +// which is not valid JSON, hence the scrape). Having found a coordinate is the +// only evidence that the scrape worked. +func parseBunLockState(data []byte) (lockfileState, bool) { state := newLockfileState() seen := map[string]struct{}{} quoted := quotedStringPattern.FindAllStringSubmatch(string(data), -1) @@ -62,7 +102,7 @@ func parseBunLockState(data []byte) lockfileState { for _, token := range tokens { addBunCoordinate(state, seen, token) } - return state + return state, len(state.packages) > 0 || lockfileHasNoContent(data) } func addBunCoordinate(state lockfileState, seen map[string]struct{}, token string) { diff --git a/internal/codeguard/checks/support/supply_chain_lockfiles_node.go b/internal/codeguard/checks/support/supply_chain_lockfiles_node.go index 939b7173..2f01405a 100644 --- a/internal/codeguard/checks/support/supply_chain_lockfiles_node.go +++ b/internal/codeguard/checks/support/supply_chain_lockfiles_node.go @@ -8,9 +8,17 @@ import ( "gopkg.in/yaml.v3" ) +// yarnRegistryProtocol is the only Yarn Berry resolution protocol whose +// requirement is a public registry range. Anything else (workspace:, link:, +// portal:, file:, patch:, git:, exec:, …) resolves to a local path or a +// rewritten package, so its "requirement" is not a version range and must not +// be recorded as one. +const yarnRegistryProtocol = "npm" + func parsePackageLockState(data []byte) (lockfileState, bool) { var doc struct { - Packages map[string]struct { + LockfileVersion *int `json:"lockfileVersion"` + Packages map[string]struct { Version string `json:"version"` } `json:"packages"` Dependencies map[string]json.RawMessage `json:"dependencies"` @@ -18,6 +26,9 @@ func parsePackageLockState(data []byte) (lockfileState, bool) { if err := json.Unmarshal(data, &doc); err != nil { return lockfileState{}, false } + // An npm lockfile identifies itself structurally, so a freshly generated + // one with no dependencies is still recognised (and therefore compared). + recognized := doc.LockfileVersion != nil || doc.Packages != nil || doc.Dependencies != nil state := newLockfileState() for pkgPath, pkg := range doc.Packages { name := packageLockEntryName(pkgPath) @@ -33,39 +44,64 @@ func parsePackageLockState(data []byte) (lockfileState, bool) { addLockfilePackage(state, name, strings.TrimSpace(dep.Version)) } } - return state, true + return state, recognized || len(state.packages) > 0 } func parsePNPMLockState(data []byte) (lockfileState, bool) { var doc struct { - Packages map[string]any `yaml:"packages"` + LockfileVersion any `yaml:"lockfileVersion"` + Importers map[string]any `yaml:"importers"` + Packages map[string]any `yaml:"packages"` + Snapshots map[string]any `yaml:"snapshots"` } if err := yaml.Unmarshal(data, &doc); err != nil { return lockfileState{}, false } + recognized := doc.LockfileVersion != nil || doc.Importers != nil || doc.Packages != nil || doc.Snapshots != nil state := newLockfileState() - for key := range doc.Packages { - name, version := parsePNPMPackageKey(key) - if name != "" { - addLockfilePackage(state, name, version) + for _, keys := range []map[string]any{doc.Packages, doc.Snapshots} { + for key := range keys { + name, version := parsePNPMPackageKey(key) + if name != "" { + addLockfilePackage(state, name, version) + } } } - return state, true + return state, recognized || len(state.packages) > 0 } -func parseYarnLockState(data []byte) lockfileState { +// parseYarnLockState reads both Yarn v1 ("classic") and Yarn 2+ ("Berry") +// lockfiles. The two share a block layout — an unindented selector line ending +// in `:` followed by indented fields — but spell the version field +// differently: +// +// classic: version "2.6.0" +// berry: version: 2.6.0 +// +// Reading only the classic spelling parsed every Berry lockfile to zero +// packages, which surfaced as a fail-level drift finding for every dependency +// in the manifest. +// +// Recognition deliberately does not trust Berry's `__metadata:` header: yarn +// never writes a lockfile full of entries that yield no packages, so "zero +// packages from a non-empty file" means this parser failed, and treating the +// header as proof of an empty lockfile would let exactly that failure be +// reported as total drift again. +func parseYarnLockState(data []byte) (lockfileState, bool) { state := newLockfileState() - lines := strings.Split(string(data), "\n") currentSelectors := make([]string, 0) - for _, rawLine := range lines { + for _, rawLine := range strings.Split(string(data), "\n") { line := strings.TrimSpace(rawLine) switch { - case line == "": + case line == "" || strings.HasPrefix(line, "#"): currentSelectors = nil - case !strings.HasPrefix(rawLine, " ") && strings.HasSuffix(line, ":"): + case !strings.HasPrefix(rawLine, " ") && !strings.HasPrefix(rawLine, "\t") && strings.HasSuffix(line, ":"): currentSelectors = parseYarnSelectors(strings.TrimSuffix(line, ":")) - case len(currentSelectors) > 0 && strings.HasPrefix(line, "version "): - version := firstQuotedValue(line) + case len(currentSelectors) > 0: + version, ok := parseYarnVersionLine(line) + if !ok { + continue + } for _, selector := range currentSelectors { name, req := parseYarnSelector(selector) if name == "" { @@ -76,7 +112,21 @@ func parseYarnLockState(data []byte) lockfileState { } } } - return state + return state, len(state.packages) > 0 || lockfileHasNoContent(data) +} + +// parseYarnVersionLine extracts the resolved version from a lockfile entry's +// version field in either the classic or the Berry spelling. +func parseYarnVersionLine(line string) (string, bool) { + switch { + case strings.HasPrefix(line, "version:"): + value := strings.TrimSpace(strings.TrimPrefix(line, "version:")) + return strings.Trim(value, `"'`), true + case strings.HasPrefix(line, "version "): + return firstQuotedValue(line), true + default: + return "", false + } } func packageLockEntryName(entry string) string { @@ -90,6 +140,11 @@ func packageLockEntryName(entry string) string { return "" } +// parsePNPMPackageKey splits a pnpm `packages`/`snapshots` key into name and +// version. pnpm has used two shapes: `/name/1.2.3` (lockfile v5 and earlier, +// optionally with a `_peer-hash` suffix) and `name@1.2.3` (v6 and later, +// optionally with `(peer@1.0.0)` suffixes). Only the second was handled, so v5 +// lockfiles also parsed to zero packages. func parsePNPMPackageKey(key string) (string, string) { key = strings.TrimSpace(strings.TrimPrefix(key, "/")) if key == "" { @@ -98,6 +153,13 @@ func parsePNPMPackageKey(key string) (string, string) { if idx := strings.Index(key, "("); idx >= 0 { key = key[:idx] } + if slash := strings.LastIndex(key, "/"); slash > 0 && startsWithDigit(key[slash+1:]) { + version := key[slash+1:] + if under := strings.Index(version, "_"); under > 0 { + version = version[:under] + } + return key[:slash], version + } at := strings.LastIndex(key, "@") if at <= 0 { return "", "" @@ -117,14 +179,73 @@ func parseYarnSelectors(line string) []string { return out } +// parseYarnSelector splits a lockfile selector into the package name and the +// registry range it was requested with. +// +// The name ends at the first `@` that is not the leading one of a scope, which +// is correct for classic (`left-pad@^1.3.0`, `@babel/core@^7.0.0`) and for +// Berry's protocol form (`@linear/sdk@npm:^2.6.0`, +// `fsevents@patch:fsevents@npm%3A2.3.2#…`) alike; splitting on the last `@` +// mis-read both Berry protocols and classic npm aliases. +// +// The returned requirement is empty for any non-registry protocol: a +// workspace:, link:, portal:, file:, patch: or git: resolution is a local or +// rewritten package, not a registry range, and comparing a manifest range +// against it would be meaningless. The package is still recorded as present +// with its resolved version, so such a dependency is never reported as missing. func parseYarnSelector(selector string) (string, string) { selector = strings.TrimSpace(strings.Trim(selector, `"`)) - if selector == "" { - return "", "" - } - at := strings.LastIndex(selector, "@") + at := yarnSelectorNameEnd(selector) if at <= 0 { return "", "" } - return selector[:at], selector[at+1:] + name := selector[:at] + requirement := selector[at+1:] + protocol, value, ok := splitYarnProtocol(requirement) + if !ok { + return name, requirement + } + if protocol != yarnRegistryProtocol { + return name, "" + } + // `npm:@` is an alias; the range is the trailing part. + if alias := yarnSelectorNameEnd(value); alias > 0 { + value = value[alias+1:] + } + return name, value +} + +// yarnSelectorNameEnd returns the index of the `@` separating a selector's +// package name from its requirement, or -1 when there is none. A leading `@` +// belongs to the package scope and is skipped. +func yarnSelectorNameEnd(selector string) int { + if len(selector) < 2 { + return -1 + } + idx := strings.Index(selector[1:], "@") + if idx < 0 { + return -1 + } + return idx + 1 +} + +// splitYarnProtocol separates a Yarn Berry protocol prefix ("npm:^2.6.0" -> +// "npm", "^2.6.0") from the value it qualifies. A classic semver range has no +// protocol; a URL-shaped requirement reports its scheme as the protocol, which +// is correct — it is not a registry range either. +func splitYarnProtocol(requirement string) (string, string, bool) { + idx := strings.Index(requirement, ":") + if idx <= 0 { + return "", requirement, false + } + protocol := requirement[:idx] + if len(protocol) > 12 { + return "", requirement, false + } + for _, r := range protocol { + if (r < 'a' || r > 'z') && r != '+' && r != '-' && r != '.' { + return "", requirement, false + } + } + return protocol, requirement[idx+1:], true } diff --git a/tests/checks/supplychain_lockfile_parsing_test.go b/tests/checks/supplychain_lockfile_parsing_test.go new file mode 100644 index 00000000..12c946c1 --- /dev/null +++ b/tests/checks/supplychain_lockfile_parsing_test.go @@ -0,0 +1,388 @@ +package checks_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// yarnBerryLock is shaped like a real Yarn 2+ lockfile: a `__metadata` header, +// YAML-style `version:` fields, `npm:` protocol selectors, a `patch:` entry and +// the workspace root entry. +const yarnBerryLock = `# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@linear/sdk@npm:^2.6.0": + version: 2.6.0 + resolution: "@linear/sdk@npm:2.6.0" + dependencies: + "@linear/import": "npm:^1.0.0" + checksum: 10c0/deadbeef + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + languageName: node + linkType: hard + +"left-pad@npm:^1.3.0": + version: 1.3.0 + resolution: "left-pad@npm:1.3.0" + checksum: 10c0/cafebabe + languageName: node + linkType: hard + +"shared-utils@workspace:packages/utils": + version: 0.0.0-use.local + resolution: "shared-utils@workspace:packages/utils" + languageName: unknown + linkType: soft + +"typescript@npm:^5.5.4": + version: 5.5.4 + resolution: "typescript@npm:5.5.4" + languageName: node + linkType: hard + +"workspace-root@workspace:.": + version: 0.0.0-use.local + resolution: "workspace-root@workspace:." + languageName: unknown + linkType: soft +` + +// yarnClassicLock is shaped like a real Yarn v1 lockfile: quoted selectors with +// no protocol and `version "x"` fields. +const yarnClassicLock = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@linear/sdk@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@linear/sdk/-/sdk-2.6.0.tgz#deadbeef" + integrity sha512-deadbeef== + dependencies: + "@linear/import" "^1.0.0" + +left-pad@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#cafebabe" + integrity sha512-cafebabe== + +typescript@^5.5.4: + version "5.5.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#abc123" + integrity sha512-abc123== +` + +func TestSupplyChainLockfileParsing(t *testing.T) { + nodeManifest := `{ + "name": "frontend", + "dependencies": { + "@linear/sdk": "^2.6.0", + "left-pad": "^1.3.0" + }, + "devDependencies": { + "typescript": "^5.5.4" + } +}` + + pyprojectManifest := `[tool.poetry] +name = "backend" +version = "0.1.0" +license = "MIT" + +[tool.poetry.dependencies] +python = ">=3.10,<3.13" +Jinja2 = "^3.1.0" +Markdown = "^3.5.0" +typing_extensions = "^4.12.0" +zope.interface = "^6.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.0.0" +` + + // poetry.lock records PEP 503-normalised names; the manifest above keeps the + // author's mixed-case and underscored spellings. + poetryLock := `# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] + +[[package]] +name = "markdown" +version = "3.7" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.8" +groups = ["main"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main"] + +[[package]] +name = "zope-interface" +version = "6.4.post2" +description = "Interfaces for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] + +[[package]] +name = "pytest" +version = "8.3.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10,<3.13" +content-hash = "deadbeef" +` + + cases := []struct { + name string + files map[string]string + want []string + }{ + { + name: "yarn berry resolves every dependency", + files: map[string]string{ + "package.json": nodeManifest, + "yarn.lock": yarnBerryLock, + }, + want: nil, + }, + { + name: "yarn berry still reports a genuinely missing dependency", + files: map[string]string{ + "package.json": nodeManifest, + "yarn.lock": `__metadata: + version: 8 + +"@linear/sdk@npm:^2.6.0": + version: 2.6.0 + resolution: "@linear/sdk@npm:2.6.0" + +"typescript@npm:^5.5.4": + version: 5.5.4 + resolution: "typescript@npm:5.5.4" +`, + }, + want: []string{"dependency left-pad is not present in yarn.lock"}, + }, + { + name: "yarn classic resolves every dependency", + files: map[string]string{ + "package.json": nodeManifest, + "yarn.lock": yarnClassicLock, + }, + want: nil, + }, + { + name: "pnpm lockfile v9 resolves every dependency", + files: map[string]string{ + "package.json": nodeManifest, + "pnpm-lock.yaml": `lockfileVersion: '9.0' + +importers: + .: + dependencies: + '@linear/sdk': + specifier: ^2.6.0 + version: 2.6.0 + +packages: + '@linear/sdk@2.6.0': + resolution: {integrity: sha512-deadbeef==} + left-pad@1.3.0: + resolution: {integrity: sha512-cafebabe==} + typescript@5.5.4: + resolution: {integrity: sha512-abc123==} +`, + }, + want: nil, + }, + { + name: "pnpm lockfile v5 slash keys resolve every dependency", + files: map[string]string{ + "package.json": nodeManifest, + "pnpm-lock.yaml": `lockfileVersion: 5.4 + +specifiers: + '@linear/sdk': ^2.6.0 + +packages: + + /@linear/sdk/2.6.0: + resolution: {integrity: sha512-deadbeef==} + dev: false + + /left-pad/1.3.0_react@18.2.0: + resolution: {integrity: sha512-cafebabe==} + dev: false + + /typescript/5.5.4: + resolution: {integrity: sha512-abc123==} + dev: true +`, + }, + want: nil, + }, + { + name: "package-lock v3 resolves every dependency", + files: map[string]string{ + "package.json": nodeManifest, + "package-lock.json": `{ + "name": "frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": {"name": "frontend"}, + "node_modules/@linear/sdk": {"version": "2.6.0"}, + "node_modules/left-pad": {"version": "1.3.0"}, + "node_modules/typescript": {"version": "5.5.4", "dev": true} + } +}`, + }, + want: nil, + }, + { + name: "poetry lock matches PEP 503 normalised names", + files: map[string]string{ + "pyproject.toml": pyprojectManifest, + "poetry.lock": poetryLock, + }, + want: nil, + }, + { + name: "poetry lock still reports a genuinely missing dependency", + files: map[string]string{ + "pyproject.toml": pyprojectManifest, + "poetry.lock": `# This file is automatically @generated by Poetry and should not be changed by hand. + +[[package]] +name = "jinja2" +version = "3.1.4" + +[[package]] +name = "typing-extensions" +version = "4.12.2" + +[[package]] +name = "zope-interface" +version = "6.4.post2" + +[[package]] +name = "pytest" +version = "8.3.3" + +[metadata] +lock-version = "2.1" +`, + }, + want: []string{"dependency Markdown is not present in poetry.lock"}, + }, + { + name: "npm names are not normalised across separators or case", + files: map[string]string{ + "package.json": `{"dependencies": {"Left_Pad": "^1.3.0"}}`, + "package-lock.json": `{ + "lockfileVersion": 3, + "packages": { + "": {"name": "frontend"}, + "node_modules/left-pad": {"version": "1.3.0"} + } +}`, + }, + want: []string{"dependency Left_Pad is not present in package-lock.json"}, + }, + { + name: "unparseable yarn lockfile reports nothing", + files: map[string]string{ + "package.json": nodeManifest, + "yarn.lock": `<<<<<<< HEAD +this file was mangled by a bad merge and is not a lockfile +======= +neither is this half +>>>>>>> feature +`, + }, + want: nil, + }, + { + name: "unparseable poetry lockfile reports nothing", + files: map[string]string{ + "pyproject.toml": pyprojectManifest, + "poetry.lock": `this poetry.lock was truncated during a rebase +and holds no package tables at all +`, + }, + want: nil, + }, + { + name: "empty poetry lockfile is compared rather than skipped", + files: map[string]string{ + "pyproject.toml": `[tool.poetry] +name = "backend" + +[tool.poetry.dependencies] +Jinja2 = "^3.1.0" +`, + "poetry.lock": `# This file is automatically @generated by Poetry and should not be changed by hand. +package = [] + +[metadata] +lock-version = "2.1" +content-hash = "deadbeef" +`, + }, + want: []string{"dependency Jinja2 is not present in poetry.lock"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + for name, content := range tc.files { + writeFile(t, filepath.Join(dir, name), content) + } + + report, err := codeguard.Run(context.Background(), supplyChainTestConfig(dir, "lockfile-parsing")) + if err != nil { + t.Fatalf("run: %v", err) + } + + got := supplyChainRuleMessages(report, "supply_chain.lockfile-drift") + if len(got) != len(tc.want) { + t.Fatalf("lockfile-drift findings = %v, want %v", got, tc.want) + } + for idx, want := range tc.want { + if got[idx] != want { + t.Fatalf("lockfile-drift finding %d = %q, want %q", idx, got[idx], want) + } + } + }) + } +} From e8f519c49217dd4b11ceb192e51a7de66731acc3 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 17 Sep 2026 14:50:38 -0400 Subject: [PATCH 02/16] fix(scan): widen default exclusions and guard every line scanner against binary content Two false-positive sources, both in the shared scan plumbing. 1. Default exclusions were far too narrow. The built-in list was five globs anchored at the target root (`.git/**`, `.gocache/**`, `.gomodcache/**`, `.codeguard/**`, `dist/**`) plus three segment-matched names. Two defects followed: nested build output was scanned (`dist/app.js` was excluded but `services/app/dist/app.js` was not, so every monorepo analyzed its own output), and the common generated trees were not excluded at all. Measured on real repositories: 97% of findings on one Next.js monorepo and 78% on another came from `.next/`, and a Go repo with a `.build/go-cache` tree produced 566 security findings against compiled build-cache objects. The generated/dependency/cache names now live in `isDefaultDependencyOrBuildPath`, so they are segment-matched and depth-independent, and minified bundles plus source maps are skipped by file suffix. Over-exclusion is treated as the equal risk, because a silently skipped directory is reported as a pass. Names that are sometimes real source (`build`, `out`, `bin`, `obj`, `target`, `coverage`, `_build`) are NOT excluded on the name alone: `walkEntryDecision` prunes one only when a sibling manifest corroborates it (`Cargo.toml`/`pom.xml` beside `target/`, a `.csproj` beside `bin/`+`obj/`, `CMakeLists.txt`/`build.gradle` beside `build/`). A Go package named `build`, a Rust crate's `src/bin`, and coverage.py's own `coverage/` source package therefore stay scanned. The unconditional names get an escape hatch: an `exclude` entry prefixed with `!` re-includes matching paths and wins over the built-in defaults. Directory pruning is suppressed when a re-include could match inside the directory, otherwise the negation would be unreachable. 2. `looksBinary` guarded only the secrets pass (one caller). Every other repository-wide detector - weak hash, weak cipher, and the per-language shell-execution, dynamic-code and unsafe-HTML-sink patterns - regexed compiled binaries and reported findings at offsets inside object code. The heuristic moves to `runnersupport.LooksBinary` and is applied in `scanTargetFiles`, the shared per-file findings path, so no detector can opt out by accident. It is deliberately NOT pushed into `corpusRead` or `VisitTargetFiles`: several passes use `VisitTargetFiles` purely to enumerate repository paths, and supply-chain/CI passes legitimately read non-source text (lockfiles, Dockerfiles, YAML, Markdown), so a guard at the read layer would quietly shrink those inventories. Adds `tests/support/runner_support_exclusions_test.go` (table test over `ShouldExclude` covering both the new exclusions and the must-not-exclude cases, plus walk-level tests for the corroborated build dirs, the `!` re-include, and `LooksBinary`) and `tests/security/binary_scan_guard_test.go` (a binary-content file produces no line findings while the identical text still does). Co-Authored-By: Claude Opus 5 (1M context) --- docs/checks.md | 36 ++ .../security/security_secret_entropy.go | 12 - .../checks/security/security_secrets.go | 8 +- .../codeguard/runner/support/binary_guard.go | 34 ++ .../codeguard/runner/support/findings_scan.go | 8 + internal/codeguard/runner/support/utils.go | 69 +++- .../runner/support/utils_build_output.go | 112 ++++++ .../codeguard/runner/support/utils_match.go | 238 +++++++++++- tests/security/binary_scan_guard_test.go | 85 +++++ .../support/runner_support_exclusions_test.go | 354 ++++++++++++++++++ 10 files changed, 930 insertions(+), 26 deletions(-) create mode 100644 internal/codeguard/runner/support/binary_guard.go create mode 100644 internal/codeguard/runner/support/utils_build_output.go create mode 100644 tests/security/binary_scan_guard_test.go create mode 100644 tests/support/runner_support_exclusions_test.go diff --git a/docs/checks.md b/docs/checks.md index 4361a1a8..127d977d 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -245,6 +245,42 @@ Config keys: Current behavior: - excluded paths are not scanned by any check family +- an entry prefixed with `!` is a **re-include**: it cancels exclusion for + matching paths, including the built-in defaults below + (`"exclude": ["!packages/ui/dist/**"]`) + +### Built-in default exclusions + +CodeGuard always skips dependency trees, tool caches, and generated build +output. These are matched by path *segment*, so they apply at any depth (a +monorepo's `services/*/dist` is skipped, not just a root-level `dist`): + +- version control and CodeGuard state: `.git`, `.hg`, `.svn`, `.codeguard` +- dependencies: `node_modules`, `bower_components`, `site-packages`, `venv`, + `.venv`, `.pub-cache`, `Pods`, `Carthage`, and `vendor` (see + `scan_vendored_source` to opt vendored source back in) +- framework/bundler output: `dist`, `.next`, `.nuxt`, `.svelte-kit`, `.angular`, + `.astro`, `.docusaurus`, `.output`, `.vercel`, `.netlify`, `cdk.out` +- caches: `.cache`, `.gocache`, `.gomodcache`, `.turbo`, `.parcel-cache`, + `.vite`, `.webpack`, `.gradle`, `.terraform`, `.build`, `.dart_tool`, + `.sass-cache`, `.mypy_cache`, `.pytest_cache`, `.ruff_cache`, `.tox`, `.nox`, + `.eggs`, `.ipynb_checkpoints` +- generated artifacts: `__pycache__`, `*.egg-info`, `htmlcov`, `.nyc_output`, + and minified bundles/source maps (`*.min.js`, `*.min.css`, `*.bundle.js`, + `*.js.map`, `*.css.map`) +- binary files (detected by content, not name) are skipped by every per-file + line/regex detector + +Directory names that are build output in most repositories but real source in +some — `build`, `out`, `bin`, `obj`, `target`, `coverage`, `_build` — are **not** +excluded on the name alone. They are skipped only when a sibling manifest proves +the directory is that build system's output (for example `target/` next to +`Cargo.toml` or `pom.xml`, `bin/` and `obj/` next to a `.csproj`, `build/` next +to `CMakeLists.txt` or `build.gradle`). A Go package named `build`, a Rust crate's +`src/bin`, or a Python `coverage` source package therefore stays scanned. + +Use a `!` re-include if one of the unconditional defaults hides real source in +your repository. ## Waivers diff --git a/internal/codeguard/checks/security/security_secret_entropy.go b/internal/codeguard/checks/security/security_secret_entropy.go index ca6b1c44..ac5861ea 100644 --- a/internal/codeguard/checks/security/security_secret_entropy.go +++ b/internal/codeguard/checks/security/security_secret_entropy.go @@ -1,7 +1,6 @@ package security import ( - "bytes" "math" "strings" @@ -125,17 +124,6 @@ func maskSecret(value string) string { return string(runes[:4]) + "…" + string(runes[len(runes)-4:]) } -// looksBinary reports whether data appears to be binary (contains a NUL byte in -// its leading window). Binary files are skipped: scanning them wastes time and -// produces noise rather than real credential findings. -func looksBinary(data []byte) bool { - limit := len(data) - if limit > binarySniffBytes { - limit = binarySniffBytes - } - return bytes.IndexByte(data[:limit], 0) >= 0 -} - // shannonEntropy returns the Shannon entropy of s in bits per character. ASCII // bytes are counted in a fixed array to avoid a per-call map allocation; the // rare non-ASCII rune falls back to a map. diff --git a/internal/codeguard/checks/security/security_secrets.go b/internal/codeguard/checks/security/security_secrets.go index ec5b5693..7b983901 100644 --- a/internal/codeguard/checks/security/security_secrets.go +++ b/internal/codeguard/checks/security/security_secrets.go @@ -6,6 +6,7 @@ import ( "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" + runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" ) var concatenatedLiteralPattern = regexp.MustCompile(`(["'])([^"']*)["']\s*\+\s*["']([^"']*)(["'])`) @@ -17,7 +18,6 @@ var concatenatedLiteralPattern = regexp.MustCompile(`(["'])([^"']*)["']\s*\+\s*[ const ( maxScanFileBytes = 5 << 20 // skip files larger than 5 MiB maxScanLineBytes = 64 << 10 // scan at most the first 64 KiB of any line - binarySniffBytes = 8 << 10 // bytes inspected when detecting binary content ) // ScanContent runs the secret/credential scan over file content and returns the @@ -102,7 +102,11 @@ func located(m *Match, lineNo int) []Match { } func secretResultsForFile(env support.Context, file string, data []byte, scanner Scanner) ([]core.Finding, []core.Diagnostic) { - if scanner.SkipPath(file) || len(data) > maxScanFileBytes || looksBinary(data) { + // The binary check is retained here as well as in the shared scan path + // (runnersupport.LooksBinary, applied in ScanTargetFiles) because + // secretResultsForFile is reachable from callers that do not go through the + // corpus scan; the shared helper is the single definition of the heuristic. + if scanner.SkipPath(file) || len(data) > maxScanFileBytes || runnersupport.LooksBinary(data) { return nil, nil } content := string(data) diff --git a/internal/codeguard/runner/support/binary_guard.go b/internal/codeguard/runner/support/binary_guard.go new file mode 100644 index 00000000..bbb94ef9 --- /dev/null +++ b/internal/codeguard/runner/support/binary_guard.go @@ -0,0 +1,34 @@ +package support + +import "bytes" + +// binarySniffBytes is the leading window inspected when classifying content as +// binary. A NUL byte in the first 8 KiB is the classic heuristic (it is what +// git, grep and diff all use) and it is cheap enough to run on every file. +const binarySniffBytes = 8 << 10 + +// LooksBinary reports whether data appears to be object code or another +// non-text blob rather than source. +// +// This guard used to live only inside the secrets pass, which meant every other +// repository-wide line detector - weak hash, weak cipher, and the per-language +// shell-execution, dynamic-code and unsafe-HTML-sink patterns - happily +// regex-matched compiled binaries and reported findings at "line numbers" +// inside object code (a real report pointed at line 7321 of a linked +// executable). It is applied in scanTargetFiles instead, so a detector cannot +// opt out of it by accident. +// +// It is deliberately NOT applied in VisitTargetFiles or corpusRead. Several +// passes use VisitTargetFiles purely to enumerate repository paths (see +// quality_operations.go and quality_go_toolchain_dead_code.go), and +// supply-chain/CI passes legitimately read non-source text such as lockfiles, +// Dockerfiles, YAML and Markdown; filtering at the read layer would quietly +// shrink those inventories. Findings-producing per-file scans are the only +// place the classification is unambiguously correct. +func LooksBinary(data []byte) bool { + limit := len(data) + if limit > binarySniffBytes { + limit = binarySniffBytes + } + return bytes.IndexByte(data[:limit], 0) >= 0 +} diff --git a/internal/codeguard/runner/support/findings_scan.go b/internal/codeguard/runner/support/findings_scan.go index 62affa9d..2929afc6 100644 --- a/internal/codeguard/runner/support/findings_scan.go +++ b/internal/codeguard/runner/support/findings_scan.go @@ -49,6 +49,14 @@ func scanTargetFiles(sc Context, target core.TargetConfig, spec fileScanSpec) [] if err != nil { return nil } + // Binary content produces only noise: a line/regex detector run over + // object code reports findings at offsets inside a compiled artifact. + // Guarding here rather than per detector means no findings-producing + // pass can skip the check. See LooksBinary for why this is not pushed + // down to the corpus read or to VisitTargetFiles. + if LooksBinary(data) { + return nil + } return cachedFileFindings(sc, fileScanInput{ sectionID: spec.sectionID, target: target, diff --git a/internal/codeguard/runner/support/utils.go b/internal/codeguard/runner/support/utils.go index 13426c67..dd1978f7 100644 --- a/internal/codeguard/runner/support/utils.go +++ b/internal/codeguard/runner/support/utils.go @@ -58,6 +58,12 @@ func walkFilesBounded(root string, excludes []string, opts FileWalkOptions, incl return err } if path == root { + // The dependency/build policy is applied to the target's logical + // (repository-relative) root as well, so a target pointed straight + // at a dependency or output tree is skipped rather than scanned + // wholesale. shouldExclude already honours a "!" re-include, which + // is the escape hatch for a repository that really does keep source + // under one of those names. logicalRoot := filepath.ToSlash(filepath.Clean(opts.LogicalPath)) if logicalRoot != "." && shouldExclude(logicalRoot, excludes, opts) { return fs.SkipAll @@ -70,12 +76,12 @@ func walkFilesBounded(root string, excludes []string, opts FileWalkOptions, incl } rel = filepath.ToSlash(rel) logicalRel := joinLogicalPath(opts.LogicalPath, rel) - if shouldExclude(rel, excludes, opts) || - (logicalRel != rel && shouldExclude(logicalRel, excludes, opts)) { - if d.IsDir() { - return filepath.SkipDir - } + switch walkEntryDecision(path, rel, logicalRel, d.IsDir(), excludes, opts) { + case walkSkipDir: + return filepath.SkipDir + case walkSkipFile: return nil + case walkInclude: } if d.IsDir() { return nil @@ -100,6 +106,59 @@ func walkFilesBounded(root string, excludes []string, opts FileWalkOptions, incl return files, truncated, err } +// walkDecision is the exclusion verdict for one walked entry. +type walkDecision int + +const ( + walkInclude walkDecision = iota + walkSkipFile + walkSkipDir +) + +// walkEntryDecision decides whether a walked entry is scanned, skipped, or +// pruned along with its whole subtree. +// +// rel is the path relative to the directory being walked; logicalRel is the +// same file's repository-relative path when the target root is a subdirectory. +// The built-in default exclusions are matched against rel ONLY, never against +// logicalRel: matching them against the repository path would let an ancestor +// of the target root poison everything beneath it, so a target configured at +// services/app/dist would find nothing at all. (An excluded ancestor is still +// honoured - walkFilesBounded checks the logical root separately and skips the +// walk outright.) User-configured excludes are matched against both forms, +// since they may be written against either. +func walkEntryDecision(path string, rel string, logicalRel string, isDir bool, excludes []string, opts FileWalkOptions) walkDecision { + if matchesReinclude(rel, excludes) || matchesReinclude(logicalRel, excludes) { + return walkInclude + } + if isDefaultDependencyOrBuildPath(rel, opts.ScanVendoredSource) || + matchesUserExclude(rel, excludes) || + (logicalRel != rel && matchesUserExclude(logicalRel, excludes)) { + if isDir { + // A directory is normally pruned wholesale, which is what keeps the + // exclusions cheap. It must not be pruned when a re-include could + // match inside it: "!packages/ui/dist/**" would never be reached if + // the walk skipped packages/ui/dist first. + if reincludeCoversSubtree(rel, logicalRel, excludes) { + return walkInclude + } + return walkSkipDir + } + return walkSkipFile + } + // Ambiguous build-output names (build, out, bin, obj, target, coverage) are + // pruned only when a sibling manifest corroborates them; see + // utils_build_output.go for why a name match alone is not enough. + if isDir && isCorroboratedBuildOutputDir(path) && !reincludeCoversSubtree(rel, logicalRel, excludes) { + return walkSkipDir + } + return walkInclude +} + +func reincludeCoversSubtree(rel string, logicalRel string, excludes []string) bool { + return reincludeMayCoverSubtree(rel, excludes) || reincludeMayCoverSubtree(logicalRel, excludes) +} + // CountLines reports how many lines data spans without allocating. It // preserves the exact semantics of the previous // strings.Split(strings.TrimRight(...))-based implementation: all trailing diff --git a/internal/codeguard/runner/support/utils_build_output.go b/internal/codeguard/runner/support/utils_build_output.go new file mode 100644 index 00000000..bce86423 --- /dev/null +++ b/internal/codeguard/runner/support/utils_build_output.go @@ -0,0 +1,112 @@ +package support + +import ( + "os" + "path/filepath" + "strings" +) + +// buildOutputDirMarkers maps an *ambiguous* directory name to the sibling files +// that prove the directory is a build system's output directory rather than +// hand-written source. +// +// Why a corroborating signal instead of a plain name match (which is what +// unambiguousGeneratedDirs in utils_match.go does): every name here is real +// source somewhere. A Go package can be called build; a Rust binary crate lives +// in src/bin; plenty of repositories keep their build tooling in build/ and +// their application in out/. Silently dropping those from the scan would turn a +// false positive problem into a false *negative* problem, which for a security +// scanner is strictly worse. +// +// The markers are chosen so the directory name is that build system's output +// convention, not merely "a project of this kind exists here": +// +// - target cargo, maven and sbt all emit into ./target +// - build out-of-source cmake, gradle, meson, setuptools and dune emit +// into ./build. Note package.json is deliberately NOT a marker: +// a JS repo with a build/ directory very often keeps build +// *scripts* there (build/webpack.config.js), and we would rather +// scan create-react-app output than hide those. +// - out next export and tsc outDir conventions; also MSBuild +// - bin/obj MSBuild emits both beside the project file. A Go or Rust repo has +// no project file, so src/bin stays scanned. +// - coverage jest/nyc, cargo-llvm-cov and Go coverage tooling default to +// ./coverage. Python markers are deliberately absent: coverage.py's +// own repository has a coverage/ *source* package next to its +// pyproject.toml, which is exactly the trap this table avoids. +// +// A leading "*" makes the marker a suffix match against the sibling directory's +// entries (used for MSBuild project files, whose stem is the project name). +var buildOutputDirMarkers = map[string][]string{ + "target": {"Cargo.toml", "pom.xml", "build.sbt"}, + "build": { + "CMakeLists.txt", + "build.gradle", + "build.gradle.kts", + "gradlew", + "settings.gradle", + "settings.gradle.kts", + "meson.build", + "setup.py", + "pyproject.toml", + "dune-project", + }, + "_build": {"dune-project", "mix.exs"}, + "out": {"next.config.js", "next.config.mjs", "next.config.ts", "tsconfig.json", "*.sln", "*.csproj"}, + "bin": {"*.csproj", "*.fsproj", "*.vbproj", "*.sln"}, + "obj": {"*.csproj", "*.fsproj", "*.vbproj", "*.sln"}, + "coverage": {"package.json", "Cargo.toml", "go.mod"}, +} + +// isCorroboratedBuildOutputDir reports whether dir - whose base name is one of +// the ambiguous names in buildOutputDirMarkers - is really build output, judged +// by the manifest files sitting beside it. +// +// This lives in the directory walk rather than in ShouldExclude because the +// decision needs the filesystem, and ShouldExclude is a pure path predicate +// used for path-only filtering. Consequence to be aware of: these names are +// skipped during a repository walk but are not considered excluded by +// ShouldExclude, so a path-only caller (diff-mode filtering) still sees them. +// That asymmetry errs toward scanning, which is the safe direction. +func isCorroboratedBuildOutputDir(dir string) bool { + markers, ok := buildOutputDirMarkers[filepath.Base(dir)] + if !ok { + return false + } + parent := filepath.Dir(dir) + var suffixes []string + for _, marker := range markers { + if suffix, isSuffix := strings.CutPrefix(marker, "*"); isSuffix { + suffixes = append(suffixes, suffix) + continue + } + if info, err := os.Stat(filepath.Join(parent, marker)); err == nil && !info.IsDir() { + return true + } + } + return len(suffixes) > 0 && dirHasEntryWithSuffix(parent, suffixes) +} + +// dirHasEntryWithSuffix reports whether dir directly contains a file whose name +// ends in any of the given suffixes. The result is intentionally not memoized: +// codeguard also runs as a long-lived MCP server, where a cached directory +// listing could serve a stale answer across scans, and the read is cheap next +// to reading and regexing every file the directory contains. +func dirHasEntryWithSuffix(dir string, suffixes []string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + for _, suffix := range suffixes { + if len(name) > len(suffix) && strings.HasSuffix(name, suffix) { + return true + } + } + } + return false +} diff --git a/internal/codeguard/runner/support/utils_match.go b/internal/codeguard/runner/support/utils_match.go index 41b9dbbf..969dbc0f 100644 --- a/internal/codeguard/runner/support/utils_match.go +++ b/internal/codeguard/runner/support/utils_match.go @@ -31,16 +31,33 @@ func IsSDKFacadeFile(path string) bool { return strings.HasPrefix(normalized, "pkg/codeguard/") } +// ShouldExclude reports whether a target-root-relative path is skipped by the +// built-in defaults or by the user's exclude list. func ShouldExclude(rel string, excludes []string) bool { return shouldExclude(rel, excludes, FileWalkOptions{}) } func shouldExclude(rel string, excludes []string, opts FileWalkOptions) bool { + if matchesReinclude(rel, excludes) { + return false + } if isDefaultDependencyOrBuildPath(rel, opts.ScanVendoredSource) { return true } - defaults := []string{".git/**", ".gocache/**", ".gomodcache/**", ".codeguard/**", "dist/**"} - for _, pattern := range append(defaults, excludes...) { + return matchesUserExclude(rel, excludes) +} + +// matchesUserExclude reports whether rel matches any (non-negated) configured +// exclude glob. It deliberately ignores the built-in defaults so that the walk +// can apply the two sets at different scopes: the defaults are meaningful only +// relative to the directory actually being scanned, whereas user globs are +// written against both the target-relative and the repository-relative form of +// a path. See walkFilesBounded. +func matchesUserExclude(rel string, excludes []string) bool { + for _, pattern := range excludes { + if strings.HasPrefix(strings.TrimSpace(pattern), "!") { + continue + } if MatchPattern(pattern, rel) { return true } @@ -48,13 +65,220 @@ func shouldExclude(rel string, excludes []string, opts FileWalkOptions) bool { return false } +// matchesReinclude reports whether rel matches a negated exclude entry +// ("!some/glob/**"). +// +// This is the escape hatch for the built-in default exclusions below. Those +// defaults are not user-editable, so a repository whose real source happens to +// live under one of the excluded names would otherwise have no way to get it +// scanned. A re-include wins over everything, defaults included: it is an +// explicit statement by the repository owner, which is the only signal strong +// enough to override a name-based heuristic. +func matchesReinclude(rel string, excludes []string) bool { + for _, pattern := range excludes { + trimmed := strings.TrimSpace(pattern) + if !strings.HasPrefix(trimmed, "!") { + continue + } + if MatchPattern(strings.TrimPrefix(trimmed, "!"), rel) { + return true + } + } + return false +} + +// reincludeMayCoverSubtree reports whether any "!" exclude entry could match a +// path beneath dir. +// +// The walk prunes an excluded directory wholesale, which is what keeps the +// exclusions cheap, but that would make a re-include of +// "!packages/ui/dist/**" unreachable: packages/ui/dist is skipped before the +// walk ever descends. This is the same problem gitignore negation has. The test +// is intentionally loose - it only has to decide whether descending is +// worthwhile; each file inside is still matched individually. +func reincludeMayCoverSubtree(dir string, excludes []string) bool { + prefix := filepath.ToSlash(strings.TrimSpace(dir)) + if prefix == "" || prefix == "." { + return false + } + prefix += "/" + for _, pattern := range excludes { + trimmed := strings.TrimSpace(pattern) + if !strings.HasPrefix(trimmed, "!") { + continue + } + glob := filepath.ToSlash(strings.TrimPrefix(trimmed, "!")) + // The pattern reaches into this directory ... + if strings.HasPrefix(glob, prefix) { + return true + } + // ... or its literal prefix stops at or above this directory, in which + // case its wildcards may still match inside. + if static := staticGlobPrefix(glob); static == "" || strings.HasPrefix(prefix, static) { + return true + } + } + return false +} + +// staticGlobPrefix returns the leading directory portion of glob that contains +// no wildcard, or "" when the glob starts with one. +func staticGlobPrefix(glob string) string { + wildcard := strings.IndexAny(glob, "*?[") + if wildcard < 0 { + return glob + } + if cut := strings.LastIndex(glob[:wildcard], "/"); cut >= 0 { + return glob[:cut+1] + } + return "" +} + +// Built-in scan exclusions are deliberately split by how certain the name is, +// because over-excluding is as harmful as under-excluding: a directory skipped +// by name disappears from the scan with no signal to the user, so a real +// vulnerability in it is reported as a pass. +// +// 1. unambiguousGeneratedDirs - directory names that are never hand-written +// source in any ecosystem we know of (dependency trees, tool caches, +// framework build output). Matched by path *segment*, so they are skipped at +// any depth. Depth independence is the point: these used to be anchored +// globs like "dist/**", which matched dist/app.js but not +// services/app/dist/app.js, so every monorepo scanned its own build output. +// 2. generatedFileSuffixes - individual generated files (minified bundles and +// source maps). They are text, so the binary guard does not catch them, yet +// they are machine-generated megabyte blobs whose findings are never +// actionable. +// 3. buildOutputDirMarkers (utils_build_output.go) - names that are build +// output in most repositories but legitimate source in some: build, out, +// bin, obj, target, coverage. These are NOT excluded on the name alone. +// A sibling manifest must corroborate that the directory is that build +// system's output directory. +// +// "vendor" keeps its existing opt-out (config scan_vendored_source) because +// vendored *source* is real code that some repositories do want reviewed. +var unambiguousGeneratedDirs = map[string]struct{}{ + // Version control metadata and codeguard's own state directory. + ".git": {}, + ".hg": {}, + ".svn": {}, + ".codeguard": {}, + + // Dependency trees. Nothing here is written by the repository. + "node_modules": {}, + "bower_components": {}, + "site-packages": {}, + ".venv": {}, + "venv": {}, + ".pub-cache": {}, + "Pods": {}, + "Carthage": {}, + + // JS/TS framework, bundler and deploy output. ".next" alone accounted for + // 97% of the findings on one real Next.js monorepo and 78% on another. + ".next": {}, + ".nuxt": {}, + ".svelte-kit": {}, + ".angular": {}, + ".astro": {}, + ".docusaurus": {}, + ".output": {}, + ".vercel": {}, + ".netlify": {}, + ".turbo": {}, + ".parcel-cache": {}, + ".vite": {}, + ".webpack": {}, + ".nyc_output": {}, + + // Tool caches. ".cache" is the common name that ".gocache"/".gomodcache" + // were standing in for: a Go repo with a .build/go-cache tree produced 566 + // security findings against compiled build-cache objects. + ".cache": {}, + ".gocache": {}, + ".gomodcache": {}, + ".mypy_cache": {}, + ".pytest_cache": {}, + ".ruff_cache": {}, + ".tox": {}, + ".nox": {}, + ".gradle": {}, + ".terraform": {}, + ".build": {}, // SwiftPM, and the go-cache tree noted above + ".dart_tool": {}, + ".sass-cache": {}, + ".eggs": {}, + ".ipynb_checkpoints": {}, + + // Report output whose directory name has no other meaning. + "htmlcov": {}, + + // Infrastructure synth output. + "cdk.out": {}, + + // Python bytecode. + "__pycache__": {}, + + // "dist" is treated as unconditional rather than corroborated: it was + // already a built-in root-anchored default, so the project has already + // accepted the name as build output, and a *source* directory named dist is + // essentially unheard of. Making it depth-independent is the single + // highest-impact fix here. A repository that really does keep source in a + // dist/ directory re-includes it with exclude: ["!path/to/dist/**"]. + "dist": {}, +} + +// unambiguousGeneratedDirSuffixes are directory-name suffixes (rather than +// exact names) that identify generated trees. +var unambiguousGeneratedDirSuffixes = []string{ + ".egg-info", // setuptools metadata +} + +// generatedFileSuffixes are file names produced by a bundler or compiler. Only +// compound suffixes are listed: a bare ".map" could plausibly be a data file, +// whereas ".js.map" is a source map and nothing else. +var generatedFileSuffixes = []string{ + ".min.js", + ".min.mjs", + ".min.cjs", + ".min.css", + ".bundle.js", + ".bundle.mjs", + ".bundle.css", + ".js.map", + ".mjs.map", + ".cjs.map", + ".css.map", + ".ts.map", +} + func isDefaultDependencyOrBuildPath(rel string, scanVendoredSource bool) bool { - for _, segment := range strings.Split(filepath.ToSlash(rel), "/") { - switch segment { - case "node_modules", "cdk.out": + normalized := filepath.ToSlash(rel) + for _, segment := range strings.Split(normalized, "/") { + if _, generated := unambiguousGeneratedDirs[segment]; generated { + return true + } + for _, suffix := range unambiguousGeneratedDirSuffixes { + if len(segment) > len(suffix) && strings.HasSuffix(segment, suffix) { + return true + } + } + if segment == "vendor" && !scanVendoredSource { + return true + } + } + return isGeneratedFileName(normalized) +} + +func isGeneratedFileName(normalized string) bool { + base := normalized + if idx := strings.LastIndex(base, "/"); idx >= 0 { + base = base[idx+1:] + } + lower := strings.ToLower(base) + for _, suffix := range generatedFileSuffixes { + if len(lower) > len(suffix) && strings.HasSuffix(lower, suffix) { return true - case "vendor": - return !scanVendoredSource } } return false diff --git a/tests/security/binary_scan_guard_test.go b/tests/security/binary_scan_guard_test.go new file mode 100644 index 00000000..a2abeead --- /dev/null +++ b/tests/security/binary_scan_guard_test.go @@ -0,0 +1,85 @@ +package security_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// TestScanSkipsBinaryContentForEveryLineDetector locks in the hoisted binary +// guard. +// +// The NUL-byte check used to live inside the secrets pass and had exactly one +// caller, so every other repository-wide line detector - weak hash, weak +// cipher, and the per-language shell-execution, dynamic-code and +// unsafe-HTML-sink patterns - happily regex-matched compiled binaries and +// reported findings at "line numbers" inside object code (a real report pointed +// at line 7321 of a linked executable). The guard now runs in the shared +// per-file scan path, so no detector can opt out of it. +// +// The identical content in a text file must still be reported; otherwise this +// test would pass just as well with detection switched off. +func TestScanSkipsBinaryContentForEveryLineDetector(t *testing.T) { + // Content that trips several independent line detectors at once: a weak + // hash construction, a weak cipher, and an AWS-shaped credential. + detectable := "package app\n" + + "\n" + + "import (\n" + + "\t\"crypto/des\"\n" + + "\t\"crypto/md5\"\n" + + ")\n" + + "\n" + + "const accessKey = \"AKIA" + "1234567890ABCDEF" + "\"\n" + + "\n" + + "func Digest(data []byte) []byte {\n" + + "\th := md5.New()\n" + + "\th.Write(data)\n" + + "\tblock, _ := des.NewCipher(data)\n" + + "\t_ = block\n" + + "\treturn h.Sum(nil)\n" + + "}\n" + + root := t.TempDir() + write := func(rel string, content []byte) { + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + + // The text control. + write("app/hash.go", []byte(detectable)) + // The same bytes with a NUL in the leading window, which is what an object + // file or a linked executable in a build-cache tree looks like. + write("app/compiled.o", append([]byte("\x7fELF\x02\x01\x01\x00"), detectable...)) + + cfg := codeguard.ExampleConfig() + cfg.Name = "binary-scan-guard" + cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: root, Language: "go"}} + cfg.Checks.Security = true + cfg.Checks.SecurityRules.GovulncheckMode = "off" + cacheDisabled := false + cfg.Cache.Enabled = &cacheDisabled + + report, err := codeguard.RunWithOptions(context.Background(), cfg, codeguard.ScanOptions{Mode: codeguard.ScanModeFull}) + if err != nil { + t.Fatalf("full scan: %v", err) + } + + if !reportHasFindingPath(report, "app/hash.go") { + t.Fatal("no findings on the text control file; the detectors under test did not fire at all") + } + for _, section := range report.Sections { + for _, finding := range section.Findings { + if finding.Path == "app/compiled.o" { + t.Fatalf("binary file produced a line finding: %s at line %d (%s)", finding.RuleID, finding.Line, finding.Message) + } + } + } +} diff --git a/tests/support/runner_support_exclusions_test.go b/tests/support/runner_support_exclusions_test.go new file mode 100644 index 00000000..10821cc5 --- /dev/null +++ b/tests/support/runner_support_exclusions_test.go @@ -0,0 +1,354 @@ +package support_test + +import ( + "bytes" + "os" + "path/filepath" + "reflect" + "sort" + "testing" + + runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" +) + +// TestShouldExcludeDefaults pins the built-in scan exclusions. +// +// Two defects are locked out here. First, the defaults used to be globs +// anchored at the target root ("dist/**"), so dist/app.js was excluded but +// services/app/dist/app.js was scanned - every monorepo analyzed its own build +// output. Second, the common generated and dependency trees (.next, __pycache__, +// .venv, .terraform, tool caches) were not excluded at all; .next alone +// accounted for the overwhelming majority of findings on real Next.js +// repositories. +// +// The negative cases matter just as much: a scanner that silently skips real +// source reports a pass on code it never read, which is worse than reporting +// noise. Names that are sometimes legitimate source (build, out, bin, obj, +// target, coverage) must NOT be excluded on the name alone. +func TestShouldExcludeDefaults(t *testing.T) { + cases := []struct { + name string + path string + want bool + }{ + // Depth independence: the original defect. + {"dist at root", "dist/app.js", true}, + {"dist nested in a monorepo", "services/app/dist/app.js", true}, + {"dist deeply nested", "a/b/c/d/dist/chunk.js", true}, + {"git metadata", ".git/config", true}, + {"nested git submodule metadata", "third_party/lib/.git/config", true}, + {"codeguard state", ".codeguard/cache.json", true}, + {"nested gomodcache", "tools/.gomodcache/pkg/mod/x.go", true}, + + // Dependency trees. + {"node_modules", "node_modules/left-pad/index.js", true}, + {"nested node_modules", "web/node_modules/left-pad/index.js", true}, + {"bower_components", "static/bower_components/jquery/jquery.js", true}, + {"python site-packages", "lib/python3.11/site-packages/requests/api.py", true}, + {"dot venv", ".venv/lib/python3.11/site-packages/x.py", true}, + {"bare venv", "venv/bin/activate.py", true}, + {"cocoapods", "ios/Pods/Alamofire/Source/Alamofire.swift", true}, + {"carthage", "Carthage/Build/iOS/x.swift", true}, + {"dart pub cache", ".pub-cache/hosted/pub.dev/x.dart", true}, + {"vendor by default", "vendor/example.com/dep/dep.go", true}, + + // JS/TS framework and bundler output. + {"next output", ".next/server/pages/index.js", true}, + {"nested next output", "apps/web/.next/static/chunks/main.js", true}, + {"nuxt output", ".nuxt/dist/server.mjs", true}, + {"svelte-kit output", ".svelte-kit/output/client/app.js", true}, + {"angular cache", ".angular/cache/17.0.0/vite/x.js", true}, + {"astro output", ".astro/types.d.ts", true}, + {"docusaurus output", ".docusaurus/registry.js", true}, + {"nitro output", ".output/server/index.mjs", true}, + {"vercel output", ".vercel/output/functions/index.func/index.js", true}, + {"netlify output", ".netlify/functions-internal/x.js", true}, + {"turbo cache", ".turbo/cache/abc123/out.js", true}, + {"parcel cache", ".parcel-cache/abc.txt", true}, + {"vite cache", ".vite/deps/react.js", true}, + {"nyc output", ".nyc_output/out.json", true}, + + // Tool caches. ".cache" is the far more common name that ".gocache" was + // standing in for; a Go repo with a .build/go-cache tree produced 566 + // security findings against compiled build-cache objects. + {"generic cache", ".cache/go-build/ab/abcdef", true}, + {"nested generic cache", "sub/.cache/go-build/ab/abcdef", true}, + {"swiftpm build cache", ".build/go-cache/ab/abcdef", true}, + {"mypy cache", ".mypy_cache/3.11/x.json", true}, + {"pytest cache", ".pytest_cache/v/cache/nodeids", true}, + {"ruff cache", ".ruff_cache/0.1.0/x", true}, + {"tox", ".tox/py311/lib/x.py", true}, + {"nox", ".nox/tests/lib/x.py", true}, + {"gradle cache", ".gradle/caches/modules-2/x.jar", true}, + {"terraform modules", ".terraform/modules/vpc/main.tf", true}, + {"nested terraform modules", "infra/prod/.terraform/providers/x", true}, + {"dart tool", ".dart_tool/package_config.json", true}, + {"sass cache", ".sass-cache/x.scssc", true}, + {"python eggs", ".eggs/x.egg/x.py", true}, + {"notebook checkpoints", "notebooks/.ipynb_checkpoints/x.ipynb", true}, + {"python bytecode", "pkg/__pycache__/mod.cpython-311.pyc", true}, + {"egg-info metadata", "src/mypkg.egg-info/PKG-INFO", true}, + {"coverage html report", "htmlcov/index.html", true}, + {"cdk synth output", "infrastructure/cdk.out/asset/index.js", true}, + + // Generated files, which the binary guard cannot catch because they are + // text. Only compound suffixes are listed: a bare ".map" could be a + // data file, while ".js.map" is a source map and nothing else. + {"minified js", "static/js/app.min.js", true}, + {"minified css", "static/css/app.min.css", true}, + {"webpack bundle", "public/vendor.bundle.js", true}, + {"js source map", "public/app.js.map", true}, + {"css source map", "public/app.css.map", true}, + + // Names that are sometimes real source: never excluded by name alone. + // walkFilesBounded skips these only with a corroborating sibling + // manifest (see TestWalkFilesRequiresCorroborationForAmbiguousBuildDirs). + {"go package named build", "internal/build/version.go", false}, + {"rust binary crate", "src/bin/server.rs", false}, + {"out directory", "out/index.js", false}, + {"cargo target name", "target/debug/app.rs", false}, + {"dotnet obj name", "obj/Debug/x.cs", false}, + {"coverage source package", "coverage/control.py", false}, + + // Directories that must never be excluded: checks read them. + {"github workflows", ".github/workflows/ci.yml", false}, + {"agent context", ".claude/knowledge/x.md", false}, + {"editor config", ".vscode/settings.json", false}, + {"ordinary source", "internal/codeguard/runner/support/utils.go", false}, + {"testdata", "tests/corpus/testdata/go/x.go", false}, + // Partial segment matches must not fire. + {"distribution is not dist", "distribution/app.js", false}, + {"venv prefix is not venv", "venvtools/main.py", false}, + {"next suffix is not .next", "my.next/app.js", false}, + {"map is not a source map", "data/regions.map", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := runnersupport.ShouldExclude(tc.path, nil); got != tc.want { + t.Fatalf("ShouldExclude(%q, nil) = %t, want %t", tc.path, got, tc.want) + } + }) + } +} + +// TestShouldExcludeReinclude covers the escape hatch. The built-in default list +// is not user-editable, so a repository whose real source lives under an +// excluded name needs a way to opt back in; a "!" entry in the exclude list +// re-includes matching paths and wins over the defaults. +func TestShouldExcludeReinclude(t *testing.T) { + excludes := []string{"!packages/ui/dist/**", "generated/**"} + cases := []struct { + path string + want bool + }{ + {"packages/ui/dist/index.ts", false}, // re-included + {"packages/api/dist/index.js", true}, // still a default exclusion + {"generated/schema.go", true}, // ordinary user exclusion + {"packages/ui/src/button.tsx", false}, // never excluded + } + for _, tc := range cases { + if got := runnersupport.ShouldExclude(tc.path, excludes); got != tc.want { + t.Fatalf("ShouldExclude(%q, %v) = %t, want %t", tc.path, excludes, got, tc.want) + } + } +} + +// TestWalkFilesSkipsNestedGeneratedTrees is the end-to-end form of the depth +// independence fix: a walk of a monorepo must return owned source only. +func TestWalkFilesSkipsNestedGeneratedTrees(t *testing.T) { + root := writeTree(t, map[string]string{ + "apps/web/app/page.tsx": "export default function Page() {}\n", + "apps/web/.next/static/chunks/main.js": "self.webpackChunk=[];\n", + "apps/web/dist/bundle.js": "var a=1;\n", + "services/api/main.go": "package main\n", + "services/api/dist/server": "compiled\n", + "services/api/__pycache__/mod.cpython-311.pyc": "bytecode\n", + "infra/.terraform/providers/aws/plugin": "plugin\n", + "tools/.cache/go-build/ab/abcdef": "object code\n", + "web/public/app.min.js": "var a=1;\n", + "web/public/app.js.map": "{\"version\":3}\n", + }) + + got, err := runnersupport.WalkFiles(root, nil, func(string) bool { return true }) + if err != nil { + t.Fatalf("walk: %v", err) + } + sort.Strings(got) + want := []string{"apps/web/app/page.tsx", "services/api/main.go"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("walked files = %v, want only owned source %v", got, want) + } +} + +// TestWalkFilesRequiresCorroborationForAmbiguousBuildDirs is the other half of +// the judgement call: build, out, bin, obj, target and coverage are build output +// in most repositories and real source in some, so the walk skips one only when +// a sibling manifest proves which it is. Without that signal the directory is +// scanned, because silently dropping real source is the worse failure. +func TestWalkFilesRequiresCorroborationForAmbiguousBuildDirs(t *testing.T) { + cases := []struct { + name string + files map[string]string + wantPath string + want bool + }{ + { + name: "cargo target is build output", + files: map[string]string{ + "Cargo.toml": "[package]\n", + "target/debug/app.rs": "fn main() {}\n", + }, + wantPath: "target/debug/app.rs", + want: false, + }, + { + name: "target without a manifest stays scanned", + files: map[string]string{ + "go.mod": "module example.com/app\n", + "target/handler.go": "package target\n", + }, + wantPath: "target/handler.go", + want: true, + }, + { + name: "gradle build is build output", + files: map[string]string{ + "build.gradle": "plugins {}\n", + "build/classes/Main.java": "class Main {}\n", + }, + wantPath: "build/classes/Main.java", + want: false, + }, + { + name: "go package named build stays scanned", + files: map[string]string{ + "go.mod": "module example.com/app\n", + "internal/build/version.go": "package build\n", + }, + wantPath: "internal/build/version.go", + want: true, + }, + { + name: "dotnet bin and obj are build output", + files: map[string]string{ + "App.csproj": "\n", + "bin/Debug/App.dll.cs": "// generated\n", + "obj/Debug/App.g.cs": "// generated\n", + "Program.cs": "class Program {}\n", + }, + wantPath: "bin/Debug/App.dll.cs", + want: false, + }, + { + name: "rust src/bin stays scanned", + files: map[string]string{ + "Cargo.toml": "[package]\n", + "src/bin/cli.rs": "fn main() {}\n", + }, + wantPath: "src/bin/cli.rs", + want: true, + }, + { + name: "jest coverage output is build output", + files: map[string]string{ + "package.json": "{}\n", + "coverage/lcov.info": "TN:\n", + }, + wantPath: "coverage/lcov.info", + want: false, + }, + { + // coverage.py's own repository keeps a coverage/ *source* package + // next to its pyproject.toml, which is why Python markers are + // deliberately absent from the coverage entry. + name: "python coverage source package stays scanned", + files: map[string]string{ + "pyproject.toml": "[project]\n", + "coverage/control.py": "class Coverage: pass\n", + }, + wantPath: "coverage/control.py", + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := writeTree(t, tc.files) + got, err := runnersupport.WalkFiles(root, nil, func(string) bool { return true }) + if err != nil { + t.Fatalf("walk: %v", err) + } + found := false + for _, file := range got { + if file == tc.wantPath { + found = true + } + } + if found != tc.want { + t.Fatalf("walk included %q = %t, want %t (walked %v)", tc.wantPath, found, tc.want, got) + } + }) + } +} + +// TestWalkFilesReincludeOverridesDefaultExclusion verifies the escape hatch end +// to end: the built-in list is not user-editable, so a "!" exclude entry must be +// able to bring a default-excluded tree back into the walk. +func TestWalkFilesReincludeOverridesDefaultExclusion(t *testing.T) { + root := writeTree(t, map[string]string{ + "packages/ui/src/button.tsx": "export const Button = () => null;\n", + "packages/ui/dist/index.ts": "export * from './button';\n", + "packages/api/dist/index.js": "var a=1;\n", + }) + + got, err := runnersupport.WalkFiles(root, []string{"!packages/ui/dist/**"}, func(string) bool { return true }) + if err != nil { + t.Fatalf("walk: %v", err) + } + sort.Strings(got) + want := []string{"packages/ui/dist/index.ts", "packages/ui/src/button.tsx"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("walked files = %v, want %v", got, want) + } +} + +// TestLooksBinary pins the shared binary classifier that now guards every +// per-file findings scan, not just the secrets pass. +func TestLooksBinary(t *testing.T) { + cases := []struct { + name string + data []byte + want bool + }{ + {"empty", nil, false}, + {"source", []byte("package main\n\nfunc main() {}\n"), false}, + {"utf8 text", []byte("// café ☕\n"), false}, + {"object code", []byte("\x7fELF\x02\x01\x01\x00\x00\x00md5.New()"), true}, + {"nul late in the window", append(bytes.Repeat([]byte("a"), 4096), 0x00), true}, + // The sniff window is bounded at 8 KiB, so a NUL past it is not seen. + {"nul beyond the sniff window", append(bytes.Repeat([]byte("a"), 8<<10), 0x00), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := runnersupport.LooksBinary(tc.data); got != tc.want { + t.Fatalf("LooksBinary(%q...) = %t, want %t", tc.data[:min(len(tc.data), 8)], got, tc.want) + } + }) + } +} + +func writeTree(t *testing.T, files map[string]string) string { + t.Helper() + root := t.TempDir() + for rel, content := range files { + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + return root +} From d889052efd0e14b7c4abebd55a45a3a494314dd2 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 17 Sep 2026 15:02:33 -0400 Subject: [PATCH 03/16] fix(security): grade hardcoded-credential findings in fixtures and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit security.hardcoded-credential bypassed fixture classification entirely: classifyFixtureCandidate short-circuited to `confirmed` on the rule ID before isFixturePath was ever consulted, so a credential-shaped literal in a test file or a documentation placeholder was reported at fail level with high confidence and blocked CI. The rule now participates in classification, with suppression kept strictly separate from path evidence: - A fixture or documentation path DEMOTES a credential finding to warn with medium confidence and an annotated message. It is still a reported, baselinable, SARIF-exported finding — a real leaked key does not stop being real because of the file it was committed in — it just no longer fails CI on a fixture. - Suppression to an informational diagnostic always requires positive evidence about the VALUE: a vendor's own published documentation placeholder (AWS's AKIAIOSFODNN7EXAMPLE and friends, recognized on any path since they are world-readable non-secrets), an EXAMPLE/FAKE/REDACTED marker or repeated filler run in the body, or a credential aimed at a reserved (RFC 2606) or loopback host. - PEM private-key material and high-entropy hits are still never path-demoted. - security_rules.demote_fixture_findings is wired up for the first time (it was documented as inert): setting it false reports every match exactly as the detector produced it. allow_paths remains the only full-suppression lever. Fixture path signals are widened well beyond the four suffixes and three directories they covered. The structure now supports base-name PREFIXES (pytest's dominant test_*.py, conftest.py), case-sensitive PascalCase suffixes for the JVM/.NET conventions (AccountTest.java, AccountTests.cs — matched case-sensitively so Latest.java stays production code), many more dot/snake suffixes (.test.tsx, .spec.js, .stories.tsx, _spec.rb, _test.rs, _test.cc, ...), and segment-matched directories (tests, test, spec, __tests__, __mocks__, mocks, e2e, cypress, stories, ...). Documentation is a separate scope (docs/, examples/, .md/.mdx/.rst/.adoc) that demotes but never suppresses, because a token pasted into a committed README is a real leak vector. Corpus: security.hardcoded-credential goes 8 TP/0 FP to 11 TP/0 FP with precision and recall still 1.000. Two vulnerable fixtures and the TS one used AKIAIOSFODNN7EXAMPLE, i.e. the corpus was scoring AWS's own published example as a true positive; they now use a split-literal AWS-shaped key (folded by the scanner, no contiguous token committed) and the documented placeholders moved to a clean fixture. New fixtures measure the graded outcomes: a production-looking credential in a _test.go and in a test_*.py still fires, loopback/filler/vendor-placeholder values do not, and a bearer token in a .md still fires. Co-Authored-By: Claude Opus 5 (1M context) --- docs/checks.md | 31 +++ .../security_fixture_classification.go | 108 ++++++++-- .../security_fixture_classification_test.go | 91 +++++++-- .../security/security_fixture_demote.go | 132 ++++++++++++- .../checks/security/security_secrets.go | 40 +++- .../checks/security_fixture_demotion_test.go | 187 ++++++++++++++---- tests/checks/security_secrets_test.go | 8 +- tests/corpus/expectations.yaml | 29 +++ .../credential-docs/clean/quickstart.md | 7 + .../vulnerable/authentication.md | 7 + .../clean/local_dsn_test.go | 8 + .../vulnerable/session_store_test.go | 9 + .../clean/vendor_examples.go | 10 + .../vulnerable/embedded_config.go | 2 +- .../vulnerable/providers.go | 8 +- .../clean/test_fixtures.py | 4 + .../vulnerable/test_billing_client.py | 4 + .../clean/checkout.spec.tsx | 3 + .../hardcoded-credential/vulnerable/config.ts | 2 +- 19 files changed, 605 insertions(+), 85 deletions(-) create mode 100644 tests/corpus/testdata/agnostic/credential-docs/clean/quickstart.md create mode 100644 tests/corpus/testdata/agnostic/credential-docs/vulnerable/authentication.md create mode 100644 tests/corpus/testdata/go/credential-fixture-path/clean/local_dsn_test.go create mode 100644 tests/corpus/testdata/go/credential-fixture-path/vulnerable/session_store_test.go create mode 100644 tests/corpus/testdata/go/hardcoded-credential/clean/vendor_examples.go create mode 100644 tests/corpus/testdata/python/credential-fixture-path/clean/test_fixtures.py create mode 100644 tests/corpus/testdata/python/credential-fixture-path/vulnerable/test_billing_client.py create mode 100644 tests/corpus/testdata/typescript/credential-fixture-path/clean/checkout.spec.tsx diff --git a/docs/checks.md b/docs/checks.md index 4361a1a8..cdc78c85 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -1753,6 +1753,37 @@ Config keys (under `checks.security_rules.secrets`): - `entropy` enables the high-entropy heuristic (off by default); tune `min_length` (default 20), `threshold` in bits/char (default 4.5), and `level` (default `warn`). +#### Fixture and documentation classification + +Credential-shaped literals in test fixtures and documentation are graded rather than +either failed or exempted, because a real key committed to a `_test.go` file is still a +leaked key. Each match is classified from two independent kinds of evidence — where the +file lives, and what the value looks like: + +| Classification | Reported as | When | +| --- | --- | --- | +| `confirmed` | the detector's own level (`fail` for credentials/private keys) | production paths, and PEM key material or high-entropy hits anywhere | +| `ambiguous_fixture` | `warn`, `medium` confidence for provider/connection-string formats and `low` for the name-based heuristic, message annotated | a test-fixture or documentation path with no synthetic evidence about the value | +| `likely_synthetic_fixture` | an informational `security.credential-fixture` diagnostic, not a finding | the value itself is demonstrably filler | + +A path signal never suppresses a finding on its own — suppression always needs positive +evidence about the value: a vendor's own published documentation placeholder (for +example AWS's `AKIAIOSFODNN7EXAMPLE`, recognized on **any** path since it is a +world-readable non-secret), an explicit `EXAMPLE`/`FAKE`/`REDACTED` marker or repeated +filler run in the body, or a credential aimed at a reserved (RFC 2606) or loopback host. + +Fixture paths are recognized by directory segment (`testdata`, `test`, `tests`, +`__tests__`, `spec`, `fixtures`, `__fixtures__`, `mocks`, `__mocks__`, `e2e`, `cypress`, +`stories`, …), by file-name suffix (`_test.go`, `_test.py`, `_spec.rb`, `_test.rs`, +`_test.cc`, `.test.ts`/`.tsx`/`.js`/`.jsx`/`.mjs`, `.spec.*`, `.stories.*`), by file-name +*prefix* (pytest's `test_*.py`, `conftest.py`), and by the case-sensitive JVM/.NET +convention (`AccountTest.java`, `AccountTests.cs`). Documentation paths are `docs/`, +`doc/`, `examples/`, `samples/` and the prose extensions `.md`, `.mdx`, `.rst`, `.adoc`. + +Set `security_rules.demote_fixture_findings` to `false` to turn all of this off and have +every match reported exactly as the detector produced it. `allow_paths` remains the way +to silence a path completely. + Invalid `allow_patterns`/`custom_patterns` entries are rejected by config validation. If an unvalidated config reaches the scan anyway (e.g. through the SDK), the unusable pattern is skipped and reported as a `security.secrets-config` (**fail**) finding rather diff --git a/internal/codeguard/checks/security/security_fixture_classification.go b/internal/codeguard/checks/security/security_fixture_classification.go index 6c84f1e4..3645a5b6 100644 --- a/internal/codeguard/checks/security/security_fixture_classification.go +++ b/internal/codeguard/checks/security/security_fixture_classification.go @@ -15,22 +15,79 @@ const ( type fixtureAssessment struct { Classification fixtureClassification + Scope pathScope Evidence []string } -var syntheticTokens = []string{"fixture", "example", "dummy", "fake", "mock", "test"} +var syntheticTokens = map[string]struct{}{ + "fixture": {}, "example": {}, "dummy": {}, "fake": {}, "mock": {}, "test": {}, +} var fixtureSymbolTokens = []string{"test", "fake", "mock", "fixture"} var syntheticComponentSeparator = regexp.MustCompile(`[^a-z0-9]+`) +// vendorDocumentedPlaceholders are credential-shaped literals that a vendor +// publishes in its own public documentation as non-secrets. They are the +// strongest synthetic signal there is: the value is world-readable by design, +// so a match can never be a leak, and using one confers no advantage on an +// attacker trying to smuggle a live key past the scanner. They are therefore +// the only value class recognized regardless of path. +var vendorDocumentedPlaceholders = []string{ + // AWS documentation examples (also allowlisted by GitHub secret scanning). + "akiaiosfodnn7example", + "asiaiosfodnn7example", + "akiai44qh8dhbexample", + "wjalrxutnfemi/k7mdeng/bpxrficyexamplekey", + "je7mtgbclwbf/2zp9utk/h3yco8nvbexamplekey", +} + +// syntheticValueMarkers are substrings that mark a credential body as filler. +// They only apply inside a fixture or documentation scope: the same words in +// production code are much weaker evidence, and this repo's own +// isPlaceholderSecret already applies the equivalent test to every pattern that +// captures its value. +var syntheticValueMarkers = []string{ + "example", "fake", "dummy", "redacted", "placeholder", "changeme", + "notreal", "not-real", "sample", "synthetic", "xxxxxxxx", "deadbeef", +} + +// unroutableCredentialHosts are reserved (RFC 2606) or loopback hosts. A +// credential aimed at one of them cannot authenticate against anything real, +// so pairing it with a fixture or documentation path is synthetic evidence. +var unroutableCredentialHosts = []string{"example.com", "example.org", "example.net", "example.test", "localhost", "127.0.0.1", "::1"} + +// fillerRunPattern matches a run of one repeated character long enough to be a +// stand-in body ("AKIA0000000000000000", "ghp_xxxxxx..."). +var fillerRunPattern = regexp.MustCompile(`(0{8,}|x{8,}|a{8,}|z{8,}|1{8,}|\*{8,}|\.{8,}|-{8,}|_{8,})`) + +// classifyFixtureCandidate grades one secret match into a reporting tier. +// +// The security trade-off, in order of precedence: +// +// 1. PEM private-key material and the entropy heuristic are never +// path-demoted. Key material is dangerous wherever it is committed, and +// the entropy rule is already an opt-in low-confidence signal. +// 2. A vendor's own documented placeholder is a non-secret everywhere, so it +// drops to an informational diagnostic on any path. +// 3. Outside a fixture or documentation path everything else stays confirmed +// (fail, high confidence) exactly as before. +// 4. Inside those paths a credential-shaped literal is DEMOTED, not +// suppressed, unless the value itself carries synthetic evidence. A real +// key in a _test.go file is still a leaked key, so the finding stays +// visible and baselinable at the review tier instead of failing CI. func classifyFixtureCandidate(path, line string, match Match) fixtureAssessment { - if match.SecretType == "private_key" || match.SecretType == "high_entropy" || match.RuleID == hardcodedCredentialRule { - return fixtureAssessment{Classification: fixtureConfirmed, Evidence: []string{"credential_structure:" + match.SecretType}} - } - if !isFixturePath(path) { - return fixtureAssessment{Classification: fixtureConfirmed, Evidence: []string{"path_scope:non_fixture"}} + if match.SecretType == "private_key" || match.SecretType == "high_entropy" { + return fixtureAssessment{Classification: fixtureConfirmed, Scope: classifyPathScope(path), Evidence: []string{"credential_structure:" + match.SecretType}} } lower := strings.ToLower(line) - evidence := []string{"path_scope:fixture"} + scope := classifyPathScope(path) + if containsAny(lower, vendorDocumentedPlaceholders) { + return fixtureAssessment{Classification: fixtureLikelySynthetic, Scope: scope, Evidence: []string{"value:vendor_documented_placeholder"}} + } + if scope == scopeProduction { + return fixtureAssessment{Classification: fixtureConfirmed, Scope: scope, Evidence: []string{"path_scope:non_fixture"}} + } + + evidence := []string{"path_scope:" + string(scope)} value := lower symbol := false if assignment := strings.IndexAny(lower, "=:"); assignment > 0 { @@ -38,8 +95,10 @@ func classifyFixtureCandidate(path, line string, match Match) fixtureAssessment value = lower[assignment+1:] } synthetic := containsSyntheticValueComponent(value) - if strings.Contains(value, "example.com") { - evidence = append(evidence, "host:reserved_example") + unroutable := containsAny(value, unroutableCredentialHosts) + filler := containsAny(value, syntheticValueMarkers) || fillerRunPattern.MatchString(value) + if unroutable { + evidence = append(evidence, "host:unroutable") } if symbol { evidence = append(evidence, "symbol:fixture_convention") @@ -47,19 +106,34 @@ func classifyFixtureCandidate(path, line string, match Match) fixtureAssessment if synthetic { evidence = append(evidence, "value:synthetic_component") } + if filler { + evidence = append(evidence, "value:synthetic_marker") + } + + // A provider-format or connection-string match is structurally certain, so + // the fixture symbol name is not required: the value is the only thing that + // could be real. Positive evidence about the value suppresses; its absence + // demotes. + if match.RuleID == hardcodedCredentialRule { + if filler || unroutable { + return fixtureAssessment{Classification: fixtureLikelySynthetic, Scope: scope, Evidence: evidence} + } + return fixtureAssessment{Classification: fixtureAmbiguous, Scope: scope, Evidence: evidence} + } + + // The name-based heuristic is the noisiest tier, so it keeps the stricter + // combined bar: the symbol must follow a fixture convention AND the value + // must look synthetic. if symbol && synthetic { - return fixtureAssessment{Classification: fixtureLikelySynthetic, Evidence: evidence} + return fixtureAssessment{Classification: fixtureLikelySynthetic, Scope: scope, Evidence: evidence} } - return fixtureAssessment{Classification: fixtureAmbiguous, Evidence: evidence} + return fixtureAssessment{Classification: fixtureAmbiguous, Scope: scope, Evidence: evidence} } func containsSyntheticValueComponent(value string) bool { - components := syntheticComponentSeparator.Split(strings.ToLower(value), -1) - for _, component := range components { - for _, token := range syntheticTokens { - if component == token { - return true - } + for _, component := range syntheticComponentSeparator.Split(strings.ToLower(value), -1) { + if _, ok := syntheticTokens[component]; ok { + return true } } return false diff --git a/internal/codeguard/checks/security/security_fixture_classification_test.go b/internal/codeguard/checks/security/security_fixture_classification_test.go index 308fc1d6..c2badca6 100644 --- a/internal/codeguard/checks/security/security_fixture_classification_test.go +++ b/internal/codeguard/checks/security/security_fixture_classification_test.go @@ -5,23 +5,37 @@ import "testing" func TestClassifyFixtureCandidateRequiresCombinedEvidence(t *testing.T) { t.Parallel() tests := []struct { - name, path, line, secretType string - want fixtureClassification + name, path, line, secretType, ruleID string + want fixtureClassification }{ - {"provider credential in test", "testdata/aws.json", `{"accessKey":"AKIA1234567890ABCDEF"}`, "aws_access_key", fixtureConfirmed}, - {"private key in fixture", "fixtures/key.pem", "-----BEGIN PRIVATE KEY-----", "private_key", fixtureConfirmed}, - {"high entropy in test", "src/auth.test.ts", `const token = "k7Jx9PqL2mNvB4wR8tZc3aYd5eHfUgQ1"`, "high_entropy", fixtureConfirmed}, - {"explicit fake symbol and dummy value", "pkg/auth_test.go", `const FakeAuthToken = "fixture-token-for-example.com"`, "named_secret", fixtureLikelySynthetic}, - {"path alone", "testdata/auth.json", `{"password":"hunter2hunter2"}`, "named_secret", fixtureAmbiguous}, - {"production file", "config/auth.json", `{"password":"fixture-password"}`, "named_secret", fixtureConfirmed}, + // Key material and the entropy heuristic are never path-demoted. + {"private key in fixture", "fixtures/key.pem", "-----BEGIN PRIVATE KEY-----", "private_key", privateKeyRule, fixtureConfirmed}, + {"high entropy in test", "src/auth.test.ts", `const token = "k7Jx9PqL2mNvB4wR8tZc3aYd5eHfUgQ1"`, "high_entropy", highEntropyRule, fixtureConfirmed}, + + // A provider-format credential in a test path is demoted, not + // suppressed: the value could still be a live key. + {"provider credential in test", "testdata/aws.json", `{"accessKey":"AKIA1234567890ABCDEF"}`, "api_key", hardcodedCredentialRule, fixtureAmbiguous}, + {"provider credential in pytest module", "api/test_client.py", `KEY = "AKIA1234567890ABCDEF"`, "api_key", hardcodedCredentialRule, fixtureAmbiguous}, + {"provider credential in production code", "internal/config/aws.go", `key := "AKIA1234567890ABCDEF"`, "api_key", hardcodedCredentialRule, fixtureConfirmed}, + + // Value-level synthetic evidence is what unlocks suppression. + {"filler body in test", "pkg/client_test.go", `key := "AKIAFAKE000000000000"`, "api_key", hardcodedCredentialRule, fixtureLikelySynthetic}, + {"unroutable dsn in test", "pkg/store_test.go", `dsn := "postgres://app:p4ssw0rdish@127.0.0.1:5432/app"`, "connection_string", hardcodedCredentialRule, fixtureLikelySynthetic}, + {"vendor placeholder in production code", "cmd/main.go", `key := "AKIAIOSFODNN7EXAMPLE"`, "api_key", hardcodedCredentialRule, fixtureLikelySynthetic}, + {"vendor secret placeholder in docs", "docs/aws.md", `aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`, "credential", hardcodedCredentialRule, fixtureLikelySynthetic}, + + // Documentation is its own scope, demoted but never silent. + {"bearer token in docs", "docs/okta-authentication.md", "Authorization: Bearer 7f3b91c0d4e58a62b1d09f4c", "token", hardcodedCredentialRule, fixtureAmbiguous}, + {"bearer token in a config file", "deploy/gateway.yaml", "authorization: Bearer 7f3b91c0d4e58a62b1d09f4c", "token", hardcodedCredentialRule, fixtureConfirmed}, + + // The name-based tier keeps the stricter combined bar. + {"explicit fake symbol and dummy value", "pkg/auth_test.go", `const FakeAuthToken = "fixture-token-for-example.com"`, "named_secret", hardcodedSecretRule, fixtureLikelySynthetic}, + {"path alone", "testdata/auth.json", `{"password":"hunter2hunter2"}`, "named_secret", hardcodedSecretRule, fixtureAmbiguous}, + {"production file", "config/auth.json", `{"password":"fixture-password"}`, "named_secret", hardcodedSecretRule, fixtureConfirmed}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - ruleID := hardcodedSecretRule - if tc.secretType == "aws_access_key" { - ruleID = hardcodedCredentialRule - } - got := classifyFixtureCandidate(tc.path, tc.line, Match{RuleID: ruleID, SecretType: tc.secretType}) + got := classifyFixtureCandidate(tc.path, tc.line, Match{RuleID: tc.ruleID, SecretType: tc.secretType}) if got.Classification != tc.want { t.Fatalf("classification = %q, want %q (evidence %v)", got.Classification, tc.want, got.Evidence) } @@ -29,6 +43,57 @@ func TestClassifyFixtureCandidateRequiresCombinedEvidence(t *testing.T) { } } +func TestClassifyPathScopeCoversCommonConventions(t *testing.T) { + t.Parallel() + tests := []struct { + path string + want pathScope + }{ + {"pkg/handler_test.go", scopeFixture}, + {"scripts/util_test.py", scopeFixture}, + {"api/test_settings.py", scopeFixture}, + {"api/conftest.py", scopeFixture}, + {"web/app.test.ts", scopeFixture}, + {"web/app.test.tsx", scopeFixture}, + {"web/app.spec.js", scopeFixture}, + {"web/app.spec.jsx", scopeFixture}, + {"web/app.test.mjs", scopeFixture}, + {"lib/parser_spec.rb", scopeFixture}, + {"src/main/java/com/acme/AccountTest.java", scopeFixture}, + {"src/Acme/AccountTests.cs", scopeFixture}, + {"crates/api/src/router_test.rs", scopeFixture}, + {"src/engine_test.cc", scopeFixture}, + {"packages/api/src/__tests__/client.ts", scopeFixture}, + {"packages/api/src/__mocks__/client.ts", scopeFixture}, + {"e2e/checkout/flow.ts", scopeFixture}, + {"cypress/support/commands.js", scopeFixture}, + {"web/components/Button.stories.tsx", scopeFixture}, + {"test/integration/run.sh", scopeFixture}, + {"tests/checks/security_test.go", scopeFixture}, + {"spec/models/user.rb", scopeFixture}, + {"testdata/creds.txt", scopeFixture}, + + {"docs/okta-authentication.md", scopeDocumentation}, + {"README.md", scopeDocumentation}, + {"guide/setup.rst", scopeDocumentation}, + {"examples/hooks/devin/config.yaml", scopeDocumentation}, + + {"internal/config/aws.go", scopeProduction}, + {"deploy/gateway.yaml", scopeProduction}, + {"docker-compose.ci.yml", scopeProduction}, + // Case-sensitive PascalCase matching keeps this out of the fixture + // scope even though its lowercased name ends in "test.java". + {"src/main/java/com/acme/Latest.java", scopeProduction}, + } + for _, tc := range tests { + t.Run(tc.path, func(t *testing.T) { + if got := classifyPathScope(tc.path); got != tc.want { + t.Fatalf("classifyPathScope(%q) = %q, want %q", tc.path, got, tc.want) + } + }) + } +} + func TestScannerDetectsConcatenatedProviderCredential(t *testing.T) { t.Parallel() scanner, issues := BuildScanner(nil) diff --git a/internal/codeguard/checks/security/security_fixture_demote.go b/internal/codeguard/checks/security/security_fixture_demote.go index 436be038..799e6f92 100644 --- a/internal/codeguard/checks/security/security_fixture_demote.go +++ b/internal/codeguard/checks/security/security_fixture_demote.go @@ -5,27 +5,141 @@ import ( "strings" ) -// fixtureDirSegments and fixtureFileSuffixes provide one evidence signal for -// classification; a path match alone never exempts or confirms a credential. +// pathScope records why a file is a candidate for credential demotion. +// +// A path signal is only ever ONE piece of evidence. It can move a +// credential-shaped finding down to the review tier, but on its own it never +// suppresses one: a real AWS key committed to a _test.go file is still a +// leaked key, and an attacker who wants to hide a credential must not be able +// to do it by choosing a file name. Suppression always requires positive +// evidence about the value itself (see security_fixture_classification.go). +type pathScope string + +const ( + scopeProduction pathScope = "non_fixture" + scopeFixture pathScope = "fixture" + scopeDocumentation pathScope = "documentation" +) + var ( - fixtureFileSuffixes = []string{"_test.go", ".test.ts", "_test.py", ".spec.ts"} - fixtureDirSet = map[string]struct{}{ + // fixtureFileSuffixes are lowercase file-name suffixes for the + // snake/dot test-file conventions (Go, pytest, RSpec, Rust, C++, Jest, + // Vitest, Jasmine, Storybook). + fixtureFileSuffixes = []string{ + "_test.go", + "_test.py", "_tests.py", + "_test.rb", "_spec.rb", + "_test.rs", + "_test.cc", "_test.cpp", "_test.cxx", "_test.c", + "_test.php", "_testcase.php", + ".test.ts", ".test.tsx", ".test.js", ".test.jsx", ".test.mjs", ".test.cjs", ".test.mts", + ".spec.ts", ".spec.tsx", ".spec.js", ".spec.jsx", ".spec.mjs", ".spec.cjs", ".spec.mts", + ".stories.ts", ".stories.tsx", ".stories.js", ".stories.jsx", + ".fixture.ts", ".fixture.js", ".fixtures.json", ".golden", + } + + // fixtureFilePrefixes are lowercase base-name prefixes. They exist because + // the dominant pytest convention (`test_*.py`) marks a test file by PREFIX, + // which a suffix-only check cannot express; Go's `example_` files and + // pytest's `conftest.py` follow the same shape. + fixtureFilePrefixes = []string{"test_", "tests_", "spec_", "specs_", "conftest.", "mock_", "mocks_", "fake_", "fixture_", "fixtures_"} + + // fixturePascalBaseSuffixes are CASE-SENSITIVE base-name suffixes for the + // JVM/.NET/Swift conventions (FooTest.java, UserTests.cs, ApiSpec.scala). + // Matching case-sensitively is what keeps `Latest.java` — whose lowercased + // name ends in "test.java" — out of the fixture scope. + fixturePascalBaseSuffixes = []string{"Test", "Tests", "TestCase", "TestCases", "Spec", "Specs", "IT"} + fixturePascalExtensions = map[string]struct{}{ + ".java": {}, ".kt": {}, ".kts": {}, ".cs": {}, ".scala": {}, ".groovy": {}, ".swift": {}, + } + + // fixtureDirSet is segment-matched at any depth, so nested layouts such as + // packages/api/src/__tests__/ are covered without anchored globs. + fixtureDirSet = map[string]struct{}{ "testdata": {}, + "test": {}, + "tests": {}, + "__tests__": {}, + "spec": {}, + "specs": {}, + "fixture": {}, "fixtures": {}, "__fixtures__": {}, + "mocks": {}, + "__mocks__": {}, + "e2e": {}, + "cypress": {}, + "stories": {}, + "testutil": {}, + "testutils": {}, + } + + // documentationDirSet and documentationExtensions mark prose and sample + // trees. They are a separate scope from fixtures because the demotion + // message differs and because documentation carries a different risk + // profile (see classifyFixtureCandidate). + documentationDirSet = map[string]struct{}{ + "docs": {}, + "doc": {}, + "documentation": {}, + "example": {}, + "examples": {}, + "sample": {}, + "samples": {}, + } + documentationExtensions = map[string]struct{}{ + ".md": {}, ".mdx": {}, ".markdown": {}, ".rst": {}, ".adoc": {}, ".asciidoc": {}, ".textile": {}, } ) -// isFixturePath reports whether the file lives in a test/fixture location. -func isFixturePath(path string) bool { +// classifyPathScope reports which demotion scope a file belongs to. Fixture +// evidence wins over documentation evidence: a fixture under docs/ is still +// primarily a fixture. +func classifyPathScope(path string) pathScope { normalized := strings.ToLower(filepath.ToSlash(path)) - for _, segment := range strings.Split(normalized, "/") { + segments := strings.Split(normalized, "/") + base := segments[len(segments)-1] + + for _, segment := range segments[:len(segments)-1] { if _, ok := fixtureDirSet[segment]; ok { - return true + return scopeFixture } } + if isFixtureBaseName(base, filepath.Base(filepath.ToSlash(path))) { + return scopeFixture + } + for _, segment := range segments[:len(segments)-1] { + if _, ok := documentationDirSet[segment]; ok { + return scopeDocumentation + } + } + if _, ok := documentationExtensions[strings.ToLower(filepath.Ext(base))]; ok { + return scopeDocumentation + } + return scopeProduction +} + +// isFixtureBaseName applies the suffix, prefix, and PascalCase conventions. +// lowered is the lowercased base name; original preserves case for the +// case-sensitive JVM/.NET check. +func isFixtureBaseName(lowered string, original string) bool { for _, suffix := range fixtureFileSuffixes { - if strings.HasSuffix(normalized, suffix) { + if strings.HasSuffix(lowered, suffix) { + return true + } + } + for _, prefix := range fixtureFilePrefixes { + if strings.HasPrefix(lowered, prefix) { + return true + } + } + ext := strings.ToLower(filepath.Ext(original)) + if _, ok := fixturePascalExtensions[ext]; !ok { + return false + } + stem := strings.TrimSuffix(original, filepath.Ext(original)) + for _, suffix := range fixturePascalBaseSuffixes { + if len(stem) > len(suffix) && strings.HasSuffix(stem, suffix) { return true } } diff --git a/internal/codeguard/checks/security/security_secrets.go b/internal/codeguard/checks/security/security_secrets.go index ec5b5693..b09ffd57 100644 --- a/internal/codeguard/checks/security/security_secrets.go +++ b/internal/codeguard/checks/security/security_secrets.go @@ -107,6 +107,7 @@ func secretResultsForFile(env support.Context, file string, data []byte, scanner } content := string(data) lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + demote := fixtureDemotionEnabled(env.Config) matches := scanner.ScanContent(content) findings := make([]core.Finding, 0, len(matches)) diagnostics := make([]core.Diagnostic, 0) @@ -116,14 +117,15 @@ func secretResultsForFile(env support.Context, file string, data []byte, scanner line = lines[match.Line-1] } assessment := classifyFixtureCandidate(file, line, match) + if !demote { + assessment = fixtureAssessment{Classification: fixtureConfirmed, Scope: assessment.Scope, Evidence: []string{"policy:fixture_demotion_disabled"}} + } if assessment.Classification == fixtureLikelySynthetic { diagnostics = append(diagnostics, core.Diagnostic{ID: "security.credential-fixture", Level: "info", Kind: string(assessment.Classification), Message: "likely synthetic credential fixture", Path: file, Evidence: assessment.Evidence, Metadata: secretMetadata(match)}) continue } if assessment.Classification == fixtureAmbiguous { - match.Level = "warn" - match.Confidence = core.ConfidenceLow - match.Message += " (ambiguous credential-shaped fixture)" + applyAmbiguousFixtureDemotion(&match, assessment) } metadata := secretMetadata(match) if metadata == nil { @@ -145,6 +147,38 @@ func secretResultsForFile(env support.Context, file string, data []byte, scanner return findings, diagnostics } +// fixtureDemotionEnabled reports whether security_rules.demote_fixture_findings +// permits evidence-based demotion. It defaults to on (config/defaults_rules.go +// sets it explicitly); turning it off makes every match report exactly as the +// detector produced it, which is the escape hatch for teams that want a hard +// fail on any credential-shaped literal anywhere in the tree. +func fixtureDemotionEnabled(cfg core.Config) bool { + if flag := cfg.Checks.SecurityRules.DemoteFixtureFindings; flag != nil { + return *flag + } + return true +} + +// applyAmbiguousFixtureDemotion moves a credential-shaped finding to the review +// tier: warn, so it cannot block CI, but still a real finding that is reported, +// baselinable, and exported to SARIF. Provider-format and connection-string +// matches keep medium confidence because their structure is certain and only +// the intent is in doubt; the name-based heuristic drops to low because both +// its structure and its intent are weak. +func applyAmbiguousFixtureDemotion(match *Match, assessment fixtureAssessment) { + match.Level = "warn" + if match.RuleID == hardcodedCredentialRule { + match.Confidence = core.ConfidenceMedium + } else { + match.Confidence = core.ConfidenceLow + } + if assessment.Scope == scopeDocumentation { + match.Message += " (ambiguous credential-shaped documentation example)" + return + } + match.Message += " (ambiguous credential-shaped fixture)" +} + func secretMetadata(match Match) map[string]string { if match.SecretType == "" { return nil diff --git a/tests/checks/security_fixture_demotion_test.go b/tests/checks/security_fixture_demotion_test.go index 0bc00d49..195b0019 100644 --- a/tests/checks/security_fixture_demotion_test.go +++ b/tests/checks/security_fixture_demotion_test.go @@ -35,7 +35,12 @@ func fixtureDemotionReport(t *testing.T, dir string, demote *bool) codeguard.Rep return report } -func TestSecurityFixturePathsKeepProviderCredentialFindings(t *testing.T) { +// TestSecurityFixturePathsDemoteProviderCredentialsToReview pins the core +// trade-off: a credential-shaped literal in a test path is moved to the review +// tier (warn, medium confidence) rather than suppressed. A real leaked key does +// not stop being real because of the file it lives in, so the finding must stay +// reportable and baselinable — it simply no longer fails CI on a fixture. +func TestSecurityFixturePathsDemoteProviderCredentialsToReview(t *testing.T) { t.Parallel() cases := []struct { @@ -45,9 +50,28 @@ func TestSecurityFixturePathsKeepProviderCredentialFindings(t *testing.T) { {"testdata dir", "testdata/creds.txt"}, {"fixtures dir", "fixtures/creds.txt"}, {"dunder fixtures dir", "src/__fixtures__/creds.ts"}, + {"dunder tests dir", "src/__tests__/creds.ts"}, + {"mocks dir", "src/__mocks__/creds.ts"}, + {"tests dir", "tests/creds.txt"}, + {"singular test dir", "test/creds.txt"}, + {"spec dir", "spec/creds.txt"}, + {"e2e dir", "e2e/creds.txt"}, + {"cypress dir", "cypress/creds.js"}, {"go test file", "pkg/handler_test.go"}, {"ts test file", "web/app.test.ts"}, - {"python test file", "scripts/util_test.py"}, + {"tsx test file", "web/app.test.tsx"}, + {"js spec file", "web/app.spec.js"}, + {"jsx spec file", "web/app.spec.jsx"}, + {"mjs test file", "web/app.test.mjs"}, + {"storybook file", "web/Button.stories.tsx"}, + {"python test suffix", "scripts/util_test.py"}, + {"pytest prefix", "scripts/test_util.py"}, + {"pytest conftest", "scripts/conftest.py"}, + {"ruby spec file", "lib/user_spec.rb"}, + {"java test class", "src/AccountTest.java"}, + {"csharp test class", "src/AccountTests.cs"}, + {"rust test file", "crates/router_test.rs"}, + {"cpp test file", "src/engine_test.cc"}, {"ts spec file", "src/api.spec.ts"}, {"json fixture", "fixtures/auth.json"}, {"cpp fixture", "testdata/auth_test.cpp"}, @@ -60,17 +84,93 @@ func TestSecurityFixturePathsKeepProviderCredentialFindings(t *testing.T) { writeFile(t, filepath.Join(dir, filepath.FromSlash(tc.path)), "key = \""+cred("AKIA", "1234567890ABCDEF")+"\"\n") report := fixtureDemotionReport(t, dir, nil) - assertSectionStatus(t, report, "Security", "fail") - assertFindingLevel(t, report, "Security", "security.hardcoded-credential", "fail") - assertFindingConfidence(t, report, "Security", "security.hardcoded-credential", "high") + assertFindingLevel(t, report, "Security", "security.hardcoded-credential", "warn") + assertFindingConfidence(t, report, "Security", "security.hardcoded-credential", "medium") finding := findFinding(t, report, "Security", "security.hardcoded-credential") - if finding.Metadata["classification"] != "confirmed" { - t.Fatalf("classification = %q, want confirmed", finding.Metadata["classification"]) + if finding.Metadata["classification"] != "ambiguous_fixture" { + t.Fatalf("classification = %q, want ambiguous_fixture", finding.Metadata["classification"]) + } + if !strings.HasSuffix(finding.Message, " (ambiguous credential-shaped fixture)") { + t.Fatalf("message missing ambiguous fixture suffix: %q", finding.Message) } }) } } +// TestSecurityDocumentationCredentialStaysVisible covers the documentation +// scope. A credential pasted into a committed .md is a real and common leak +// vector, so it is demoted rather than dropped; only the value evidence (see +// the vendor-placeholder test) can silence it. +func TestSecurityDocumentationCredentialStaysVisible(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "docs", "okta-authentication.md"), "Send the header:\n\n Authorization: Bearer 7f3b91c0d4e58a62b1d09f4c\n") + + report := fixtureDemotionReport(t, dir, nil) + assertFindingLevel(t, report, "Security", "security.hardcoded-credential", "warn") + finding := findFinding(t, report, "Security", "security.hardcoded-credential") + if !strings.HasSuffix(finding.Message, " (ambiguous credential-shaped documentation example)") { + t.Fatalf("message missing documentation suffix: %q", finding.Message) + } +} + +// TestSecurityVendorDocumentedPlaceholderIsDiagnostic covers the one value +// class that is recognized regardless of path: literals a vendor publishes in +// its own documentation as non-secrets. They are world-readable by design, so a +// match can never be a leak and reusing one gains an attacker nothing. +func TestSecurityVendorDocumentedPlaceholderIsDiagnostic(t *testing.T) { + t.Parallel() + for _, path := range []string{"src/config.go", "docs/setup.md", "pkg/config_test.go"} { + t.Run(path, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, filepath.FromSlash(path)), "key = \""+cred("AKIAIOSFODNN7", "EXAMPLE")+"\"\n") + + report := fixtureDemotionReport(t, dir, nil) + assertFindingRuleAbsent(t, report, "Security", "security.hardcoded-credential") + assertSyntheticFixtureDiagnostic(t, report) + }) + } +} + +// TestSecurityFixtureCredentialWithSyntheticBodyIsDiagnostic checks that +// suppression inside a fixture path needs positive evidence about the VALUE, +// not just the path. +func TestSecurityFixtureCredentialWithSyntheticBodyIsDiagnostic(t *testing.T) { + t.Parallel() + cases := []struct { + name string + value string + }{ + {"filler run", cred("AKIA", "0000000000000000")}, + {"fake marker", cred("AKIA", "FAKE000000000000")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "pkg", "client_test.go"), "package pkg\n\nvar key = \""+tc.value+"\"\n") + + report := fixtureDemotionReport(t, dir, nil) + assertFindingRuleAbsent(t, report, "Security", "security.hardcoded-credential") + assertSyntheticFixtureDiagnostic(t, report) + }) + } +} + +// TestSecurityFixtureUnroutableHostCredentialIsDiagnostic covers a credential +// aimed at a reserved or loopback host: it cannot authenticate against anything +// real, so pairing it with a fixture path is enough to silence it. +func TestSecurityFixtureUnroutableHostCredentialIsDiagnostic(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "pkg", "store_test.go"), "package pkg\n\nvar dsn = \"postgres://app:p4ssw0rdish@127.0.0.1:5432/app\"\n") + + report := fixtureDemotionReport(t, dir, nil) + assertFindingRuleAbsent(t, report, "Security", "security.hardcoded-credential") + assertSyntheticFixtureDiagnostic(t, report) +} + func TestSecurityFixtureDemotionSkipsNonFixturePaths(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -81,24 +181,40 @@ func TestSecurityFixtureDemotionSkipsNonFixturePaths(t *testing.T) { assertFindingLevel(t, report, "Security", "security.hardcoded-credential", "fail") assertFindingConfidence(t, report, "Security", "security.hardcoded-credential", "high") finding := findFinding(t, report, "Security", "security.hardcoded-credential") - if strings.Contains(finding.Message, "(fixture path)") { - t.Fatalf("non-fixture finding unexpectedly demoted: %q", finding.Message) + if finding.Metadata["classification"] != "confirmed" { + t.Fatalf("classification = %q, want confirmed", finding.Metadata["classification"]) } } +// TestSecurityFixtureDemotionDisabledKeepsOldBehavior exercises the operator +// escape hatch: with security_rules.demote_fixture_findings off, every match is +// reported exactly as the detector produced it. func TestSecurityFixtureDemotionDisabledKeepsOldBehavior(t *testing.T) { + t.Parallel() + for _, path := range []string{"testdata/creds.txt", "docs/setup.md"} { + t.Run(path, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, filepath.FromSlash(path)), "key = \""+cred("AKIA", "1234567890ABCDEF")+"\"\n") + + report := fixtureDemotionReport(t, dir, boolPtr(false)) + assertSectionStatus(t, report, "Security", "fail") + assertFindingLevel(t, report, "Security", "security.hardcoded-credential", "fail") + assertFindingConfidence(t, report, "Security", "security.hardcoded-credential", "high") + }) + } +} + +// TestSecurityFixtureDemotionDisabledKeepsVendorPlaceholders proves the toggle +// also restores the vendor-placeholder matches, so nothing the detector found is +// hidden from an operator who asked for strict reporting. +func TestSecurityFixtureDemotionDisabledKeepsVendorPlaceholders(t *testing.T) { t.Parallel() dir := t.TempDir() - writeFile(t, filepath.Join(dir, "testdata", "creds.txt"), "key = \""+cred("AKIA", "1234567890ABCDEF")+"\"\n") + writeFile(t, filepath.Join(dir, "pkg", "config_test.go"), "package pkg\n\nvar key = \""+cred("AKIAIOSFODNN7", "EXAMPLE")+"\"\n") report := fixtureDemotionReport(t, dir, boolPtr(false)) - assertSectionStatus(t, report, "Security", "fail") assertFindingLevel(t, report, "Security", "security.hardcoded-credential", "fail") - assertFindingConfidence(t, report, "Security", "security.hardcoded-credential", "high") - finding := findFinding(t, report, "Security", "security.hardcoded-credential") - if strings.Contains(finding.Message, "(fixture path)") { - t.Fatalf("finding demoted with toggle off: %q", finding.Message) - } } func TestSecurityFixtureDemotionMarksNameBasedSecret(t *testing.T) { @@ -155,24 +271,8 @@ func TestSecurityLikelySyntheticFixtureIsDiagnosticNotFinding(t *testing.T) { writeFile(t, filepath.Join(dir, "testdata", "auth.go"), "package testdata\nconst FakePassword = \"fixture-password-local\"\n") report := fixtureDemotionReport(t, dir, nil) - var section *codeguard.SectionResult - for i := range report.Sections { - if report.Sections[i].Name == "Security" { - section = &report.Sections[i] - break - } - } - if section == nil { - t.Fatal("Security section missing") - } - for _, finding := range section.Findings { - if finding.RuleID == "security.hardcoded-secret" { - t.Fatalf("synthetic fixture became baselinable finding: %#v", finding) - } - } - if len(section.Diagnostics) != 1 || section.Diagnostics[0].Kind != "likely_synthetic_fixture" { - t.Fatalf("diagnostics = %#v, want likely synthetic fixture", section.Diagnostics) - } + assertFindingRuleAbsent(t, report, "Security", "security.hardcoded-secret") + assertSyntheticFixtureDiagnostic(t, report) } func TestSecurityFixtureSymbolDoesNotSuppressRealSecretValue(t *testing.T) { @@ -196,3 +296,22 @@ func TestSecuritySyntheticTokensRequireValueComponents(t *testing.T) { report := fixtureDemotionReport(t, dir, nil) assertFindingRulePresent(t, report, "Security", "security.hardcoded-secret") } + +// assertSyntheticFixtureDiagnostic asserts the Security section carries a +// likely-synthetic-fixture diagnostic, which is how a suppressed credential +// stays auditable instead of vanishing. +func assertSyntheticFixtureDiagnostic(t *testing.T, report codeguard.Report) { + t.Helper() + for _, section := range report.Sections { + if section.Name != "Security" { + continue + } + for _, diagnostic := range section.Diagnostics { + if diagnostic.Kind == "likely_synthetic_fixture" { + return + } + } + t.Fatalf("diagnostics = %#v, want a likely_synthetic_fixture entry", section.Diagnostics) + } + t.Fatal("Security section missing") +} diff --git a/tests/checks/security_secrets_test.go b/tests/checks/security_secrets_test.go index b84b66c9..a1c1f6e1 100644 --- a/tests/checks/security_secrets_test.go +++ b/tests/checks/security_secrets_test.go @@ -73,11 +73,13 @@ func TestSecuritySecretsAllowPathsSkipsFixtures(t *testing.T) { }, "go") assertSectionStatus(t, allowed, "Security", "pass") - // Without the allowlist the provider-shaped credential remains strict even - // in a fixture path; path evidence alone cannot demote it. + // Without the allowlist the provider-shaped credential is still reported — + // the fixture path moves it to the review tier (warn) but never silences + // it, so allow_paths stays the only way to get full suppression. blocked := secretsScanConfig(t, dir, nil, "go") - assertSectionStatus(t, blocked, "Security", "fail") + assertSectionStatus(t, blocked, "Security", "warn") assertFindingRulePresent(t, blocked, "Security", "security.hardcoded-credential") + assertFindingLevel(t, blocked, "Security", "security.hardcoded-credential", "warn") } func TestSecuritySecretsAllowPatternsSkipsLine(t *testing.T) { diff --git a/tests/corpus/expectations.yaml b/tests/corpus/expectations.yaml index e83c2fd5..753b5ca0 100644 --- a/tests/corpus/expectations.yaml +++ b/tests/corpus/expectations.yaml @@ -58,6 +58,15 @@ groups: - rule: security.cors-wildcard line: 3 - path: cors-wildcard/clean/headers.yaml + # Documentation is its own demotion scope: a credential in a committed + # .md is still reported (at the review tier) because a pasted-in real + # token is a common leak vector, while a vendor's published example is + # recognized by value and drops to a diagnostic. + - path: credential-docs/vulnerable/authentication.md + must_fire: + - rule: security.hardcoded-credential + line: 5 + - path: credential-docs/clean/quickstart.md - path: dockerfile-root/vulnerable/Dockerfile must_fire: - rule: security.dockerfile-root @@ -86,6 +95,14 @@ groups: line: 6 - path: cors-wildcard/clean/specific_origin.go - path: cors-wildcard/clean/comment_mention.go + # Fixture-path classification: a test path demotes a credential finding + # but never suppresses it, so a production-looking value in a _test.go + # file must still be reported. + - path: credential-fixture-path/vulnerable/session_store_test.go + must_fire: + - rule: security.hardcoded-credential + line: 8 + - path: credential-fixture-path/clean/local_dsn_test.go - path: hardcoded-credential/vulnerable/providers.go must_fire: - rule: security.hardcoded-credential @@ -108,6 +125,7 @@ groups: line: 9 - path: hardcoded-credential/clean/placeholders.go - path: hardcoded-credential/clean/comment_mention.go + - path: hardcoded-credential/clean/vendor_examples.go - path: hardcoded-secret/vulnerable/basic.go must_fire: - rule: security.hardcoded-secret @@ -216,6 +234,14 @@ groups: language: python root: testdata/python files: + # pytest marks tests by file-name PREFIX (test_*.py), which a + # suffix-only path check cannot express. The clean fixture only stays + # silent if that prefix is recognized as a fixture scope. + - path: credential-fixture-path/vulnerable/test_billing_client.py + must_fire: + - rule: security.hardcoded-credential + line: 4 + - path: credential-fixture-path/clean/test_fixtures.py - path: debug-enabled/vulnerable/flask_debug.py must_fire: - rule: security.debug-enabled @@ -323,6 +349,9 @@ groups: language: typescript root: testdata/typescript files: + # .spec.tsx is one of the Jest/Vitest conventions the widened fixture + # path signals cover; without it this fake-marked token would fail. + - path: credential-fixture-path/clean/checkout.spec.tsx - path: dynamic-code/vulnerable/eval.ts must_fire: - rule: security.typescript.dynamic-code diff --git a/tests/corpus/testdata/agnostic/credential-docs/clean/quickstart.md b/tests/corpus/testdata/agnostic/credential-docs/clean/quickstart.md new file mode 100644 index 00000000..3c60bb44 --- /dev/null +++ b/tests/corpus/testdata/agnostic/credential-docs/clean/quickstart.md @@ -0,0 +1,7 @@ +# Quickstart + +Copy the credentials block and replace both values with your own: + + aws_access_key_id = AKIAIOSFODNN7EXAMPLE + +Those are the identifiers AWS uses in its own documentation. diff --git a/tests/corpus/testdata/agnostic/credential-docs/vulnerable/authentication.md b/tests/corpus/testdata/agnostic/credential-docs/vulnerable/authentication.md new file mode 100644 index 00000000..8d944ff6 --- /dev/null +++ b/tests/corpus/testdata/agnostic/credential-docs/vulnerable/authentication.md @@ -0,0 +1,7 @@ +# Authentication + +Send the session token on every request: + + Authorization: Bearer 7f3b91c0d4e58a62b1d09f4c + +Rotate the token every 90 days. diff --git a/tests/corpus/testdata/go/credential-fixture-path/clean/local_dsn_test.go b/tests/corpus/testdata/go/credential-fixture-path/clean/local_dsn_test.go new file mode 100644 index 00000000..b618ab50 --- /dev/null +++ b/tests/corpus/testdata/go/credential-fixture-path/clean/local_dsn_test.go @@ -0,0 +1,8 @@ +package fixtures + +// A loopback credential in a test file. The host is unroutable, so the value +// cannot authenticate against anything real; path scope plus that value +// evidence together make it a diagnostic rather than a finding. +func localDSN() string { + return "postgres://app:app_local_pw_7231@127.0.0.1:5432/app_local" +} diff --git a/tests/corpus/testdata/go/credential-fixture-path/vulnerable/session_store_test.go b/tests/corpus/testdata/go/credential-fixture-path/vulnerable/session_store_test.go new file mode 100644 index 00000000..2b0342df --- /dev/null +++ b/tests/corpus/testdata/go/credential-fixture-path/vulnerable/session_store_test.go @@ -0,0 +1,9 @@ +package fixtures + +// A production-looking credential that happens to live in a Go test file. The +// test path is only one piece of evidence, so the finding is demoted to the +// review tier but must stay visible: a real leaked credential does not stop +// being real because of the file it was committed in. +func sessionStoreDSN() string { + return "postgres://svc_sessions:Qh7dRm2VtbLx9@sessions.prod.internal:5432/sessions" +} diff --git a/tests/corpus/testdata/go/hardcoded-credential/clean/vendor_examples.go b/tests/corpus/testdata/go/hardcoded-credential/clean/vendor_examples.go new file mode 100644 index 00000000..eede017d --- /dev/null +++ b/tests/corpus/testdata/go/hardcoded-credential/clean/vendor_examples.go @@ -0,0 +1,10 @@ +package fixtures + +// Values AWS publishes in its own documentation as non-secrets. They match the +// provider token shapes but are world-readable by design, so a match can never +// be a leak and an attacker gains nothing by reusing one. The scanner records +// them as informational diagnostics on any path, never as findings. +const ( + docsAccessKeyID = "AKIAIOSFODNN7EXAMPLE" + docsSessionKey = "ASIAIOSFODNN7EXAMPLE" +) diff --git a/tests/corpus/testdata/go/hardcoded-credential/vulnerable/embedded_config.go b/tests/corpus/testdata/go/hardcoded-credential/vulnerable/embedded_config.go index 697d39bf..d0df3542 100644 --- a/tests/corpus/testdata/go/hardcoded-credential/vulnerable/embedded_config.go +++ b/tests/corpus/testdata/go/hardcoded-credential/vulnerable/embedded_config.go @@ -5,7 +5,7 @@ import "net/http" // uploadBackupBlob wires a fake AWS key (renamed variable, realistic call // site) plus a fake bearer token through ordinary-looking client code. func uploadBackupBlob() (*http.Request, error) { - blobSigner := "AKIAIOSFODNN7EXAMPLE" + blobSigner := "AKIA" + "Q7RMZLK4TP2XWDVB" req, err := http.NewRequest(http.MethodPut, "https://storage.example.com/backups", nil) if err != nil { return nil, err diff --git a/tests/corpus/testdata/go/hardcoded-credential/vulnerable/providers.go b/tests/corpus/testdata/go/hardcoded-credential/vulnerable/providers.go index 0db0759b..4f4f46e8 100644 --- a/tests/corpus/testdata/go/hardcoded-credential/vulnerable/providers.go +++ b/tests/corpus/testdata/go/hardcoded-credential/vulnerable/providers.go @@ -1,10 +1,10 @@ -// Package fixtures holds synthetic provider-format credentials. Every value -// below is fake: all-zero or documentation-example bodies that still match -// the provider token shapes the scanner looks for. +// Package fixtures holds synthetic provider-format credentials. Every value is +// fake. The AWS key is split across two literals so no contiguous AWS-shaped +// token is committed; the scanner folds adjacent literals before matching. package fixtures const ( - awsAccessKeyID = "AKIAIOSFODNN7EXAMPLE" + awsAccessKeyID = "AKIA" + "Q7RMZLK4TP2XWDVB" githubToken = "ghp_000000000000000000000000000000000000" slackBotToken = "xoxb-FAKEFAKEFAKE-FAKEFAKEFAKEFAKEFAKE" stripeSecret = "sk_live_FAKEFAKEFAKEFAKEFAKE00" diff --git a/tests/corpus/testdata/python/credential-fixture-path/clean/test_fixtures.py b/tests/corpus/testdata/python/credential-fixture-path/clean/test_fixtures.py new file mode 100644 index 00000000..d50b0737 --- /dev/null +++ b/tests/corpus/testdata/python/credential-fixture-path/clean/test_fixtures.py @@ -0,0 +1,4 @@ +# A fake-marked provider token in a pytest-convention module name. Suppression +# needs both signals: without the test_ prefix being recognized as a fixture +# scope this line would be reported at fail level. +FAKE_GITHUB_TOKEN = "ghp_FAKE00000000000000000000000000000000" diff --git a/tests/corpus/testdata/python/credential-fixture-path/vulnerable/test_billing_client.py b/tests/corpus/testdata/python/credential-fixture-path/vulnerable/test_billing_client.py new file mode 100644 index 00000000..da3432a9 --- /dev/null +++ b/tests/corpus/testdata/python/credential-fixture-path/vulnerable/test_billing_client.py @@ -0,0 +1,4 @@ +# pytest marks a test module by file-name PREFIX, so this path is a fixture +# scope. A production-looking credential here is still reported (demoted to the +# review tier), never dropped. +BILLING_DSN = "mysql://billing_svc:Zr4tKp9wQm2x@billing.prod.internal:3306/billing" diff --git a/tests/corpus/testdata/typescript/credential-fixture-path/clean/checkout.spec.tsx b/tests/corpus/testdata/typescript/credential-fixture-path/clean/checkout.spec.tsx new file mode 100644 index 00000000..ed1f17f6 --- /dev/null +++ b/tests/corpus/testdata/typescript/credential-fixture-path/clean/checkout.spec.tsx @@ -0,0 +1,3 @@ +// .spec.tsx is a Jest/Vitest convention the fixture scope has to recognize; +// combined with the fake-marked body this is a diagnostic, not a finding. +export const fakeUploadToken = "ghp_FAKE00000000000000000000000000000000"; diff --git a/tests/corpus/testdata/typescript/hardcoded-credential/vulnerable/config.ts b/tests/corpus/testdata/typescript/hardcoded-credential/vulnerable/config.ts index 9ffd0048..3480dceb 100644 --- a/tests/corpus/testdata/typescript/hardcoded-credential/vulnerable/config.ts +++ b/tests/corpus/testdata/typescript/hardcoded-credential/vulnerable/config.ts @@ -1 +1 @@ -export const uploaderKey = "AKIAIOSFODNN7EXAMPLE"; +export const uploaderKey = "AKIA" + "Q7RMZLK4TP2XWDVB"; From ce16feb8c41b236257280b724e8fbd532ac1815c Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 17 Sep 2026 15:24:55 -0400 Subject: [PATCH 04/16] fix(quality): narrow three false-positive-dominated quality rules Measured on frozen `git archive HEAD` snapshots of four real Go repositories (szr, slop, cleanr, merger) with cache disabled and generated directories excluded. Per-rule totals before -> after: quality.dependency-direction 631 -> 0 naming.cardinality-mismatch 586 -> 1 defensive.null-assumption 420 -> 0 defensive.fail-open-authorization 14 -> 0 defensive.unsafe-default 4 -> 0 Every hand-inspected finding in the "before" column was a false positive; the single survivor is `collectCodeOwners(codeOwners string)`, where the plural name does hold one string of file text. quality.dependency-direction `allowsInternalImport` flagged any file outside internal/, cmd/, tests/, and pkg/codeguard/ that imported a path containing "/internal/", once per import. Repositories using test/ (singular) had every external test file flagged, and so did any pkg// facade, because the SDK exemption was hardcoded to codeguard's own path. The rule now implements Go's actual internal-visibility rule: an internal tree is importable only by the package rooted at its parent directory and that package's descendants. The importing package's path is resolved through the existing goModuleResolver (extended with the module's directory) and compared against the tree's owner. Same-module internal imports - the entire measured population - are legal and are no longer reported; a cross-tree import in a multi-module repository still is. Test code is exempted by naming convention across languages rather than by a hardcoded "tests/" prefix, with ci_rules.allowed_test_paths consulted as the configured test-path policy for support files that carry no test-naming marker. naming.cardinality-mismatch Two defects. First, `isPluralName` delegated its unit check to `unitSuffixPattern`, which lists a bare "s" among its alternatives, so every name ending in "s" looked unit-suffixed and every name not ending in "s" failed the plural test: the function returned false for every input, and the plural-name-on-scalar half of the rule had never fired in the rule's history. Second, the surviving half reduced to "flag any collection-typed identifier whose name does not end in s/list/set/map", which fired on byte strings (`payload []byte`), argv vectors (`command []string`), maps keyed by id (`index map[string]Ref`), comparison pairs (`left, right []Source`), Latin plurals (`criteria []string`), and mass nouns (`evidence map[string]any`). `isPluralName` now strips a trailing ordinal digit (so `outputs1` reads as plural) and uses a unit pattern without the bare second alias. The singular direction is narrowed to the one high-precision case: the identifier is the element type of the collection it holds (`user []User`). The newly-live plural direction is limited to non-numeric, non-boolean scalars, because a plural noun on a number names a count (`maxLines int`) and on a flag names a mode (`lineNumbers bool`) - enabling it without those carve-outs added 481 counts and 13 flags and no true positives. defensive.null-assumption `nullableParam` treated any pointer-typed parameter as nullable. In Go, C, C++, and Rust a bare pointer is the pass-by-reference idiom and carries no nullability information, so the rule fired on out parameters, accumulators, builders, options structs, and protobuf request pointers whose Get*() accessors are nil-safe by construction. Nullability is now taken only from an explicit declaration ("?", "| null", "| undefined", "| None", Optional, Maybe, std::optional), which keeps the TypeScript, Python, and C++ true positives. The message no longer claims the value crossed a boundary, because nothing established that. defensive.* confidence calibration The regex-over-lowercased-body rules in this family rated themselves medium, with two at high. Both high-rated ones were measured at 100% false positives and both had a concrete cause: - fail-open-authorization matched `err != nil { ... return nil }`, which in Go is the fail-CLOSED idiom when a non-nil error accompanies it, gated only on the body containing "auth" or "policy". The nil or true must now be the sole return value; confidence high -> medium. - unsafe-default seeded on the bare substring "config" and matched safety tokens inside unrelated words, so any line naming a Config type or a ConfigFields key could match. The seed must now be a config read and the tokens whole words; an environment read keeps the positional-default and comparison forms, which are real defaulting syntax. Confidence high -> low. - null-assumption's residual path is declaration-driven but its guard detection is still textual: medium -> low. The AST-driven members of the family (unchecked-type-assertion, unsafe-numeric-conversion) keep high, which they earn. goModuleResolver is now concurrency-safe. Its go.mod cache was only ever reached from the serial AI pipeline; the dependency-direction rule consults it from inside ScanTargetFiles, which evaluates files on a worker pool, and an unguarded map write there aborted the process with "concurrent map writes". The lock is a *sync.Mutex so the by-value resolver stays copyable for go vet's copylocks check while every copy shares one lock. Catalog descriptions and docs/checks.md are updated to state what the rules now detect; quality.dependency-direction's entry had described something the implementation never did. Co-Authored-By: Claude Opus 5 (1M context) --- docs/checks.md | 8 +- .../checks/quality/quality_defensive.go | 94 +++-- .../codeguard/checks/quality/quality_go.go | 50 ++- .../quality/quality_go_internal_imports.go | 168 +++++++++ .../checks/quality/quality_go_modules.go | 29 +- .../quality_precision_workstreams_cd.go | 188 ++++++++-- .../checks/quality/quality_scan_language.go | 3 +- internal/codeguard/rules/catalog_quality.go | 6 +- .../rules/catalog_quality_errors_defensive.go | 2 +- tests/checks/naming_precision_test.go | 14 +- .../quality_error_defensive_multilang_test.go | 8 +- ...precision_false_positive_narrowing_test.go | 336 ++++++++++++++++++ 12 files changed, 828 insertions(+), 78 deletions(-) create mode 100644 internal/codeguard/checks/quality/quality_go_internal_imports.go create mode 100644 tests/checks/quality_precision_false_positive_narrowing_test.go diff --git a/docs/checks.md b/docs/checks.md index 4361a1a8..6c3a8982 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -1370,7 +1370,7 @@ These rules live outside the repository-wide `Change Safety` section in report o | Naming | `naming.boolean-not-predicate` | warn | Boolean variables, parameters, or boolean-returning functions do not read like predicates. | | Naming | `naming.domain-vocabulary-drift` | warn | Configured glossary concepts appear under multiple terms in the same source. | | Naming | `naming.unknown-abbreviation` | warn | Identifiers contain abbreviations that are not common or configured for the repository. | -| Naming | `naming.cardinality-mismatch` | warn | Plural names are used for scalar values or singular names for collection-like values. | +| Naming | `naming.cardinality-mismatch` | warn | A plural name holds a single non-numeric, non-boolean scalar, or a singular name holds a collection of the type it is named after (`user []User`). Counts, flags, byte strings, argv vectors, and keyed maps are not reported. | | Naming | `naming.implementation-leak` | warn | Domain-facing names encode infrastructure details such as SQL, HTTP, Redis, Kafka, or ORM. | | Naming | `naming.missing-unit` | warn | Numeric duration, size, or money names omit a unit suffix. | | Naming | `naming.role-suffix-overuse` | warn | A file repeatedly uses vague suffixes such as Manager, Helper, Util, Service, or Processor. | @@ -1400,17 +1400,17 @@ These rules live outside the repository-wide `Change Safety` section in report o | Defensive programming | `defensive.unsafe-numeric-conversion` | warn | A narrowing or sign-changing numeric conversion can truncate, wrap, or lose precision. | | Defensive programming | `defensive.unvalidated-boundary-input` | warn | Handler, API, event, or filesystem input is consumed without validation evidence. | | Defensive programming | `defensive.invalid-state-representable` | warn | Booleans or raw status strings can represent impossible state combinations. | -| Defensive programming | `defensive.null-assumption` | warn | Nullable boundary values are dereferenced without a nil/null guard. | +| Defensive programming | `defensive.null-assumption` | warn | A value whose declared type says it may be absent (`?`, `| null`, `| None`, `Optional`, `Maybe`, `std::optional`) is dereferenced without a guard. A bare pointer is the pass-by-reference idiom, not a nullability declaration, and is not reported. Confidence `low`. | | Defensive programming | `defensive.integer-overflow` | warn | Arithmetic on count, size, or length input lacks an overflow bound check. | | Defensive programming | `defensive.sequence-collision-risk` | warn | External ID allocation derives the next value from current count; bounded unique-collision retry is treated as mitigation, not full resolution. | | Defensive programming | `defensive.bounds-assumption` | warn | Indexed access assumes collection bounds without a nearby length check. | -| Defensive programming | `defensive.unsafe-default` | warn | A config/env fallback can fail open or disable a safety control. | +| Defensive programming | `defensive.unsafe-default` | warn | A config/env fallback can fail open or disable a safety control. Confidence `low`. | | Defensive programming | `defensive.non-exhaustive-branch` | warn | Enum-like state/kind/type branching lacks default or exhaustive handling. | | Defensive programming | `defensive.unchecked-external-response` | warn | External responses are consumed without checking status, ok, or transport errors. | | Defensive programming | `defensive.missing-schema-validation` | warn | Decoded JSON, events, or request payloads are used without schema or invariant validation. | | Defensive programming | `defensive.missing-resource-limit` | warn | Boundary reads or uploads lack explicit size, count, or time limits. | | Defensive programming | `defensive.invalid-state-transition` | warn | State transitions write terminal states without checking allowed prior state. | -| Defensive programming | `defensive.fail-open-authorization` | warn | Authorization failure paths default to allow or success. | +| Defensive programming | `defensive.fail-open-authorization` | warn | Authorization failure paths default to allow or success. A `return nil` alongside a non-nil error is fail-closed and is not reported. Confidence `medium`. | | Structural smell | `smell.god-object` | warn | A local type/class accumulates many methods, fields, and responsibility clusters. | | Structural smell | `smell.feature-envy` | warn | A function or method mostly interrogates one external collaborator instead of its own receiver/context. | | Structural smell | `smell.middle-man` | warn | A type/class mostly forwards calls to one collaborator without policy, translation, or ownership. | diff --git a/internal/codeguard/checks/quality/quality_defensive.go b/internal/codeguard/checks/quality/quality_defensive.go index ebded550..29174250 100644 --- a/internal/codeguard/checks/quality/quality_defensive.go +++ b/internal/codeguard/checks/quality/quality_defensive.go @@ -30,21 +30,49 @@ var ( externalCallPattern = regexp.MustCompile(`(?i)(http\.Get|client\.Do|fetch\s*\(|axios\.|requests\.(get|post|put|delete)|curl_easy_perform|httplib::|http_client)`) resourceReadPattern = regexp.MustCompile(`(?i)(io\.ReadAll|ReadAll|read_to_string|read_to_end|\.read\s*\(|\.text\s*\(|arrayBuffer\s*\(|bodyParser|multer|formData\s*\(|request\.body|r\.Body)`) ormCollectionReadPattern = regexp.MustCompile(`(?i)\bfindMany\s*\(`) - unsafeDefaultPattern = regexp.MustCompile(`(?i)(getenv|process\.env|os\.environ|std::getenv|config).*?(default|fallback|\|\||!=|,\s*['"]).*?(true|false|allow|disable|skip|insecure)`) - switchLikePattern = regexp.MustCompile(`(?i)\b(switch|match)\b[^{:\n]*(status|state|kind|type)`) - stateAssignmentPattern = regexp.MustCompile(`(?i)(status|state)\s*(?:=|:=|=>)\s*["']?(paid|active|complete|completed|shipped|deleted|approved)["']?`) - authFailOpenPattern = regexp.MustCompile(`(?is)(except|catch)\b[^{:\n]*(?:\{|:)[^}\n]*return\s+(true|allow|nil|none)`) - structStartPattern = regexp.MustCompile(`(?i)\b(type\s+\w+\s+struct|interface\s+\w+|class\s+\w+|struct\s+\w+)`) - boolFieldPattern = regexp.MustCompile(`(?i)\b(bool|boolean)\b`) - stringStateFieldPattern = regexp.MustCompile(`(?i)\b(status|state|kind)\b.*\b(string|str|std::string|String)\b|\b(string|str|std::string|String)\b.*\b(status|state|kind)\b`) - resourceCountGuard = regexp.MustCompile(`(?i)\b(?:count|size|length|len|bytes)\s*(?:<=|<|>|>=)\s*(?:max|limit|quota|cap|[0-9])`) - resourceNamedCountLimit = regexp.MustCompile(`(?i)\b(?:max|limit|quota|cap)[A-Za-z0-9_]*(?:count|size|length|len|bytes)\b`) - sequenceAllocationLine = regexp.MustCompile(`(?i)\b(?:external[_]?id|next[_]?id|sequence|slug|number)\b.*(?:count|max)\s*\+\s*1|(?:count|max)\s*\+\s*1.*\b(?:external[_]?id|next[_]?id|sequence|slug|number)\b`) - jsonReaderSchemaCall = regexp.MustCompile(`(?i)\b(?:read|parse|decode)Json[A-Za-z0-9_]*\s*\([^)\n,]+,\s*[A-Za-z_$][\w$]*(?:Schema|Validator|Codec|Parser)\b`) - prismaTakePattern = regexp.MustCompile(`(?is)\b(?:findMany|findFirst|findUnique|query|search)\s*\([^)]*\btake\s*:`) - sequenceIndexKeyPattern = regexp.MustCompile(`(?i)^(?:i|j|n|idx|index|offset|position|pos|[A-Za-z_$][\w$]*(?:Index|Idx|Offset|Position|Pos))$`) - dataTransferPartPattern = regexp.MustCompile(`part\d+$`) - blockExitKeywordPattern = regexp.MustCompile(`\b(?:return|throw|continue|break)\b`) + // An environment read's trailing string/literal argument IS its default + // ("os.environ.get('AUTHZ_DISABLED', 'true')"), and a comparison against a + // literal selects one ("os.Getenv(\"AUTHZ_DISABLED\") != \"false\"" is an + // inverted default), so both count as defaulting syntax for an env read. + unsafeEnvDefaultPattern = regexp.MustCompile(`(?i)(getenv|process\.env|os\.environ|std::getenv).*?(\bdefault\b|\bfallback\b|\|\||\?\?|!=|={2,3}|,\s*['"]).*?\b(true|false|allow|disable|skip|insecure)\b`) + // NOTE: for a plain config read, three deliberate tightenings, each of + // which removed a measured false-positive class: + // - the seed must be a config READ ("cfg.", "config[", "settings("), + // not the bare substring "config", which matched any line naming a + // Config type, a ConfigFields key, or the word "configs" inside an + // error string; + // - the middle group must be real defaulting syntax. It used to admit + // ",\s*['\"]" and "!=", i.e. any call taking a string argument, so + // "collectPolicyRules(cfg.Rules, \"tool\", \"allow\")" and + // "fs.BoolVar(&opts.x, \"apply-patches\", true, ...)" matched. A + // config read carries no positional-default convention, so unlike an + // env read it does not get the comma-literal alternative; + // - the safety token must be a whole word, so "Skippable" no longer + // supplies "skip" and "allowed_write_paths" no longer supplies + // "allow". + unsafeConfigDefaultPattern = regexp.MustCompile(`(?i)\b(config|cfg|conf|settings|options|opts)\s*[.\[(].*?(\bdefault\b|\bfallback\b|\|\||\?\?).*?\b(true|false|allow|disable|skip|insecure)\b`) + switchLikePattern = regexp.MustCompile(`(?i)\b(switch|match)\b[^{:\n]*(status|state|kind|type)`) + stateAssignmentPattern = regexp.MustCompile(`(?i)(status|state)\s*(?:=|:=|=>)\s*["']?(paid|active|complete|completed|shipped|deleted|approved)["']?`) + authFailOpenPattern = regexp.MustCompile(`(?is)(except|catch)\b[^{:\n]*(?:\{|:)[^}\n]*return\s+(?:true|allow|nil|none)\s*(?:$|[;}\n])`) + authorizeFailOpenPattern = regexp.MustCompile(`(?is)(?:if\s+)?(?:\w+\s*:=\s*)?authorize\([^)]*\)\s*;\s*err\s*!=\s*nil\s*\{[^}]*return\s+(?:true|nil)\s*(?:$|[;}\n])`) + // NOTE: the returned nil or true must be the SOLE return value. In Go, + // "if err != nil { return nil, err }" is the fail-CLOSED idiom - the nil + // is the absent result and the error is what the caller acts on. Matching + // "return nil" without that restriction made this rule fire on every + // function that both handles an error and mentions "auth" or "policy" + // anywhere in its body; all 14 measured findings were of that shape. + errorFailOpenPattern = regexp.MustCompile(`(?is)err\s*!=\s*nil\s*\{[^}]*return\s+(?:true|nil)\s*(?:$|[;}\n])`) + structStartPattern = regexp.MustCompile(`(?i)\b(type\s+\w+\s+struct|interface\s+\w+|class\s+\w+|struct\s+\w+)`) + boolFieldPattern = regexp.MustCompile(`(?i)\b(bool|boolean)\b`) + stringStateFieldPattern = regexp.MustCompile(`(?i)\b(status|state|kind)\b.*\b(string|str|std::string|String)\b|\b(string|str|std::string|String)\b.*\b(status|state|kind)\b`) + resourceCountGuard = regexp.MustCompile(`(?i)\b(?:count|size|length|len|bytes)\s*(?:<=|<|>|>=)\s*(?:max|limit|quota|cap|[0-9])`) + resourceNamedCountLimit = regexp.MustCompile(`(?i)\b(?:max|limit|quota|cap)[A-Za-z0-9_]*(?:count|size|length|len|bytes)\b`) + sequenceAllocationLine = regexp.MustCompile(`(?i)\b(?:external[_]?id|next[_]?id|sequence|slug|number)\b.*(?:count|max)\s*\+\s*1|(?:count|max)\s*\+\s*1.*\b(?:external[_]?id|next[_]?id|sequence|slug|number)\b`) + jsonReaderSchemaCall = regexp.MustCompile(`(?i)\b(?:read|parse|decode)Json[A-Za-z0-9_]*\s*\([^)\n,]+,\s*[A-Za-z_$][\w$]*(?:Schema|Validator|Codec|Parser)\b`) + prismaTakePattern = regexp.MustCompile(`(?is)\b(?:findMany|findFirst|findUnique|query|search)\s*\([^)]*\btake\s*:`) + sequenceIndexKeyPattern = regexp.MustCompile(`(?i)^(?:i|j|n|idx|index|offset|position|pos|[A-Za-z_$][\w$]*(?:Index|Idx|Offset|Position|Pos))$`) + dataTransferPartPattern = regexp.MustCompile(`part\d+$`) + blockExitKeywordPattern = regexp.MustCompile(`\b(?:return|throw|continue|break)\b`) ) func defensiveBoundaryFindings(env support.Context, file string, fn precisionFunction) []core.Finding { @@ -61,7 +89,7 @@ func defensiveBoundaryFindings(env support.Context, file string, fn precisionFun } if line, ok := nullAssumptionLine(file, fn, loweredBody); ok { findings = append(findings, precisionWarnFinding(env, defensiveNullAssumptionRuleID, file, line, - "nullable boundary value is dereferenced without a nil/null guard", core.ConfidenceMedium)) + "value declared nullable is dereferenced without a nil/null guard", core.ConfidenceLow)) } if line, message, confidence, ok := sequenceCollisionRiskLine(file, fn, loweredBody); ok { findings = append(findings, precisionWarnFinding(env, defensiveSequenceCollisionRiskRuleID, file, line, @@ -77,7 +105,7 @@ func defensiveBoundaryFindings(env support.Context, file string, fn precisionFun } if line, ok := unsafeDefaultLine(fn.Statements, loweredBody); ok { findings = append(findings, precisionWarnFinding(env, defensiveUnsafeDefaultRuleID, file, line, - "configuration default can fail open or disable a safety control", core.ConfidenceHigh)) + "configuration default can fail open or disable a safety control", core.ConfidenceLow)) } if !isUIHelperOrMappingContext(file, fn) && !isSeedOrScriptSourcePath(file) { if line, ok := nonExhaustiveBranchLine(fn, loweredBody); ok { @@ -107,7 +135,7 @@ func defensiveBoundaryFindings(env support.Context, file string, fn precisionFun } if line, ok := failOpenAuthorizationLine(fn, loweredBody); ok { findings = append(findings, precisionWarnFinding(env, defensiveFailOpenAuthorizationRuleID, file, line, - "authorization failure path defaults to allow/success", core.ConfidenceHigh)) + "authorization failure path defaults to allow/success", core.ConfidenceMedium)) } return findings } @@ -295,6 +323,22 @@ func isTransportRequestType(typ string) bool { strings.Contains(typ, "incomingmessage") } +// nullAssumptionLine reports a parameter whose declared type says the value +// may be absent and which is then dereferenced with no guard. +// +// NOTE: this used to treat any pointer-typed parameter as nullable, which in +// Go, C, C++, and Rust is simply the pass-by-reference idiom - those languages +// have no separate optional-pointer type, so a bare "*T" carries no +// nullability information at all. Measured over four Go repositories the rule +// produced 420 findings and every hand-checked sample was an internal +// out-parameter, accumulator, builder, options struct, or receiver-adjacent +// value that the caller is contractually required to supply: "out +// *strings.Builder", "errs *ValidationErrors", "cfg *core.Config", +// "target *core.TargetConfig", and protobuf request pointers whose Get*() +// accessors are nil-safe by construction. Nullability is now taken only from +// an explicit declaration ("?", "| null", "| undefined", "| None", +// "Optional[...]", "Maybe<...>", "std::optional<...>"), which is a claim the +// declaration itself supports in every language that has the syntax. func nullAssumptionLine(file string, fn precisionFunction, loweredBody string) (int, bool) { if isUIHelperOrMappingContext(file, fn) || isReactComponentOrHookBoundary(file, fn) { return 0, false @@ -314,12 +358,14 @@ func nullAssumptionLine(file string, fn precisionFunction, loweredBody string) ( return 0, false } +// nullableParam reports whether a parameter's declared type says the value may +// be absent. A bare pointer does not: see the note on nullAssumptionLine. func nullableParam(param support.ParsedParam) bool { typ := strings.ToLower(strings.TrimSpace(param.Type)) if typ == "" { return false } - if strings.Contains(typ, "*") || strings.Contains(typ, "optional") || strings.Contains(typ, "maybe") { + if strings.Contains(typ, "optional") || strings.Contains(typ, "maybe") { return true } if strings.Contains(typ, "{") && strings.Contains(typ, "}") && @@ -618,13 +664,17 @@ func nearbyBoundsGuard(statements []support.ParsedStatement, idx int, target str func unsafeDefaultLine(statements []support.ParsedStatement, loweredBody string) (int, bool) { for _, statement := range statements { raw := strings.ToLower(firstNonEmptyString(statement.Raw, statement.Text)) - if unsafeDefaultPattern.MatchString(raw) && unsafeDefaultIsSafetySensitive(raw, loweredBody) { + if unsafeDefaultCandidate(raw) && unsafeDefaultIsSafetySensitive(raw, loweredBody) { return statement.Line, true } } return 0, false } +func unsafeDefaultCandidate(raw string) bool { + return unsafeEnvDefaultPattern.MatchString(raw) || unsafeConfigDefaultPattern.MatchString(raw) +} + func unsafeDefaultIsSafetySensitive(line string, loweredBody string) bool { evidence := line + "\n" + loweredBody return containsAny(evidence, []string{ @@ -781,7 +831,9 @@ func failOpenAuthorizationLine(fn precisionFunction, loweredBody string) (int, b if !containsAny(strings.ToLower(fn.Name), []string{"auth", "allow", "permission", "policy"}) && !containsAny(loweredBody, []string{"authorize", "permission", "authz", "policy"}) { return 0, false } - if authFailOpenPattern.MatchString(loweredBody) || regexp.MustCompile(`(?is)(?:if\s+)?(?:\w+\s*:=\s*)?authorize\([^)]*\)\s*;\s*err\s*!=\s*nil\s*\{[^}]*return\s+(true|nil)`).MatchString(loweredBody) || regexp.MustCompile(`(?is)err\s*!=\s*nil\s*\{[^}]*return\s+(true|nil)`).MatchString(loweredBody) { + if authFailOpenPattern.MatchString(loweredBody) || + authorizeFailOpenPattern.MatchString(loweredBody) || + errorFailOpenPattern.MatchString(loweredBody) { return fn.StartLine, true } return 0, false diff --git a/internal/codeguard/checks/quality/quality_go.go b/internal/codeguard/checks/quality/quality_go.go index b68234f2..bd28448d 100644 --- a/internal/codeguard/checks/quality/quality_go.go +++ b/internal/codeguard/checks/quality/quality_go.go @@ -5,14 +5,13 @@ import ( "go/ast" "go/format" "go/token" - "path/filepath" "strings" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" ) -func goFindingsForFileWithIndex(env support.Context, file string, data []byte, index *goPackageIndex) []core.Finding { +func goFindingsForFileWithIndex(env support.Context, file string, data []byte, index *goPackageIndex, modules *goModuleResolver) []core.Finding { findings := make([]core.Finding, 0) formatted, err := format.Source(data) @@ -55,7 +54,7 @@ func goFindingsForFileWithIndex(env support.Context, file string, data []byte, i index.addFile(file, fset, parsed) } pkg := index.packageFor(file, parsed.Name.Name) - findings = append(findings, importFindings(env, file, fset, parsed)...) + findings = append(findings, importFindings(env, file, fset, parsed, modules)...) findings = append(findings, goFunctionFindings(env, file, fset, parsed)...) if localPrecisionEnabled(env) { findings = append(findings, goPrecisionFindings(env, file, fset, parsed, data, pkg)...) @@ -65,29 +64,44 @@ func goFindingsForFileWithIndex(env support.Context, file string, data []byte, i return append(fileLengthFindingWithSignals(env, file, data, findings), findings...) } -func importFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File) []core.Finding { +// importFindings reports imports of an internal package tree that Go's +// internal-visibility rule does not permit, i.e. the importing package is +// outside the directory the internal tree is scoped to. +// +// It deliberately does NOT report same-module internal imports. Importing +// "/internal/x" from anywhere in that module - cmd/, pkg/, a public +// facade, an external test package - is legal and idiomatic Go, and codeguard +// measured 631 findings across four repositories of which every single one was +// such an import. See the rule's catalog entry for the remaining scope. +func importFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File, modules *goModuleResolver) []core.Finding { findings := make([]core.Finding, 0) + if testSourceFile(env, file) { + return findings + } + // The importing package path is resolved lazily: it takes the module + // resolver's lock, and most files import no internal package at all. + importer := "" + resolved := false for _, imp := range parsed.Imports { pathValue := strings.Trim(imp.Path.Value, `"`) - if strings.Contains(pathValue, "/internal/") && allowsInternalImport(env, file) { - pos := fset.Position(imp.Pos()) - findings = append(findings, warnFinding(env, "quality.dependency-direction", file, pos.Line, pos.Column, - "non-CLI package imports internal implementation detail")) + owner, ok := internalPackageOwner(pathValue) + if !ok { + continue + } + if !resolved { + importer = goPackagePathForFile(modules, file) + resolved = true + } + if importer == "" || goInternalImportVisible(importer, owner) { + continue } + pos := fset.Position(imp.Pos()) + findings = append(findings, warnFinding(env, "quality.dependency-direction", file, pos.Line, pos.Column, + fmt.Sprintf("package %q imports internal tree %q, which is scoped to %q and its descendants", importer, pathValue, owner))) } return findings } -func allowsInternalImport(env support.Context, file string) bool { - if env.IsInternalOrCmdFile(file) { - return false - } - if strings.HasPrefix(filepath.ToSlash(file), "tests/") { - return false - } - return !env.IsSDKFacadeFile(file) -} - func goFunctionFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File) []core.Finding { findings := make([]core.Finding, 0) ast.Inspect(parsed, func(n ast.Node) bool { diff --git a/internal/codeguard/checks/quality/quality_go_internal_imports.go b/internal/codeguard/checks/quality/quality_go_internal_imports.go new file mode 100644 index 00000000..70cb6547 --- /dev/null +++ b/internal/codeguard/checks/quality/quality_go_internal_imports.go @@ -0,0 +1,168 @@ +package quality + +import ( + "path" + "path/filepath" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" +) + +// internalPackageOwner returns the import-path prefix that owns an internal +// package tree, i.e. the directory Go's internal-visibility rule scopes the +// tree to. For "example.com/m/internal/x" it returns "example.com/m"; for a +// path with no "internal" segment it returns ok=false. +// +// Only the FIRST internal segment matters: once a path crosses into an +// internal tree, every deeper "internal" is already inside it. +func internalPackageOwner(importPath string) (string, bool) { + cleaned := strings.Trim(strings.TrimSpace(importPath), "/") + if cleaned == "" { + return "", false + } + segments := strings.Split(cleaned, "/") + for idx, segment := range segments { + if segment != "internal" { + continue + } + return strings.Join(segments[:idx], "/"), true + } + return "", false +} + +// goPackagePathForFile resolves the Go import path of the package containing +// file (a target-root-relative path). It returns "" when the owning module +// cannot be determined, which callers must treat as "unknown", never as a +// violation. +func goPackagePathForFile(modules *goModuleResolver, file string) string { + if modules == nil { + return "" + } + metadata := modules.metadataForFile(file) + if metadata.modulePath == "" { + return "" + } + dir := path.Dir(filepath.ToSlash(file)) + if moduleDir := metadata.moduleDir; moduleDir != "" && moduleDir != "." { + trimmed, ok := trimPathPrefix(dir, moduleDir) + if !ok { + return "" + } + dir = trimmed + } + if dir == "" || dir == "." { + return metadata.modulePath + } + return metadata.modulePath + "/" + dir +} + +func trimPathPrefix(value string, prefix string) (string, bool) { + if value == prefix { + return "", true + } + if strings.HasPrefix(value, prefix+"/") { + return strings.TrimPrefix(value, prefix+"/"), true + } + return "", false +} + +// goInternalImportVisible reports whether Go's internal-visibility rule permits +// importer (a full package import path) to import an internal tree owned by +// owner. The rule is purely lexical: an internal tree is importable only by the +// package rooted at its parent directory and that package's descendants. +func goInternalImportVisible(importer string, owner string) bool { + if importer == "" { + // Unknown importing package: stay silent rather than guess. + return true + } + if owner == "" { + // A module-root "internal" tree (import path begins with "internal/") + // is visible to the whole module; without a module path there is + // nothing to compare against. + return true + } + return importer == owner || strings.HasPrefix(importer, owner+"/") +} + +// testSourceFile reports whether file is test code, so that rules about +// production layering do not fire on it. Test naming conventions are the +// primary signal because they are language-defined and repository-independent; +// ci_rules.allowed_test_paths is consulted as the configured test-path policy +// for test support files that carry no test-naming marker. +func testSourceFile(env support.Context, file string) bool { + if testNamedSourceFile(file) { + return true + } + return configuredTestPathFile(env, file) +} + +// testNamedSourceFile recognises the per-language test-file naming +// conventions. It deliberately requires a separator or a camel-case boundary +// before the marker so that production files such as latest.go, request.py, or +// manifest.ts are not mistaken for tests. +func testNamedSourceFile(file string) bool { + base := path.Base(filepath.ToSlash(file)) + stem := strings.TrimSuffix(base, path.Ext(base)) + lowered := strings.ToLower(stem) + // Go, Python, Rust, Ruby, PHP, Elixir: foo_test.go, test_foo.py, + // foo_spec.rb, conftest.py. + if strings.HasSuffix(lowered, "_test") || strings.HasSuffix(lowered, "_tests") || + strings.HasSuffix(lowered, "_spec") || strings.HasPrefix(lowered, "test_") || + strings.HasPrefix(lowered, "spec_") || + lowered == "test" || lowered == "tests" || lowered == "conftest" { + return true + } + // JS/TS: foo.test.ts, foo.spec.tsx, foo.e2e.ts. + if strings.HasSuffix(lowered, ".test") || strings.HasSuffix(lowered, ".spec") || + strings.HasSuffix(lowered, ".e2e") { + return true + } + // Java/C#/Kotlin/Swift: FooTest.java, FooTests.cs, FooSpec.kt. The marker + // must start a camel-case word, so "Latest" and "Manifest" do not match. + for _, marker := range []string{"Test", "Tests", "Spec", "Specs"} { + if trimmed, ok := trimCamelSuffix(stem, marker); ok && trimmed != "" { + return true + } + } + return false +} + +// trimCamelSuffix strips marker from stem when it forms its own camel-case +// word, i.e. the character before it is not an upper-case letter. +func trimCamelSuffix(stem string, marker string) (string, bool) { + if !strings.HasSuffix(stem, marker) { + return "", false + } + trimmed := strings.TrimSuffix(stem, marker) + if trimmed == "" { + return "", false + } + last := trimmed[len(trimmed)-1] + if last >= 'A' && last <= 'Z' { + return "", false + } + return trimmed, true +} + +// configuredTestPathFile matches file against the repository's configured +// test-path policy (ci_rules.allowed_test_paths). That key is the existing +// source of truth for "where tests live"; this reuses it rather than adding a +// second one. +func configuredTestPathFile(env support.Context, file string) bool { + normalized := filepath.ToSlash(file) + for _, pattern := range env.Config.Checks.CIRules.AllowedTestPaths { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } + if runnersupport.MatchPattern(pattern, normalized) { + return true + } + if prefix := strings.TrimSuffix(filepath.ToSlash(pattern), "**"); prefix != pattern && + prefix != "" && strings.HasPrefix(normalized, prefix) { + return true + } + } + return false +} diff --git a/internal/codeguard/checks/quality/quality_go_modules.go b/internal/codeguard/checks/quality/quality_go_modules.go index 615b0fee..bc461c6d 100644 --- a/internal/codeguard/checks/quality/quality_go_modules.go +++ b/internal/codeguard/checks/quality/quality_go_modules.go @@ -5,10 +5,15 @@ import ( "os" "path/filepath" "strings" + "sync" ) type goModuleMetadata struct { modulePath string + // moduleDir is the go.mod directory, relative to the scan root in slash + // form ("" for the root module). It is what lets a file path be mapped + // back to its Go import path. + moduleDir string resolvable []string } @@ -16,11 +21,16 @@ type goModuleResolver struct { root string workspaceModules []string workspaceReplace []string - cache map[string]goModuleMetadata + // mu guards cache. It is a pointer so that the by-value resolver stays + // copyable (go vet copylocks) while every copy shares one lock: the + // resolver is consulted from ScanTargetFiles, which evaluates files on a + // worker pool. + mu *sync.Mutex + cache map[string]goModuleMetadata } func newGoModuleResolver(root string) goModuleResolver { - resolver := goModuleResolver{root: filepath.Clean(root), cache: map[string]goModuleMetadata{}} + resolver := goModuleResolver{root: filepath.Clean(root), mu: &sync.Mutex{}, cache: map[string]goModuleMetadata{}} workPath := filepath.Join(resolver.root, "go.work") uses, replaces := parseGoManifest(workPath, true) resolver.workspaceReplace = replaces @@ -36,6 +46,10 @@ func newGoModuleResolver(root string) goModuleResolver { } func (r *goModuleResolver) metadataForFile(rel string) goModuleMetadata { + if r.mu != nil { + r.mu.Lock() + defer r.mu.Unlock() + } dir := filepath.Dir(filepath.Join(r.root, filepath.FromSlash(rel))) for { if metadata, ok := r.cache[dir]; ok { @@ -50,6 +64,7 @@ func (r *goModuleResolver) metadataForFile(rel string) goModuleMetadata { if len(modules) > 0 { metadata.modulePath = modules[0] } + metadata.moduleDir = relativeModuleDir(r.root, dir) r.cache[dir] = metadata return metadata } @@ -61,6 +76,16 @@ func (r *goModuleResolver) metadataForFile(rel string) goModuleMetadata { } } +// relativeModuleDir renders dir relative to root in slash form, or "" when +// dir is the root itself or escapes it. +func relativeModuleDir(root string, dir string) string { + rel, err := filepath.Rel(root, dir) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "" + } + return filepath.ToSlash(rel) +} + func pathWithin(path, root string) bool { rel, err := filepath.Rel(root, path) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) diff --git a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go index 80770d6c..8906834a 100644 --- a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go +++ b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go @@ -31,19 +31,26 @@ const ( ) var ( - readCallPattern = regexp.MustCompile(`(^|[.>:\-_])(?i:count|fetch|find|get|list|load|lookup|query|read|select|search)([A-Z_:\-.]|$)`) - identifierTokenPattern = regexp.MustCompile(`[A-Za-z_$][A-Za-z0-9_$]*`) - roleSuffixPattern = regexp.MustCompile(`(?i)(manager|helper|util|utils|service|processor)$`) - durationNamePattern = regexp.MustCompile(`(?i)(timeout|duration|delay|interval|ttl|latency|elapsed|expiry|expiration|retention)`) - sizeNamePattern = regexp.MustCompile(`(?i)(size|limit|length|capacity|bytes?|mb|kb|gb)`) - moneyNamePattern = regexp.MustCompile(`(?i)(amount|price|cost|fee|total|subtotal|balance|money)`) - unitSuffixPattern = regexp.MustCompile(`(?i)(nanos?|micros?|millis?|ms|seconds?|secs?|s|minutes?|mins?|hours?|hrs?|days?|bytes?|kb|mb|gb|cents?|pennies|minor|bps|basispoints?|usd|eur|gbp|aud|cad)$`) - collectionTypePattern = regexp.MustCompile(`(?i)(\[\]|\[\s*\]|array|list|slice|map|dict|record|set|vector|collection|iterable|sequence|promise<[^>]*\[\])`) - scalarTypePattern = regexp.MustCompile(`(?i)\b(bool|boolean|char|double|float|float64|int|int32|int64|number|string|str|uint|uint64)\b`) - paramMutationPattern = regexp.MustCompile(`\b([A-Za-z_$][\w$]*)\s*(?:\.|->|\[)`) - returnLinePattern = regexp.MustCompile(`(?m)^\s*return(?:\s+([^;\n]+))?`) - partialReturnPattern = regexp.MustCompile(`(?i)\breturn\s+[^;\n,]+,\s*(err|error)\b|\breturn\s+\{[^}\n]*(data|result|value)[^}\n]*(err|error)[^}\n]*\}`) - identifierWordSplitPattern = regexp.MustCompile(`[_\-\s]+`) + readCallPattern = regexp.MustCompile(`(^|[.>:\-_])(?i:count|fetch|find|get|list|load|lookup|query|read|select|search)([A-Z_:\-.]|$)`) + identifierTokenPattern = regexp.MustCompile(`[A-Za-z_$][A-Za-z0-9_$]*`) + roleSuffixPattern = regexp.MustCompile(`(?i)(manager|helper|util|utils|service|processor)$`) + durationNamePattern = regexp.MustCompile(`(?i)(timeout|duration|delay|interval|ttl|latency|elapsed|expiry|expiration|retention)`) + sizeNamePattern = regexp.MustCompile(`(?i)(size|limit|length|capacity|bytes?|mb|kb|gb)`) + moneyNamePattern = regexp.MustCompile(`(?i)(amount|price|cost|fee|total|subtotal|balance|money)`) + unitSuffixPattern = regexp.MustCompile(`(?i)(nanos?|micros?|millis?|ms|seconds?|secs?|s|minutes?|mins?|hours?|hrs?|days?|bytes?|kb|mb|gb|cents?|pennies|minor|bps|basispoints?|usd|eur|gbp|aud|cad)$`) + collectionTypePattern = regexp.MustCompile(`(?i)(\[\]|\[\s*\]|array|list|slice|map|dict|record|set|vector|collection|iterable|sequence|promise<[^>]*\[\])`) + goSliceTypePattern = regexp.MustCompile(`^\[[^\]]*\]\s*(.+)$`) + scriptArrayTypePattern = regexp.MustCompile(`^(.+?)\s*\[\s*\]$`) + // Ordered generic containers only; keyed containers are filtered first. + genericCollectionTypePattern = regexp.MustCompile(`(?i)^(?:[\w.:]*\b)?(array|list|slice|vec|vector|set|collection|iterable|sequence|readonlyarray|deque|queue|stack|span|arraylist|immutablelist)\s*[<\[]\s*([^<>\[\]]+?)\s*[>\]]$`) + keyedContainerTypePattern = regexp.MustCompile(`(?i)(^|\b)(map\s*\[|record\s*<|dict\s*[<\[]|dictionary\s*<|hashmap\s*<|treemap\s*<|btreemap\s*<|unordered_map\s*<|multimap\s*<|counter\s*[<\[]|defaultdict\s*[<\[]|mapping\s*[<\[])`) + unitPluralExclusionPattern = regexp.MustCompile(`(?i)(nanos?|micros?|millis?|ms|seconds?|secs?|minutes?|mins?|hours?|hrs?|days?|bytes?|kb|mb|gb|cents?|pennies|minor|bps|basispoints?|usd|eur|gbp|aud|cad)$`) + scalarTypePattern = regexp.MustCompile(`(?i)\b(bool|boolean|char|double|float|float64|int|int32|int64|number|string|str|uint|uint64)\b`) + numericScalarTypePattern = regexp.MustCompile(`(?i)\b(double|float|float32|float64|int|int8|int16|int32|int64|number|uint|uint8|uint16|uint32|uint64|uintptr|bigint|decimal|long|short|size_t|ssize_t|byte|rune|duration)\b`) + paramMutationPattern = regexp.MustCompile(`\b([A-Za-z_$][\w$]*)\s*(?:\.|->|\[)`) + returnLinePattern = regexp.MustCompile(`(?m)^\s*return(?:\s+([^;\n]+))?`) + partialReturnPattern = regexp.MustCompile(`(?i)\breturn\s+[^;\n,]+,\s*(err|error)\b|\breturn\s+\{[^}\n]*(data|result|value)[^}\n]*(err|error)[^}\n]*\}`) + identifierWordSplitPattern = regexp.MustCompile(`[_\-\s]+`) ) func additionalPrecisionFunctionFindings(env support.Context, file string, fn precisionFunction) []core.Finding { @@ -904,6 +911,20 @@ func isPredicateName(name string) bool { return false } +// cardinalityMismatch reports a name whose grammatical number contradicts the +// shape of its value. It recognises exactly two situations, both of which are +// wrong independently of any repository's vocabulary: +// +// - a plural name on a scalar value ("names string"); and +// - a singular name that is the element type of the collection it holds +// ("user []User"). +// +// It deliberately does NOT report every singular name on a collection type. +// Measured over four repositories, that formulation produced 586 findings and +// no true positives: byte strings ("payload []byte"), argv vectors ("command +// []string"), maps keyed by id ("index map[string]Ref"), comparison pairs +// ("left, right []Source"), Latin plurals ("criteria []string"), and mass +// nouns ("evidence map[string]any") are all correctly singular. func cardinalityMismatch(file string, fn precisionFunction, name string, typ string) bool { base := strings.ToLower(strings.Trim(name, "_$")) if base == "" || conventionalCardinalityName(base) || configuredPluralDomainAbbreviation(base) || strings.HasSuffix(base, "status") || strings.HasSuffix(base, "class") { @@ -915,26 +936,149 @@ func cardinalityMismatch(file string, fn precisionFunction, name string, typ str if strings.Contains(typ, "{") || strings.Contains(typ, "}") { return false } - if strings.Contains(strings.ToLower(typ), "record<") && !isPluralName(base) { + if isPluralName(base) { + return pluralNameOnScalar(typ) + } + return singularNameOfElementType(base, typ) +} + +// pluralNameOnScalar reports a plural name bound to a single non-numeric, +// non-boolean scalar, e.g. "recipients string". +// +// Numeric scalars are excluded on purpose: a plural noun on a number names a +// count of things, which is correct English and pervasive ("maxLines int", +// "minFailures int", "samples int", "rawTokens int"). Booleans are excluded +// because a plural noun on a flag names a mode ("lineNumbers bool", +// "applyPatches bool", "allTargets bool"); whether such a flag should read as +// a predicate is naming.boolean-not-predicate's question, not this rule's. +// Enabling this direction without those two carve-outs added 481 counts and +// 13 flags across the measured repositories and no true positives. Variadic +// parameters are collections rather than scalars, so "needles ...string" is +// excluded too. +func pluralNameOnScalar(typ string) bool { + trimmed := strings.TrimSpace(typ) + if trimmed == "" || variadicParamType(trimmed) { return false } - plural := isPluralName(base) - collection := collectionTypePattern.MatchString(typ) || strings.Contains(typ, "[") || strings.Contains(typ, "]") - scalar := !collection && scalarTypePattern.MatchString(typ) - if collection && (strings.HasSuffix(base, "s") || strings.HasSuffix(base, "list") || strings.HasSuffix(base, "set") || strings.HasSuffix(base, "map")) { + if collectionTypePattern.MatchString(trimmed) || strings.Contains(trimmed, "[") || strings.Contains(trimmed, "]") { return false } - if plural && scalar && !collection { + if numericScalarTypePattern.MatchString(trimmed) || isBooleanNameCandidate(trimmed) { + return false + } + return scalarTypePattern.MatchString(trimmed) +} + +// variadicParamType recognises variadic parameter spellings, which denote a +// collection without any bracket syntax: Go "...string", C# "params string", +// C++ "Args&&...". +func variadicParamType(typ string) bool { + return strings.HasPrefix(typ, "...") || strings.HasSuffix(typ, "...") || + strings.HasPrefix(strings.ToLower(typ), "params ") +} + +// singularNameOfElementType reports whether base is the (singular) name of the +// element type of the collection typ, e.g. "user" for "[]User" or "scenario" +// for "Array". That coincidence is the one high-precision signal +// that a collection has been named as though it held a single value. +// +// Keyed containers are excluded: naming a map after what it maps to +// ("index map[string]Ref", "cache map[string]*Entry") is idiomatic. So are +// primitive element types: "[]byte" and "[]rune" are byte and character +// strings rather than collections of bytes, and "[]string" is routinely a +// single composite value such as an argv vector. +func singularNameOfElementType(base string, typ string) bool { + element, ok := collectionElementType(typ) + if !ok { + return false + } + if primitiveTypeName(element) { + return false + } + return base == element || base+"s" == element || base+"es" == element +} + +// collectionElementType extracts the element type of an ordered collection, +// lower-cased and stripped of package, namespace, and pointer decoration. It +// returns ok=false for keyed containers and for anything it cannot resolve. +func collectionElementType(typ string) (string, bool) { + trimmed := strings.TrimSpace(strings.Trim(typ, "*&")) + if trimmed == "" || keyedContainerTypePattern.MatchString(trimmed) { + return "", false + } + var element string + switch { + case goSliceTypePattern.MatchString(trimmed): + element = goSliceTypePattern.FindStringSubmatch(trimmed)[1] + case scriptArrayTypePattern.MatchString(trimmed): + element = scriptArrayTypePattern.FindStringSubmatch(trimmed)[1] + case genericCollectionTypePattern.MatchString(trimmed): + element = genericCollectionTypePattern.FindStringSubmatch(trimmed)[2] + default: + return "", false + } + element = strings.TrimSpace(strings.Trim(element, "*&")) + if element == "" || strings.ContainsAny(element, "[]<>(),|") { + return "", false + } + if idx := strings.LastIndexAny(element, ".:"); idx >= 0 { + element = element[idx+1:] + } + element = strings.ToLower(strings.TrimSpace(element)) + if element == "" { + return "", false + } + return element, true +} + +func primitiveTypeName(element string) bool { + switch element { + case "byte", "rune", "bool", "boolean", "char", "string", "str", "int", "int8", "int16", + "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr", + "float", "float32", "float64", "double", "number", "bigint", "any", "unknown", + "object", "error", "void": return true + default: + return false } - return !plural && collection && !strings.Contains(base, "map") && !strings.Contains(base, "list") && !strings.Contains(base, "set") } +// isPluralName reports whether a name reads as grammatically plural. +// +// NOTE: this used to delegate the unit check to unitSuffixPattern, which lists +// a bare "s" (for "seconds") among its alternatives. Every name ending in "s" +// therefore looked like a unit-suffixed name, and every name not ending in "s" +// failed the plural test outright, so the function returned false for every +// input and the plural-name-on-scalar half of naming.cardinality-mismatch had +// never fired. It now uses a unit pattern without the bare second alias, and +// strips a trailing ordinal digit so that "outputs1" reads as plural. func isPluralName(name string) bool { - if unitSuffixPattern.MatchString(name) { + trimmed := strings.TrimRight(name, "0123456789") + if trimmed == "" { + trimmed = name + } + if unitPluralExclusionPattern.MatchString(trimmed) || singularSNameExclusion(trimmed) { + return false + } + return strings.HasSuffix(trimmed, "s") +} + +// singularSNameExclusion covers names that end in "s" while being singular. +// The suffix rules carry most of it (-ss, -us, -is, -as, -os: address, status, +// analysis, alias, macos); the short set holds the residue that no suffix rule +// separates from a real plural. +func singularSNameExclusion(name string) bool { + for _, suffix := range []string{"ss", "us", "is", "as", "os"} { + if strings.HasSuffix(name, suffix) { + return true + } + } + switch name { + case "series", "species", "news", "lens", "dns", "tls", "cors", "https", "aws", "nats", "cms", "jws", "jwks": + return true + default: return false } - return strings.HasSuffix(name, "s") && !strings.HasSuffix(name, "ss") && !strings.HasSuffix(name, "us") } func implementationLeakName(name string) bool { diff --git a/internal/codeguard/checks/quality/quality_scan_language.go b/internal/codeguard/checks/quality/quality_scan_language.go index 2cf9e1d9..9bd45b92 100644 --- a/internal/codeguard/checks/quality/quality_scan_language.go +++ b/internal/codeguard/checks/quality/quality_scan_language.go @@ -34,8 +34,9 @@ func languageQualityAnalysis(ctx context.Context, env support.Context, target co if localPrecisionEnabled(env) && env.VisitTargetFiles != nil { index = buildGoPackageIndex(env, target) } + modules := newGoModuleResolver(target.Path) findings := support.ScanGoFiles(env, target, "quality", func(file string, data []byte) []core.Finding { - return goFindingsForFileWithIndex(env, file, data, index) + return goFindingsForFileWithIndex(env, file, data, index, &modules) }) if localPrecisionEnabled(env) && env.VisitTargetFiles != nil { env.VisitTargetFiles(target, func(file string) bool { return strings.HasSuffix(file, ".go") }, func(file string, data []byte) { diff --git a/internal/codeguard/rules/catalog_quality.go b/internal/codeguard/rules/catalog_quality.go index f0fefa59..543f44f4 100644 --- a/internal/codeguard/rules/catalog_quality.go +++ b/internal/codeguard/rules/catalog_quality.go @@ -152,8 +152,8 @@ var qualityCatalog = map[string]core.RuleMetadata{ DefaultLevel: "warn", ExecutionModel: core.RuleExecutionModelGoNative, Title: "Dependency direction", - Description: "Warns when non-CLI packages import CLI-only implementation details.", - HowToFix: "Move shared logic into reusable packages and keep internal or CLI details out of library code.", + Description: "Warns when a Go package imports an internal tree it is outside of, which Go's internal-visibility rule does not permit. Same-module internal imports (cmd, pkg facades, external test packages) are legal and are not reported.", + HowToFix: "Import the internal tree only from the package it is scoped to and its descendants, or promote the shared code out of internal.", }, "quality.command-check": { ID: "quality.command-check", @@ -417,7 +417,7 @@ var qualityCatalog = map[string]core.RuleMetadata{ ExecutionModel: core.RuleExecutionModelLanguageAgnostic, LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageGo, core.RuleLanguagePython, core.RuleLanguageTypeScript, core.RuleLanguageJavaScript, core.RuleLanguageCPP), Title: "Cardinality mismatch", - Description: "Warns when plural names are used for scalar values or singular names are used for collection-like values.", + Description: "Warns when a plural name holds a single non-numeric, non-boolean scalar, or when a singular name holds a collection of the type it is named after (user []User). Counts (maxLines), flags (lineNumbers), byte strings, argv vectors, keyed maps, and mass nouns are not reported.", HowToFix: "Align the identifier with the value shape, such as user for one value or users for a collection.", }, "naming.implementation-leak": { diff --git a/internal/codeguard/rules/catalog_quality_errors_defensive.go b/internal/codeguard/rules/catalog_quality_errors_defensive.go index e3b7331d..6269bb4b 100644 --- a/internal/codeguard/rules/catalog_quality_errors_defensive.go +++ b/internal/codeguard/rules/catalog_quality_errors_defensive.go @@ -17,7 +17,7 @@ var qualityErrorDefensiveCatalog = map[string]core.RuleMetadata{ "defensive.unvalidated-boundary-input": localQualityRule("defensive.unvalidated-boundary-input", "warn", "Unvalidated boundary input", "Warns when handler, API, event, or filesystem input is consumed without validation evidence.", "Validate boundary input before using it in domain logic or side effects."), "defensive.invalid-state-representable": localQualityRule("defensive.invalid-state-representable", "warn", "Invalid state representable", "Warns when booleans or raw status strings can represent impossible state combinations.", "Model state with an enum, sum type, or value object that makes invalid combinations unrepresentable."), - "defensive.null-assumption": localQualityRule("defensive.null-assumption", "warn", "Null assumption", "Warns when nullable boundary values are dereferenced without a nil/null guard.", "Check for nil/null or validate the value before dereferencing."), + "defensive.null-assumption": localQualityRule("defensive.null-assumption", "warn", "Null assumption", "Warns when a value whose declared type says it may be absent (?, | null, | undefined, | None, Optional, Maybe, std::optional) is dereferenced without a guard. A bare pointer is the pass-by-reference idiom, not a nullability declaration, and is not reported.", "Check for nil/null or validate the value before dereferencing."), "defensive.integer-overflow": localQualityRule("defensive.integer-overflow", "warn", "Integer overflow assumption", "Warns when arithmetic on count, size, or length input lacks an overflow bound check.", "Validate bounds or use a type wide enough for the source range before arithmetic."), "defensive.sequence-collision-risk": localQualityRule("defensive.sequence-collision-risk", "warn", "Sequence collision risk", "Warns when external ID allocation derives the next value from current count, including retry-mitigated cases that remain architectural debt.", "Prefer a database sequence, UUID, or transactional allocator; bounded unique-collision retry is mitigation, not the target design."), "defensive.bounds-assumption": localQualityRule("defensive.bounds-assumption", "warn", "Bounds assumption", "Warns when indexed access assumes collection bounds without a nearby length check.", "Check length or key existence before indexing."), diff --git a/tests/checks/naming_precision_test.go b/tests/checks/naming_precision_test.go index 9a7e1f97..4f6fba05 100644 --- a/tests/checks/naming_precision_test.go +++ b/tests/checks/naming_precision_test.go @@ -153,12 +153,16 @@ func TestNamingBehaviorMismatchWarnsAcrossLanguages(t *testing.T) { func TestNamingPredicateAndCardinalityPositiveNegative(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "names.ts"), strings.Join([]string{ - "export function evaluate(users: number, user: Array, enabled: boolean, flag: boolean): boolean {", + // users is plural on a non-numeric scalar; user is singular on a + // collection of the type it is named after. Both are the shapes + // naming.cardinality-mismatch still reports. + "export function evaluate(users: string, user: Array, enabled: boolean, flag: boolean): boolean {", " const active: boolean = enabled === true;", " const success: boolean = active;", - " const isReady = users > 0;", + " const isReady = users.length > 0 && user.length > 0;", " return success && isReady && flag;", "}", + "interface User { id: string }", }, "\n")) report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) @@ -244,9 +248,10 @@ func TestNamingCardinalityCreditsCollectionTypesBeforeScalarWords(t *testing.T) "export function confidentialityFilter(responsibleFields: string[]) {", " return responsibleFields.map((field) => ({ [field]: true }));", "}", - "export function badName(user: string[]) {", + "export function badName(user: User[]) {", " return user.length;", "}", + "interface User { id: string }", }, "\n")) report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) @@ -269,9 +274,10 @@ func TestNamingCardinalityAllowsUICollectiveAndMapNames(t *testing.T) { "interface Risk { id: string }", }, "\n")) writeFile(t, filepath.Join(dir, "packages/api/src/lib/bad.ts"), strings.Join([]string{ - "export function badName(user: string[]) {", + "export function badName(user: User[]) {", " return user.length;", "}", + "interface User { id: string }", }, "\n")) report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) diff --git a/tests/checks/quality_error_defensive_multilang_test.go b/tests/checks/quality_error_defensive_multilang_test.go index a45c05cf..4cc64764 100644 --- a/tests/checks/quality_error_defensive_multilang_test.go +++ b/tests/checks/quality_error_defensive_multilang_test.go @@ -365,9 +365,12 @@ func TestQualityDefensiveBoundariesDetectMultiLanguageSignals(t *testing.T) { "", }, "\n"), rules: []string{ + // defensive.null-assumption is deliberately absent: Go has no + // syntax that declares a value nullable, and a bare "*T" + // parameter is the pass-by-reference idiom rather than a + // nullability claim. "defensive.unvalidated-boundary-input", "defensive.invalid-state-representable", - "defensive.null-assumption", "defensive.unchecked-type-assertion", "defensive.unsafe-numeric-conversion", "defensive.integer-overflow", @@ -524,6 +527,7 @@ func TestQualityDefensiveBoundariesDetectMultiLanguageSignals(t *testing.T) { language: "cpp", file: "defensive.cpp", source: strings.Join([]string{ + "#include ", "#include ", "#include ", "", @@ -531,7 +535,7 @@ func TestQualityDefensiveBoundariesDetectMultiLanguageSignals(t *testing.T) { "struct User { std::string name; };", "struct Order { std::string state; };", "", - "std::string renderUser(User* user) {", + "std::string renderUser(std::optional user) {", " return user->name;", "}", "", diff --git a/tests/checks/quality_precision_false_positive_narrowing_test.go b/tests/checks/quality_precision_false_positive_narrowing_test.go new file mode 100644 index 00000000..9cf55e76 --- /dev/null +++ b/tests/checks/quality_precision_false_positive_narrowing_test.go @@ -0,0 +1,336 @@ +package checks_test + +import ( + "path/filepath" + "strings" + "testing" +) + +// goModuleFixture writes a go.mod so that quality.dependency-direction can map +// a file path back to its Go import path. +func goModuleFixture(t *testing.T, dir string) { + t.Helper() + writeFile(t, filepath.Join(dir, "go.mod"), strings.Join([]string{ + "module example.com/app", + "", + "go 1.22", + "", + }, "\n")) +} + +func TestDependencyDirectionAllowsSameModuleInternalImportFromPublicPackage(t *testing.T) { + dir := t.TempDir() + goModuleFixture(t, dir) + writeFile(t, filepath.Join(dir, "pkg", "app", "facade.go"), strings.Join([]string{ + "package app", + "", + "import \"example.com/app/internal/engine\"", + "", + "type Options = engine.Options", + "", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.dependency-direction") +} + +func TestDependencyDirectionAllowsSingularTestDirectoryExternalTestPackage(t *testing.T) { + dir := t.TempDir() + goModuleFixture(t, dir) + // "test/" (singular), which the previous hardcoded "tests/" prefix missed. + writeFile(t, filepath.Join(dir, "test", "cli", "config_test.go"), strings.Join([]string{ + "package cli_test", + "", + "import (", + "\t\"testing\"", + "", + "\t\"example.com/app/internal/config\"", + "\t\"example.com/app/internal/engine\"", + ")", + "", + "func TestLoad(t *testing.T) {", + "\t_ = config.Default", + "\t_ = engine.Default", + "}", + "", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.dependency-direction") +} + +func TestDependencyDirectionWarnsForInternalImportOutsideItsOwnTree(t *testing.T) { + dir := t.TempDir() + goModuleFixture(t, dir) + // services/b/internal is scoped to services/b; services/a may not reach it. + writeFile(t, filepath.Join(dir, "services", "a", "wire.go"), strings.Join([]string{ + "package a", + "", + "import \"example.com/app/services/b/internal/secretstore\"", + "", + "var Store = secretstore.Default", + "", + }, "\n")) + writeFile(t, filepath.Join(dir, "services", "b", "internal", "secretstore", "store.go"), strings.Join([]string{ + "package secretstore", + "", + "var Default = \"store\"", + "", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRulePresent(t, report, "Code Quality", "quality.dependency-direction") + assertFindingLevel(t, report, "Code Quality", "quality.dependency-direction", "warn") +} + +func TestCardinalityMismatchAllowsSingularNamesThatAreNotElementTypes(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "shapes.go"), strings.Join([]string{ + "package sample", + "", + "type Ref struct{ Name string }", + "", + "func WriteChunk(chunk []byte) int { return len(chunk) }", + "", + "func RunCommand(command []string) int { return len(command) }", + "", + "func Resolve(index map[string]Ref) int { return len(index) }", + "", + "func Compare(left, right []Ref) bool { return len(left) == len(right) }", + "", + "func Budget(maxLines int) int { return maxLines }", + "", + "func Render(lineNumbers bool) bool { return lineNumbers }", + "", + "func Grade(outputs1 []Ref, outputs2 []Ref) int { return len(outputs1) + len(outputs2) }", + "", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRuleAbsent(t, report, "Code Quality", "naming.cardinality-mismatch") +} + +func TestCardinalityMismatchWarnsWhenNameIsTheElementType(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "collections.go"), strings.Join([]string{ + "package sample", + "", + "type Invoice struct{ ID string }", + "", + "func Total(invoice []Invoice) int { return len(invoice) }", + "", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRulePresent(t, report, "Code Quality", "naming.cardinality-mismatch") + assertFindingMessageContains(t, report, "naming.cardinality-mismatch", "\"invoice\"") +} + +func TestCardinalityMismatchWarnsForPluralNameOnStringScalar(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "scalars.go"), strings.Join([]string{ + "package sample", + "", + "func Notify(recipients string) int { return len(recipients) }", + "", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRulePresent(t, report, "Code Quality", "naming.cardinality-mismatch") + assertFindingMessageContains(t, report, "naming.cardinality-mismatch", "\"recipients\"") +} + +func TestNullAssumptionAllowsGoPointerOutParametersAndAccumulators(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "handler.go"), strings.Join([]string{ + "package sample", + "", + "import \"strings\"", + "", + "type Summary struct{ Commands int }", + "", + "func plistProgram(out *strings.Builder) {", + "\tout.WriteString(\"ProgramArguments\")", + "}", + "", + "func addArchivedCounters(summary *Summary, delta int) {", + "\tsummary.Commands += delta", + "}", + "", + "func handleRequestSummary(summary *Summary) int {", + "\treturn summary.Commands", + "}", + "", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRuleAbsent(t, report, "Code Quality", "defensive.null-assumption") +} + +func TestNullAssumptionWarnsForDeclaredOptionalBoundaryPayload(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "route.ts"), strings.Join([]string{ + "type Order = { id: string };", + "", + "export function handleOrderRoute(payload: Order | null) {", + " return payload.id;", + "}", + "", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRulePresent(t, report, "Code Quality", "defensive.null-assumption") + assertFindingConfidence(t, report, "Code Quality", "defensive.null-assumption", "low") +} + +func TestNullAssumptionAllowsGuardedOptionalBoundaryPayload(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "route_guarded.ts"), strings.Join([]string{ + "type Order = { id: string };", + "", + "export function handleOrderRoute(payload: Order | null) {", + " if (payload === null) {", + " return \"\";", + " }", + " return payload.id;", + "}", + "", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "defensive.null-assumption") +} + +func TestFailOpenAuthorizationAllowsFailClosedErrorReturn(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "policy_load.go"), strings.Join([]string{ + "package sample", + "", + "import \"errors\"", + "", + "func loadPolicyPack(name string) (map[string]string, error) {", + "\tpack, err := readPack(name)", + "\tif err != nil {", + "\t\treturn nil, err", + "\t}", + "\treturn pack, nil", + "}", + "", + "func readPack(name string) (map[string]string, error) {", + "\tif name == \"\" {", + "\t\treturn nil, errors.New(\"empty\")", + "\t}", + "\treturn map[string]string{}, nil", + "}", + "", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRuleAbsent(t, report, "Code Quality", "defensive.fail-open-authorization") +} + +func TestFailOpenAuthorizationWarnsWhenErrorPathReturnsAllow(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "authorize.go"), strings.Join([]string{ + "package sample", + "", + "import \"errors\"", + "", + "func authorizeRequest(subject string) bool {", + "\tallowed, err := lookupPermission(subject)", + "\tif err != nil {", + "\t\treturn true", + "\t}", + "\treturn allowed", + "}", + "", + "func lookupPermission(subject string) (bool, error) {", + "\tif subject == \"\" {", + "\t\treturn false, errors.New(\"empty\")", + "\t}", + "\treturn true, nil", + "}", + "", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRulePresent(t, report, "Code Quality", "defensive.fail-open-authorization") + assertFindingConfidence(t, report, "Code Quality", "defensive.fail-open-authorization", "medium") +} + +func TestUnsafeDefaultAllowsOrdinaryConfigCallsWithStringArguments(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "rules.go"), strings.Join([]string{ + "package sample", + "", + "type Config struct{ Rules []string }", + "", + "func collect(rules []string, kind string, verdict string) []string { return rules }", + "", + "func evaluate(cfg Config) []string {", + "\treturn collect(cfg.Rules, \"tool\", \"allow\")", + "}", + "", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRuleAbsent(t, report, "Code Quality", "defensive.unsafe-default") +} + +func TestUnsafeDefaultWarnsForDefaultingSafetyControlToAllow(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "guard.go"), strings.Join([]string{ + "package sample", + "", + "import \"os\"", + "", + "func insecureAllowed() bool {", + "\tskipVerify := os.Getenv(\"STRICT_TLS\") == \"\" || true", + "\treturn skipVerify", + "}", + "", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRulePresent(t, report, "Code Quality", "defensive.unsafe-default") + assertFindingConfidence(t, report, "Code Quality", "defensive.unsafe-default", "low") +} + +func TestDependencyDirectionHonoursConfiguredTestPaths(t *testing.T) { + dir := t.TempDir() + goModuleFixture(t, dir) + // A test support helper with no test-naming marker, declared as test code + // through ci_rules.allowed_test_paths. + writeFile(t, filepath.Join(dir, "services", "a", "wire.go"), strings.Join([]string{ + "package a", + "", + "import \"example.com/app/services/b/internal/secretstore\"", + "", + "var Store = secretstore.Default", + "", + }, "\n")) + + cfg := qualityPrecisionConfig(dir) + cfg.Checks.CIRules.AllowedTestPaths = []string{"services/a/**"} + + report := runQualityPrecisionScan(t, cfg) + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.dependency-direction") + + strict := qualityPrecisionConfig(dir) + strict.Checks.CIRules.AllowedTestPaths = []string{"tests/**"} + assertFindingRulePresent(t, runQualityPrecisionScan(t, strict), "Code Quality", "quality.dependency-direction") +} From 9c4c1c64fa6114d02d93ebf535f272072b10f07f Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 17 Sep 2026 15:35:58 -0400 Subject: [PATCH 05/16] test(security): assert node_modules stays unscanned under an ordinary target TestSecuritySemanticAnalyzerScansNodeModulesWithinTarget asserted that a node_modules tree inside a target is scanned, which scanner hardening deliberately removed: isDefaultDependencyOrBuildPath skips node_modules at any depth and the policy is applied to the logical target root too (see TestWalkFilesAppliesDependencyPolicyToLogicalTargetRoot). The test has been failing at HEAD as a result. Rename it to describe the behavior that is actually intended, give the target a real source file so it is not empty, and assert the finding is absent. Co-Authored-By: Claude Opus 5 (1M context) --- tests/checks/typescript_semantic_test.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/checks/typescript_semantic_test.go b/tests/checks/typescript_semantic_test.go index 134cf70a..d4bb86c5 100644 --- a/tests/checks/typescript_semantic_test.go +++ b/tests/checks/typescript_semantic_test.go @@ -159,14 +159,20 @@ func TestSecurityCheckUsesSemanticTypeScriptAnalyzerForRequirePropertyAlias(t *t assertFindingRulePresent(t, report, "Security", "security.typescript.shell-execution") } -func TestSecuritySemanticAnalyzerScansNodeModulesWithinTarget(t *testing.T) { +// Installed dependency trees are excluded from scans, and that policy is +// applied to the logical target root as well (see +// TestWalkFilesAppliesDependencyPolicyToLogicalTargetRoot in tests/security), +// so the semantic analyzer never reaches node_modules. Replaces an earlier test +// that asserted the opposite for a capability the scanner hardening removed. +func TestSecuritySemanticAnalyzerSkipsNodeModulesUnderOrdinaryTarget(t *testing.T) { requireTypeScriptSemanticRuntime(t) dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "index.ts"), "export const ok = 1;\n") writeFile(t, filepath.Join(dir, "node_modules", "local-package", "index.ts"), "const agent = { rejectUnauthorized: false };\n") cfg := codeguard.ExampleConfig() - cfg.Name = "security-typescript-semantic-node-modules" + cfg.Name = "security-typescript-semantic-skips-node-modules" cfg.Targets = []codeguard.TargetConfig{{Name: "web", Path: dir, Language: "typescript"}} cfg.Checks.Security = true cfg.Checks.Design = false @@ -179,10 +185,8 @@ func TestSecuritySemanticAnalyzerScansNodeModulesWithinTarget(t *testing.T) { t.Fatalf("run: %v", err) } - assertSectionStatus(t, report, "Security", "fail") - assertFindingRulePresent(t, report, "Security", "security.typescript.insecure-tls") + assertFindingRuleAbsent(t, report, "Security", "security.typescript.insecure-tls") } - func requireTypeScriptSemanticRuntime(t *testing.T) { t.Helper() From 08a95388815d06a25940bc6847e2af04efc3c0f9 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 17 Sep 2026 15:36:12 -0400 Subject: [PATCH 06/16] docs(knowledge): record scan-precision findings and correct two wrong entries Captured from a false-positive audit across seven external repositories (23k findings) plus minimal reproductions: - default exclusions were root-anchored and missed most build output, which caused false NEGATIVES as well as positives: a Next.js .next/ tree exhausted the shared corpus budget and suppressed real-source findings - looksBinary guarded only the secrets pass, so every other line scanner regexed compiled binaries - the lockfile parser contract (recognised != found packages), and which ecosystems may normalise package names - credential fixture classification is two axes: path evidence may only demote, value evidence may suppress - ApplyDefaults resolved omitted keys against the init starter config, so house style was global default policy; the config-less scan -folder path still does - isPluralName returned false for every input, so half of naming.cardinality-mismatch had never fired - the defensive.* family's honest confidence line is analysis mechanism, not rule family Corrections to existing entries, both verified: - ScanTargetFiles IS file-parallel (parallel: true, 4 workers); the claim that file-level parallelism was deliberately avoided is stale and led to a concurrent map write that did not reproduce until the third run - golangci-lint needs a go@1.25 GOROOT here; the env -u GOROOT recipe panics against the local go1.27.1 toolchain Co-Authored-By: Claude Opus 5 (1M context) --- .claude/knowledge/architecture-boundaries.md | 24 +++++++++++++++++++- .claude/knowledge/local-dev-setup.md | 3 +++ .claude/knowledge/testing-patterns.md | 6 +++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.claude/knowledge/architecture-boundaries.md b/.claude/knowledge/architecture-boundaries.md index 64f5e6ad..2bb9b665 100644 --- a/.claude/knowledge/architecture-boundaries.md +++ b/.claude/knowledge/architecture-boundaries.md @@ -16,7 +16,7 @@ Key architectural decisions, service boundaries, data flow, integration points, - **Git-history secret scan lives in its own top-level package `internal/codeguard/history` (shells `git log -p -U0`), exposed via `pkg/codeguard.ScanGitHistory` and the `scan-history` CLI command.** It MUST be a separate package, not `runner/support`: `checks/security` imports `runner/support` (for `MatchPattern`), so `runner/support` importing `checks/security` would cycle. `history` imports `checks/security` (above it) and is consumed by `pkg` + CLI (both top-level). The parser tracks `+++ b/`, hunk headers (`@@ … +c`), and `+`/`-` line prefixes to attribute added lines to commits; dedupes by rule+path+masked-message keeping the newest commit (log is newest-first). - **GOTCHA: `support.ScanTargetFiles` caches per-file findings keyed by `(sectionID, target.Path, rel)`.** If two passes scan the same files with the same `sectionID`, the second pass gets a cache hit and returns the FIRST pass's findings. The secret pass therefore uses a distinct cache id `"security-secrets"` (not `"security"`) so it doesn't collide with the language pass. `sectionID` here only affects the cache key — findings still land in the real section via their RuleID/`FinalizeSection`. When adding any additional `ScanTargetFiles` call to an existing section, give it a unique cache id. - **GOTCHA: `ScanTargetFiles`'s per-file cache only works for PURE evaluators** (evaluators whose sole output is the returned `[]core.Finding`). On a cache hit the evaluator is NOT called, so any evaluator that builds state via a captured closure and returns `nil` will silently produce nothing once a warm cache exists. Clone detection hit exactly this (all `quality.duplicate-code` findings vanished on the second scan). Cross-file/stateful passes MUST use `VisitTargetFiles` (bypasses the findings cache, visits every file) instead of `ScanTargetFiles`. See `checks/quality/quality_clone.go:cloneDocumentsForTarget`. -- **PERF: the scan is parallelized at the section level** (`runner/checks/checks.go:Build`) on a `runtime.NumCPU()`-bounded worker pool; results are written into position-indexed slots so output order stays deterministic. This required making the shared mutable state concurrency-safe: `ScanCache` (mutex in `cache_types.go`), `ArtifactStore` (mutex in `artifacts.go`), and the per-scan file corpus. `OnSectionComplete` is wrapped in a mutex (`withSynchronizedSectionCallback`) since sections now finish concurrently. File-level parallelism was deliberately NOT used — some evaluators mutate closure state (clone), which is only safe when a section runs serially. +- **PERF: the scan is parallelized at the section level** (`runner/checks/checks.go:Build`) on a `runtime.NumCPU()`-bounded worker pool; results are written into position-indexed slots so output order stays deterministic. This required making the shared mutable state concurrency-safe: `ScanCache` (mutex in `cache_types.go`), `ArtifactStore` (mutex in `artifacts.go`), and the per-scan file corpus. `OnSectionComplete` is wrapped in a mutex (`withSynchronizedSectionCallback`) since sections now finish concurrently. **CORRECTION (verified): file-level parallelism IS now used.** `ScanTargetFiles` passes `parallel: true` (`runner/support/findings_scan.go:32`, `maxFileScanWorkers = 4`); the serial behaviour evaluators that mutate closure state depend on now lives in the separate `ScanTargetFilesSequential`. So ANY shared mutable state reached from a `ScanTargetFiles` evaluator needs its own lock — a new rule consulting `goModuleResolver`'s go.mod cache hit `fatal error: concurrent map writes`, and it did not reproduce until the third scan run, so a couple of green runs is not evidence of safety. - **PERF: files are walked, read, and Go-parsed once per scan via a shared `fileCorpus`** (`runner/support/corpus.go`), held by pointer on the by-value `Context` so all sections share it. `ScanTargetFiles`/`VisitTargetFiles` read bytes through it; `support.ParseGoSource(env, file, data)` returns a shared `*ast.File`/`*token.FileSet` (parsed with `ParseComments`) so quality and design no longer re-parse the same file. Cached ASTs are read-only — never mutate them. Tradeoff: peak memory is higher (the corpus retains all read bytes + ASTs for the scan), but wall-clock improves ~1.2x (small repos) to ~1.7x (large repos). Taint/graph/perf parsers keep their own cheaper parse modes (`SkipObjectResolution`/`ImportsOnly`) and are intentionally not routed through the shared cache. - **The per-file cache fingerprint is per-config-family, not global** (`SectionConfigHashes` in `cache_helpers.go`; cache version bumped to 7). Each family (`quality`/`design`/`security`/`prompts`/`ci`/`contracts`) hashes only the config that can change its findings (quality also hashes `DesignRules` + `AI`; the rule catalog is in every family since a metadata override changes `NewFinding` output). `sectionConfigFamily` maps a cache section id to its family by prefix; unknown ids fall back to the `""` all-checks hash so a new section can never silently serve stale entries. Editing an unrelated field (output format, target names, exclude list) no longer invalidates the whole cache. - **The MCP server is hand-rolled (no SDK) and transport-agnostic.** Shared JSON-RPC message builders + the synchronous method router live in `internal/cli/mcp_dispatch.go` (`buildResultMessage`/`buildErrorMessage`/`buildProgressMessage`, `serverCapabilities`, `dispatchSyncMethod`). Both transports reuse them. stdio (`mcp_run.go`/`mcp_requests.go`) keeps an async goroutine + cancel-map for `tools/call` (cancellation via `notifications/cancelled`); HTTP (`mcp_http.go`) runs `tools/call` synchronously per-request and streams progress over SSE, cancellation driven by the request context. When adding a capability, wire it into `dispatchSyncMethod` (HTTP picks it up automatically) AND the stdio method switch in `mcp_run.go`. @@ -39,3 +39,25 @@ Key architectural decisions, service boundaries, data flow, integration points, - **Section ids diverge between the registry and reports.** `runner/checks/registry.go` uses `supply-chain`, but the section finalizes as `supply_chain`, which is what appears in `SectionResult.ID`, in `checks.disabled`, and in `checks.min_confidence.sections`. `core.SectionKeys()` holds the report-facing list; `tests/codeguard` asserts every id a real scan reports is in it. - **`config.WriteFile` stamps `codeguard_version`, `ApplyDefaults` does not.** The stamp is applied at the write boundary so a *loaded* config keeps whatever its file recorded — that is what makes it useful for spotting a config produced by another release. It is never validated (any value, including an unparseable one, must still load), and it is absent from `SectionConfigHashes`, so a release upgrade cannot discard cached findings. Release builds write `v{{.Version}}` (GoReleaser `-X internal/version.Number=v{{.Version}}`), so hand-maintained configs use the `v`-prefixed form; a local dirty build writes e.g. `v1.9.0+dirty`. +- **`baseExampleConfig()` (`config/example*.go`) IS the built-in defaults source, not just `init` scaffolding.** `ApplyDefaults` → `defaultConfigForProfile` → `baseExampleConfig()`, then the profile spec overlays it. So every value in `exampleCIRules()`/`examplePromptRules()`/etc. silently becomes the effective default for users who omit that key. Consequence to keep in mind when editing those functions: `AllowedTestPaths: []string{"tests/**"}` makes `ci.test-file-location` (**fail** level) active by default, so a config of just `checks: {ci: true}` hard-fails any repo using co-located `_test.go`, NestJS `.spec.ts`, pytest `test_*.py`, or a `test/` (singular) directory — verified: 109 fail findings on an ordinary Go repo. Same coupling puts `RequiredWorkflowFiles: [".github/workflows/ci.yml"]` and a `RequiredContains: ["go test ./..."]` content rule in every unconfigured scan. Treat a change to `example*.go` as a change to global default policy, and prefer leaving house-style keys empty there (the rule already no-ops on an empty `AllowedTestPaths`). +- **Default excludes are much narrower than they look, and only three are segment-matched.** `shouldExclude` (`runner/support/utils_match.go`) applies glob defaults `.git/**`, `.gocache/**`, `.gomodcache/**`, `.codeguard/**`, `dist/**` plus `isDefaultDependencyOrBuildPath`, which segment-matches only `node_modules`, `cdk.out`, and `vendor`. Because the globs are anchored at the target root, **nested build output is scanned**: `services/app/dist/**` is NOT excluded (only root `dist/`), and `.next/`, `build/`, `out/`, `target/`, `.venv/`, `site-packages/`, `__pycache__/`, `coverage/`, `.terraform/`, `.turbo/`, `*.min.js`, `*.map` are not excluded at all. Measured impact on real repos: 97% of findings on one Next.js monorepo and 78% on another came from `.next/`; a Go repo with a `.build/go-cache` tree produced 566 security findings against compiled cache objects. Note `.gocache` is a default but `.cache/go-build` (the more common name) is not. Any new exclusion belongs in `isDefaultDependencyOrBuildPath` (segment-matched, depth-independent), not in the anchored glob list. +- **CORRECTION to the hardening note above: `looksBinary` is NOT general scanner hardening — it guards only the secrets pass** (`security_secrets.go:105`, its sole caller). Every other line/regex detector (weak-hash, weak-cipher, the per-language shell-execution/dynamic-code/unsafe-html-sink patterns) will happily regex compiled binaries and minified bundles, reporting findings at lines inside object code. If you add a repository-wide line scanner, add the `looksBinary` guard yourself; consider hoisting it into the corpus read path instead. +- **`parseLockfileState`'s bool means "I recognised this format", never "I found packages".** `SupplyChainLockfileIssues` has a `parsedAny` guard so an unparseable lockfile stays silent, but several branches historically hardcoded `true`, which defeated it: a parser that understood nothing reported *every* manifest dependency as drift, and `supply_chain.lockfile-drift` is **fail** level, so one parse bug became a fail per dependency (measured: 33 on a Yarn Berry repo, i.e. its entire `package.json`). A parser that recognised nothing MUST return false. Distinguishing "genuinely empty" from "unparsed" needs positive format evidence, not just a zero count — self-identifying keys (`lockfileVersion`, `importers`, `snapshots`) for the JSON/YAML formats, and for the TOML block formats either a seen `[[package]]` table or an all-lines-are-known-header-keys check, so a merge-mangled file fails on its garbage lines while a dependency-free lock still gets compared. +- **Lockfile package-name comparison is ecosystem-gated on `core.SupplyChainManifest.Ecosystem`, and must stay that way.** Python normalises per PEP 503 (lowercase, runs of `-_.` collapsed to `-`) because `pyproject.toml` records `Jinja2`/`typing_extensions` while `poetry.lock` records `jinja2`/`typing-extensions`. npm must NOT normalise — names are case-sensitive and `_` vs `-` are genuinely different packages, so blanket normalisation creates false negatives (there is a regression test pinning `Left_Pad` vs `left-pad` as real drift). Cargo also stays exact: crates.io treats `-`/`_` as distinct and blocks registering both spellings, and `Cargo.lock`'s `name` is the canonical index name. Go module paths are case-sensitive with `!x` escaping. +- **Yarn has two incompatible lockfile syntaxes and Berry's is the common one now.** Classic v1 writes `version "2.6.0"`; Berry (v2+) writes `version: 2.6.0` unquoted under a `__metadata:` header. Berry selectors also carry a protocol — `@linear/sdk@npm:^2.6.0`, plus `workspace:`, `link:`, `portal:`, `file:`, `patch:`, `git:`/URL — so the package name must be split at the first **non-scope** `@`, not the last (splitting at the last also mis-parsed classic npm aliases like `aliased@npm:real@^1.0.0`). Non-registry protocols are not version ranges and the manifest side is never `Pinned` for them, so record them as present-with-resolved-version and skip the range comparison. `bun.lockb` is listed as a lockfile candidate in `parsePackageJSONManifest` but is a binary format with no parser branch, so bun repos get no drift coverage at all. +- **Exclusion has two layers with deliberately different power, and the asymmetry matters.** `ShouldExclude`/`shouldExclude` is a **pure path predicate**; the corroboration for ambiguous build-output names (`build`, `out`, `bin`, `obj`, `target`, `coverage`, `_build` — skipped only when a sibling manifest such as `Cargo.toml`, `pom.xml`, a `.csproj`, `CMakeLists.txt` or `build.gradle` proves the directory is that build system's output) needs the filesystem, so it lives in `walkFilesBounded` via `isCorroboratedBuildOutputDir`. Consequence: those names are pruned during a repository walk but are NOT reported as excluded by `ShouldExclude`, so a path-only caller (e.g. diff-mode filtering) still sees them. The asymmetry errs toward scanning, which is the safe direction — but any new path-only exclusion caller needs to know it exists. Python markers are intentionally absent from `coverage`'s marker list because coverage.py's own repo has a `coverage/` *source* package next to its `pyproject.toml`; `package.json` is intentionally absent from `build`'s, because JS repos keep build *scripts* there. +- **`exclude` entries support a leading `!` as a re-include that overrides the built-in defaults** (e.g. `exclude: ["!packages/ui/dist/**"]`). This is the only escape hatch for the unconditional defaults, notably `dist` and `venv` (CPython's stdlib really does have a `Lib/venv/` source package). It required a second mechanism: directory pruning is suppressed when a re-include *could* match inside the directory (`reincludeMayCoverSubtree`), else the walk prunes the parent before the negation can apply — the same problem gitignore negation has. `reincludeMayCoverSubtree` is deliberately loose: a re-include whose glob starts with a wildcard (`!**/dist/src/**`) disables ALL directory pruning, which is a walk-cost cliff, not a correctness one (files inside stay excluded individually). Note this was added under a scope constraint rather than designed as config API — a `core.Config` flag would be the cleaner long-term shape. +- **`dirHasEntryWithSuffix` is deliberately not memoized**: codeguard also runs as a long-lived MCP server, where a cached directory listing would serve a stale answer across scans. +- **Narrow default exclusions caused false NEGATIVES, not just false positives.** Before build output was excluded depth-independently, a Next.js monorepo's `.next/` tree exhausted the shared corpus budget: the scan emitted `scan.corpus-budget` ("remaining uncached work was skipped") and `quality.duplicate-code-budget`, and real-source findings were *suppressed* — one repo went from 255 real-source findings to 665 once `.next` was excluded, with both budget diagnostics disappearing. So a flood of generated files silently starves analysis of real code. When triaging "why didn't codeguard flag X", check the section diagnostics for budget exhaustion before assuming a detector gap. +- **Credential fixture classification is two orthogonal axes, and the asymmetry is the security property.** `classifyPathScope` (`security_fixture_demote.go`) yields `non_fixture`/`fixture`/`documentation` from path evidence and can only ever **demote** (fail/high → warn/medium, message annotated); suppression to an info diagnostic requires evidence about the **value** (vendor-documented placeholder, filler marker, unroutable host). Rationale: a path is attacker-choosable and a real key in a `_test.go` is still a leaked key. Consequence for contributors: adding a path convention cannot create a detection bypass, but adding a value signal can — so keep value signals exact. `private_key` and `high_entropy` matches are never path-demoted at all, and `security_rules.demote_fixture_findings: false` restores strict fail/high for everything (verified). The git-history scan is deliberately unaffected (it goes through `Scanner.ScanContent`, not `secretResultsForFile`) because anything ever committed must be rotated regardless of where it lived. +- **`isPlaceholderSecret` only suppresses "example"-ish values for patterns that have a capture group.** The AWS/GitHub/Slack/Stripe provider patterns have none — the whole match is the token — which is why AWS's own published `AKIAIOSFODNN7EXAMPLE` used to fire at fail/high on any path. Vendor-documented placeholders are now an explicit exact-literal denylist applied path-independently. Check this behaviour whenever adding a new provider pattern. +- **`security_rules.demote_fixture_findings` is live again**; `core/config_rule_types.go:276` still describes it as "retained for configuration compatibility", which is now stale and should be corrected. +- **UPDATE to the `baseExampleConfig()` bullet above (branch `fp/defaults-vs-example-config`): the two roles are now split.** `ApplyDefaults` resolves omitted keys against `baseDefaultConfig()` (`config/defaults_base*.go`) — scan mechanics, off-or-scan-mode section activation, and thresholds with no meaningful unset value. `baseExampleConfig()` (`config/example.go`) is that baseline plus `applyStarterPolicy()` — targets, the recommended section set, and codeguard's own CI/layering conventions — and is what `codeguard init` writes. Rule for contributors: anything added to the baseline is policy enforced on every user, so repository-shape policy (filenames, content markers, test layout, package layering) belongs in the starter or a profile. Two adjacent traps: (a) a `def`-supplied value is only actually a default if some `apply*Defaults` function reads it — many `Detect*` booleans are hardcoded `true` and ignore `def` entirely, and roughly a third of the old example config was inert on the defaults path; (b) `applyCIDefaults`/`applyDesignDefaults` used to hardcode `boolPtr(true)` for `require_workflow_dir` and the four layering toggles, so editing the constructor had no effect — they now read from the baseline. +- **STILL OPEN: the config-less `scan -folder` path bypasses `ApplyDefaults` entirely.** `internal/cli/helpers.go:81 exampleConfigForProfile` returns `service.ExampleConfig()` / `ExampleConfigForProfile()` — the *starter*, not the baseline. Verified: a config-less folder scan of an ordinary Go repo still produces 144 fail-level findings (109 `ci.test-file-location`, 6 `design.service-import-internal`, 5 `ci.workflow-content`) even with the defaults split in place. This is the zero-config first-run experience, and `docs/getting-started.md:53` advertises it as "CodeGuard's built-in default config", which is now the wrong term. Fix is for that path to build from the baseline plus the recommended section set. +- **`design.cmd-through-internal-cli` cannot fire outside this repository.** `checks/design/design_go.go:141` requires `strings.Contains(pathValue, "/pkg/codeguard")`. Verified. It shipped as a fail-level default that is a guaranteed no-op for every other user. +- **`design_rules.forbid_internal_import_cmd` is dead config.** Verified: it appears only in `core/config_rule_types.go:220`, `config/defaults_rules.go:100`, the example config, and `docs/checks.md:1547`. No check reads it — it is validated, documented, defaulted, and inert. +- **A `profile:` set inside a config file cannot enable the plain-bool check sections.** `applyCheckDefaults` only defaults the `*bool` sections from `def`, so `ai-safe`'s `Prompts`/`Security` and `enterprise`'s `SupplyChain` are inert when the profile arrives via config; they only take effect through `ExampleConfigForProfile` (i.e. `init --profile`). Pre-existing, and it means `enterprise` is quietly weaker on the defaults path than its description. +- **`isPluralName` (`quality_precision_workstreams_cd.go`) returned `false` for every possible input, so half of `naming.cardinality-mismatch` had never fired.** `unitSuffixPattern` lists a bare `s` among its `$`-anchored alternatives (intended for "seconds"), so any `s`-ending name looked unit-suffixed and returned false, while any non-`s` name failed the `HasSuffix(name, "s")` test — both branches false, unconditionally. Every one of the 586 measured findings came from the rule's *other* half. Two related smells in the same file: `conventionalCardinalityName` is a ~120-entry literal allowlist harvested from one codebase (`atrisk`, `krs`, `requestsbypillar`, `latestquarter`, `prisma`) — the signature of a predicate patched by pasting in names instead of being fixed — and `collectionTypePattern` is unanchored on `record|set|list`, so `EventRecorder`, `Offset` and `Listener` all read as collection types. +- **The honest confidence line for the `defensive.*` family is analysis mechanism, not rule family.** Its two AST-driven members (`unchecked-type-assertion`, `unsafe-numeric-conversion` — real `*ast.TypeAssertExpr` + comma-ok analysis) earn `high`; the ~11 regex-over-lowercased-function-body members do not. Two rules rated `high` were measured at 100% FP with structural causes: `fail-open-authorization` matched `err != nil { … return nil }` gated only on the body mentioning "auth"/"policy" — i.e. every Go function that mentions policy and uses the fail-*closed* idiom — and `unsafe-default` chained three `.*?` segments off the bare substring `config` with unbounded safety tokens, so `ConfigFields: []string{...}` and `gitSkippableFlag` ("skip" inside "Skippable") matched. Both are now `medium`/`low`. Nine regex members still carry `medium` on no measurement; treat that as unverified. +- **`quality.dependency-direction` duplicates a compiler-enforced invariant and is recommended for retirement.** Go already rejects importing another module's (or another subtree's) `internal/`, including the monorepo `services/a` → `services/b/internal/x` case, while same-module internal imports from `cmd/`, `pkg/` or an external test package are legal and idiomatic — which is why the old exemption had to hardcode `pkg/codeguard/` via `IsSDKFacadeFile`, flagging every *other* repo's `pkg//` facade. All 631 measured findings were legal same-module imports; expected true-positive yield is ~0 for any buildable single-module repo. Its catalog entry also described behaviour ("non-CLI packages import CLI-only implementation details") the implementation never had. Properly-scoped successor: `design.service-import-internal`. Retirement precedent: `rules.RetiredPerformanceRuleIDs()` + a `codeguard doctor` waiver-migration hint. +- **`defensive.bounds-assumption` fires inside generated `proto/**/*.pb.go`.** Generated-source exclusion for the `defensive.*`/precision families is a separate gap from the `.next/`/`dist/` walk exclusions — those rules run on files the walk legitimately includes. diff --git a/.claude/knowledge/local-dev-setup.md b/.claude/knowledge/local-dev-setup.md index e66f86dc..4d1d3ca3 100644 --- a/.claude/knowledge/local-dev-setup.md +++ b/.claude/knowledge/local-dev-setup.md @@ -5,3 +5,6 @@ How to set up, run, and work with this project locally. Non-obvious dependencies - **The self-scan cache lives at `.codeguard/.codeguard/cache.json`, not `.codeguard/cache.json`.** `cache.path` resolves relative to the config directory (`config.containConfigArtifactPaths`), and `make codeguard-ci` uses `-config .codeguard/codeguard.yaml`, so the default `.codeguard/cache.json` nests. When iterating on **rule logic**, delete that file before re-running `make codeguard-ci`: cache keys cover config + file content + the release-bumped scanner-version constant, so a code-only change to a check serves stale findings from the cache and looks like your fix didn't work. - The Makefile runs Go via `env -u GOROOT go` (Makefile:6), so `make` targets work even with the shell's stale GOROOT; direct `go` commands need `export GOROOT=/opt/homebrew/opt/go/libexec && export PATH=$GOROOT/bin:$PATH` first. - Local `golangci-lint run` can fail from the shell's stale `GOROOT` or unwritable `~/Library/Caches/golangci-lint` even when CI is clean. Use `env -u GOROOT GOCACHE=/private/tmp/codeguard-go-cache GOLANGCI_LINT_CACHE=/private/tmp/golangci-lint-cache golangci-lint run` to reproduce CI lint locally. +- **`golangci-lint` needs a Go 1.25 GOROOT to run here — there IS a working recipe.** The installed v2.12.2 is built with go1.26.3 and cannot analyze this tree under the default go1.27.1: it panics inside `pkg/goanalysis` (`file requires newer Go version go1.27`) and emits phantom `math/rand/v2` stdlib typecheck errors on crypto-importing packages, reproducibly on unmodified HEAD. **Verified fix:** point it at the Homebrew go@1.25 toolchain — `G=$(ls -d /opt/homebrew/Cellar/go@1.25/*/libexec | head -1); GOROOT=$G PATH=$G/bin:$PATH /Users/alex/.go/bin/golangci-lint run ./...` → 0 issues. Note `make lint-strict` runs `env -u GOROOT golangci-lint`, which resolves yet another Go and panics, and the `export GOROOT=/opt/homebrew/opt/go/libexec` recipe elsewhere in this file is the 1.27 toolchain and does NOT work for lint. +- **`golangci-lint` takes a global lock** and aborts with `Error: parallel golangci-lint is running` if a second invocation overlaps — relevant when several worktrees or agents build/lint concurrently. Retry, or serialise; it is never a code problem. +- **A worktree-isolated agent cannot `git commit` here.** The `rtk`/`szr` `PreToolUse` hooks in `~/.claude/settings.json` rewrite `git commit`/`add`/`checkout`/`status`/`diff` into a `rtk git …` / `szr git …` launcher form, and the worktree-isolation guard then refuses the wrapped command; the permission classifier also denies the direct spelling. `git switch`, `git stage`, `git mv`, `git rev-parse`, `git ls-tree`, `git ls-files` pass through unrewritten, so an agent can stage and self-verify (`git ls-tree` diff of the staged tree against HEAD) but the commit itself must be made by the user or with the hooks disabled. Plan parallel-worktree work accordingly: have agents stage and write the message to a file, and commit at integration. diff --git a/.claude/knowledge/testing-patterns.md b/.claude/knowledge/testing-patterns.md index 48cf4cc8..146abdfd 100644 --- a/.claude/knowledge/testing-patterns.md +++ b/.claude/knowledge/testing-patterns.md @@ -26,3 +26,9 @@ Testing strategies, test infrastructure quirks, how to run/debug specific test s - **Report rendering is tested through the SDK.** `internal/codeguard/report` has no external test hooks, so `tests/report` drives it via `codeguard.WriteReport(w, report, format)` with a hand-built `codeguard.Report`. Note the text writer emits a large braille/ANSI banner, so assert on substring *positions* rather than diffing whole output. `tests/core` and `tests/report` were added as external packages for `core` and `report` respectively. - **Verifying a self-scan didn't add findings:** `make codeguard-ci` prints only a summary count, so compare against a clean worktree at HEAD and diff the `N. at:` location lines (`grep -E '^\s+[0-9]+\. at:' | sed 's/^ *[0-9]*\. at: //' | sort`). The repo's own `quality.ai.narrative-comment` rule fires on doc comments that describe what code is rather than why it exists, so new comments can add findings. +- **The precision/recall corpus covers the SECURITY section only.** `groupConfig` in `tests/corpus/corpus_test.go` builds a security-only SDK config, so the scoreboard measures 24 `security.*` rules out of ~254 catalog rules. Quality, design, performance, ci, supply-chain, prompts, agent-context, change, contracts, data and reliability have no ground-truth precision harness — a change there can only be regression-tested against hand-written fixtures. Extending the harness to another section means adding a group whose `groupConfig` enables that section (and disabling the command-driven rules, as the security groups do for govulncheck). +- **To measure false positives beyond the corpus, scan unrelated real repositories.** Write a config per language with an ABSOLUTE `targets[].path` (a relative `path: .` resolves against the *config file's* directory, so a config in a temp dir silently scans the temp dir and every repo yields identical output), set `cache.enabled: false`, `--format json`, then aggregate `rule_id` counts and sample findings with surrounding source. This surfaced the exclusion, fixture-classification, yarn-berry and PEP 503 issues; none were visible from the repo's own self-scan. +- **Determinism must be tested against a FROZEN tree, not a working copy.** Scanning a live repo gives different finding counts run to run (observed 1735–1905 on one repo, varying across 41 rules) purely because the tree mutates under the walk — build caches, daemons, editor temp files. `git archive HEAD | tar -x -C ` and scan that: the engine itself is deterministic (identical count and finding-set hash across runs). Don't chase a scheduling bug before ruling this out; `GOMAXPROCS=1` does NOT rule it out. +- **For rules that can fire en masse, assert the EXACT finding/message set, not presence.** `assertFindingRulePresent` cannot distinguish a rule firing once correctly from firing 33 times wrongly, which is exactly how the `lockfile-drift` Yarn Berry bug survived existing coverage. Table-driven cases over `supplyChainTestConfig` + `supplyChainRuleMessages` comparing the full message slice are the cheap shape for this. The suite must also pin both directions of any "stay silent when unsure" behaviour: a garbage lockfile produces zero findings, AND a genuinely empty-but-valid one still reports drift — otherwise a later "fix" can silence the rule entirely and still look green. +- **`golangci-lint` cannot run concurrently.** It takes a lock and aborts with `Error: parallel golangci-lint is running`, so parallel agents/worktrees linting at once all fail spuriously. Serialise lint runs, or pass a distinct `GOLANGCI_LINT_CACHE` and retry on that error — it is never a code problem. +- **Corpus `vulnerable/` fixtures must not use vendor-documented placeholder credentials.** Three fixtures used AWS's published `AKIAIOSFODNN7EXAMPLE`, so the harness was scoring a world-readable non-secret as a true positive — wrong ground truth, not a detector gap. Use the split-literal form instead (`"AKIA" + "<16 chars>"`): `foldConcatenatedLiterals` joins adjacent literals before matching, so detection is exercised while no contiguous provider token is committed, which is also what gets a realistic fixture past GitHub push protection. The documented placeholders belong in `clean/`. When a corpus TP count *rises* after a precision fix, check whether new `must_fire` cases were added versus ground truth being loosened — both look the same in the scoreboard. From 49cbaf0efc0e088f60e2a372bd0d2d4a1e745216 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 17 Sep 2026 15:51:53 -0400 Subject: [PATCH 07/16] fix(config): separate the init starter config from the defaults baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `config.ApplyDefaults` resolved omitted keys against `baseExampleConfig()` — the same constructor `codeguard init` uses to write its starter config — so every opinion written for the starter was the silent global default: - `ci_rules.allowed_test_paths: ["tests/**"]` made `ci.test-file-location` active at fail level for every repository, hard-failing idiomatic Go `_test.go`, `*.spec.ts` beside the source, pytest `test_*.py`, and a `test/` (singular) directory - `required_workflow_files`, `required_gates`, `workflow_content_rules` (requiring the literal strings `actions/checkout` and `go test ./...`), `required_release_files: [".goreleaser.yaml"]` and `required_automation_paths: ["Makefile"]` enforced codeguard's own release tooling on everyone, at fail level - the four `design_rules` layering toggles encoded codeguard's cmd/ -> internal/cli and pkg/ facade layout, also at fail level. `design.cmd-through-internal-cli` only ever matches an import containing "/pkg/codeguard", so it could not fire outside this repository at all The two roles are now distinct constructors. `baseDefaultConfig()` (defaults_base*.go) is the conservative baseline `ApplyDefaults` resolves against: scan mechanics, off-or-scan-mode section activation, and thresholds with no meaningful unset value. `baseExampleConfig()` (example.go) is that baseline plus `applyStarterPolicy()` — targets, the recommended section set and codeguard's CI and layering conventions — so `init` still writes a rich, opinionated, editable config. Every removed default is off via an empty list or a false toggle its rule already treats as "not configured", so the mechanism is intact and nothing was re-levelled. `applyCIDefaults` and `applyDesignDefaults` now read those toggles from the baseline instead of hardcoding true. The config-less `scan -folder` path bypassed `ApplyDefaults` entirely, resolving through `ExampleConfig()`, so it kept the house style even after the split. It now builds from `config.DefaultScanConfig`: the baseline plus `checks.use_recommended_defaults`, which reuses the single existing definition of the recommended set rather than copying it into the CLI. The target no longer asserts a language or carries codeguard's own `entrypoints: [cmd/codeguard]`. The init-only constructor is renamed `starterConfigForProfile` so a scan cannot fall back to it by accident. A profile's plain-bool section activations were inert whenever the profile arrived via `profile:` in a config or `-profile`, because `applyCheckDefaults` only defaults the `*bool` sections — `enterprise` never enabled supply_chain, `ai-safe` never enabled prompts/security. `applyProfileSectionActivation` now promotes only `true`, which is unambiguous only because the thinned baseline enables nothing. Note the consequence: an explicit `supply_chain: false` alongside `profile: enterprise` no longer disables the section, because a plain bool has no unset state; `checks.disabled` is the escape hatch and is still applied last. Measured on a snapshot of an ordinary Go repository. Minimal config with only `checks: {ci: true}` and one target: 143 fail-level findings -> 33. Config-less `scan -folder` from a directory with no discoverable config: 149 -> 36, with warn-level findings bit-identical at 1747 and all six sections still running. The remaining fails are genuine, 33 of them `ci.mutable-deployment-reference`, which has no config knob. This repository's own `.codeguard/codeguard.yaml` now states the CI gate and layering rules it previously inherited. The generated policy-profile comparison table is rendered from the defaults path instead of the starter, so its "Baseline" column is the real default policy. Co-Authored-By: Claude Opus 5 (1M context) --- .codeguard/codeguard.yaml | 16 + docs/checks.md | 67 ++++- docs/getting-started.md | 21 +- internal/cli/commands.go | 2 +- internal/cli/helpers.go | 26 +- internal/codeguard/config/defaults.go | 41 ++- internal/codeguard/config/defaults_base.go | 244 ++++++++++++++++ .../{example_ai.go => defaults_base_ai.go} | 18 +- .../codeguard/config/defaults_base_rules.go | 110 +++++++ internal/codeguard/config/defaults_helpers.go | 6 + internal/codeguard/config/defaults_rules.go | 14 +- internal/codeguard/config/defaults_scan.go | 49 ++++ internal/codeguard/config/example.go | 273 ++++-------------- internal/codeguard/config/example_rules.go | 94 ------ internal/codeguard/config/profile.go | 20 +- tests/cli/scan_zero_config_test.go | 198 +++++++++++++ tests/codeguard/defaults_house_style_test.go | 88 ++++++ 17 files changed, 939 insertions(+), 348 deletions(-) create mode 100644 internal/codeguard/config/defaults_base.go rename internal/codeguard/config/{example_ai.go => defaults_base_ai.go} (69%) create mode 100644 internal/codeguard/config/defaults_base_rules.go create mode 100644 internal/codeguard/config/defaults_scan.go delete mode 100644 internal/codeguard/config/example_rules.go create mode 100644 tests/cli/scan_zero_config_test.go create mode 100644 tests/codeguard/defaults_house_style_test.go diff --git a/.codeguard/codeguard.yaml b/.codeguard/codeguard.yaml index 81b70317..1f6214d7 100644 --- a/.codeguard/codeguard.yaml +++ b/.codeguard/codeguard.yaml @@ -54,6 +54,14 @@ checks: design_rules: max_decls_per_file: 60 god_module_threshold: 32 + # Package-layering policy is off by default (it describes one specific + # repository layout). codeguard's own layering invariants are cmd/ -> + # internal/cli and a pkg/codeguard facade that owns the only internal/ + # imports, so the self-scan states them explicitly. + require_cmd_through_internal_cli: true + forbid_internal_import_cmd: true + forbid_service_import_internal: true + forbid_service_import_cmd: true performance_rules: hot_package_importer_threshold: 32 rebuild_amplifier_threshold: 40 @@ -75,10 +83,18 @@ checks: # below 85 (the repo currently scores 100; see docs/checks.md). legibility_warn_threshold: 85 ci_rules: + # Every ci_rules list is empty by default: codeguard cannot know another + # repository's workflow filenames, release artifacts or test layout. This + # repository's conventions are therefore stated here in full. + # + # The tests/-only convention is enforced by review; internal/** and cmd/** + # are allowed here because a handful of in-package tests predate the rule. allowed_test_paths: - tests/** - internal/** - cmd/** + required_gates: + - test require_workflow_dir: true required_workflow_files: - .github/workflows/ci.yml diff --git a/docs/checks.md b/docs/checks.md index 4361a1a8..0fb606b3 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -108,9 +108,21 @@ It is therefore the final precedence layer. Accepted names are `quality`, invalid. When `use_recommended_defaults` is absent or `false`, section behavior is -unchanged from earlier configurations. Profiles are independent: they adjust -their own thresholds and policy settings, but do not select the recommended -baseline. +unchanged from earlier configurations. Profiles do not select the recommended +baseline — but a profile may enable sections of its own on top of it (for +example `enterprise` enables `supply_chain`, and `ai-safe` enables `prompts` +and `security`). Those activations apply wherever the profile is resolved: +`profile:` in a config file, `-profile` on the command line, and the +no-config-file scan path. `checks.disabled` is still applied last, so it is +the way to turn a profile's section back off. + +A scan run with **no config file at all** (`codeguard scan -folder ` +with no discoverable `codeguard.yaml`) resolves the built-in defaults with +`use_recommended_defaults` behavior applied, plus any `-profile`. It does not +use the config `codeguard init` writes, so none of the repository-shape policy +in that starter config — required workflow/release/automation files, workflow +content markers, required gates, `allowed_test_paths`, or the `design_rules` +package-layering toggles — is in effect on that path. `performance` is opt-in and covers N+1 query patterns, allocation-heavy loops, blocking I/O in request paths, unbounded concurrency, memory-pressure and framework-aware smells, Rust loop-smell heuristics, diff-mode complexity regressions, and measurement gates (size budgets, benchmark regression); see [Performance](#performance) for the rule list and the migration note for the former `quality.*` ids. @@ -325,7 +337,11 @@ Built-in profiles: - `ai-safe` The comparison below is generated from the profile definitions and verified by -the configuration tests. +the configuration tests. The `Baseline` column is the built-in default policy — +what a repository gets for any key it omits — and each profile column is that +baseline with the profile overlaid. The configuration `codeguard init` writes +is a richer, opinionated *starter* config on top of the same baseline; it is +not what an omitted key resolves to. | Setting | Baseline | Startup | Strict | Enterprise | AI-safe | @@ -339,8 +355,9 @@ the configuration tests. | `design_rules.max_methods_per_type` | 8 | 10 | 6 | 6 | 8 | | `design_rules.max_interface_methods` | 5 | 8 | 4 | 4 | 5 | | `security_rules.govulncheck_mode` | auto | auto | required | required | required | -| `ci_rules.required_release_files` | .goreleaser.yaml | — | .goreleaser.yaml | .goreleaser.yaml | .goreleaser.yaml | -| `ci_rules.required_automation_paths` | Makefile | Makefile | Makefile | Makefile
.github/workflows/ci.yml | Makefile | +| `ci_rules.required_release_files` | — | — | — | .goreleaser.yaml | — | +| `ci_rules.required_automation_paths` | — | — | — | Makefile
.github/workflows/ci.yml | — | +| `ci_rules.required_gates` | — | — | test | test
security | — | | `contracts` | scan-mode | scan-mode | true | true | scan-mode | | `reliability` | false | false | true | true | true | | `data` | false | false | false | true | true | @@ -1564,6 +1581,18 @@ Config keys: } ``` +Defaults: the four Go package-layering toggles +(`require_cmd_through_internal_cli`, `forbid_internal_import_cmd`, +`forbid_service_import_internal`, `forbid_service_import_cmd`) are **off by +default**. They describe one specific repository layout — `cmd/` delegating to +`internal/cli`, and a `pkg/` facade that must not reach into `internal/` or +`cmd/` — and report at fail level, and a `pkg/` package importing its own +module's `internal/` is ordinary Go. Set them to `true` to enforce that layout; +the config `codeguard init` writes does, and so does this repository's own +self-scan config. The remaining design rules (import cycles, god modules, +high-impact changes, the numeric thresholds, generic package names) stay on by +default. + Current behavior: - fails on configured layer, domain, data-ownership, capability, public-surface, and production/test boundary violations @@ -1917,10 +1946,30 @@ Config keys: } ``` +Defaults: every list above is **empty by default**, and each rule treats an +empty list as "not configured" and reports nothing. codeguard cannot know which +workflow filenames, release artifacts, command strings, or test directories a +repository uses, so none of them are enforced until the repository states them. +The only CI default is `require_workflow_dir: true`, since enabling +`checks.ci` is itself the assertion that the repository wants pipeline policy +checked and every other CI rule reads `.github/workflows`. + +`allowed_test_paths` in particular must stay unset for a repository with +co-located tests — idiomatic Go `_test.go` files, `*.spec.ts` beside the +source, or pytest `test_*.py` inside the package. Setting it to `["tests/**"]` +means "every test file outside `tests/` is a failure". + +The configuration `codeguard init` writes is a *starter* config, not the +defaults: it states codeguard's own conventions (`.github/workflows/ci.yml`, +a `test` gate, `actions/checkout` + `go test ./...` markers, +`.goreleaser.yaml`, `Makefile`, `tests/**`) so they are visible and editable. +Delete or replace the keys that do not describe your repository. + Current behavior: -- fails when required workflow, release, or automation files are missing -- fails when required workflow content markers are missing -- fails when detected Go, Python, TypeScript, C++, Rust, Java, C#, or Ruby test files live outside the configured test directories +- fails when a configured required workflow, release, or automation file is missing +- fails when a configured workflow content marker is missing +- fails when a configured CI gate (`required_gates`) is absent from the repository's workflows; skipped entirely when the repository has no workflow files +- fails when detected Go, Python, TypeScript, C++, Rust, Java, C#, or Ruby test files live outside `allowed_test_paths` — inactive while that list is unset ### Test quality diff --git a/docs/getting-started.md b/docs/getting-started.md index 0f82a681..2955ba6f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -48,9 +48,24 @@ codeguard explain prompts.secret-interpolation codeguard baseline -config codeguard.yaml -output codeguard-baseline.json ``` -`codeguard init` writes `codeguard.yaml` by default. - -Use `codeguard scan -folder ` when you want to scan only one folder. `-path ` is accepted as an alias. If no config file exists and you did not pass `-config`, folder scans use CodeGuard's built-in default config; add `-profile startup`, `-profile strict`, `-profile enterprise`, or `-profile ai-safe` to choose a default profile. +`codeguard init` writes `codeguard.yaml` by default. That file is a *starter* +config: it is complete and opinionated, and it states CodeGuard's own +conventions (a `.github/workflows/ci.yml`, a `test` gate, `.goreleaser.yaml`, +`Makefile`, tests under `tests/**`, and the `cmd/` → `internal/cli` package +layering). Edit or delete the keys that do not describe your repository. + +Use `codeguard scan -folder ` when you want to scan only one folder. +`-path ` is accepted as an alias. If no config file exists and you did +not pass `-config`, a folder scan runs the built-in defaults with the +recommended check sections enabled — Code Quality, Design Patterns, Security, +AI Prompts and CI/CD, plus the sections CodeGuard selects by scan mode (Agent +Context on full scans, API Contracts on diff scans). This is *not* the config +`codeguard init` writes: the defaults assert nothing about your repository's +shape, so none of the required-file, workflow-content, test-layout or +package-layering policy above is enforced until you configure it. Add +`-profile startup`, `-profile strict`, `-profile enterprise`, or `-profile +ai-safe` to overlay a policy profile, which may also enable further sections +(for example `enterprise` adds Supply Chain and Delivery). After upgrading CodeGuard, run `codeguard waivers audit -config codeguard.yaml` to identify waiver cleanup candidates. CodeGuard stores audit snapshots with version, config fingerprint, scan scope, and matched finding fingerprints; it only marks a waiver as stale after upgrade when prior comparable evidence shows the waiver matched findings before and matches none now. JSON output is available with `-format json`. diff --git a/internal/cli/commands.go b/internal/cli/commands.go index cace7faf..9ed1c87b 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -21,7 +21,7 @@ func runInit(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) return code } - cfg, err := exampleConfigForProfile(strings.TrimSpace(*profile)) + cfg, err := starterConfigForProfile(strings.TrimSpace(*profile)) if err != nil { _, _ = fmt.Fprintf(stderr, "init profile: %v\n", err) return exitError diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go index 39b2196d..d2e4a033 100644 --- a/internal/cli/helpers.go +++ b/internal/cli/helpers.go @@ -7,6 +7,7 @@ import ( "io" "strings" + "github.com/devr-tools/codeguard/internal/codeguard/config" service "github.com/devr-tools/codeguard/pkg/codeguard" ) @@ -57,7 +58,12 @@ func loadScanConfigWithFallback(path string, profile string, targetPath string, return service.Config{}, err } - cfg, err = exampleConfigForProfile(profile) + // No config file: resolve the built-in defaults, NOT the `codeguard init` + // starter config. The starter states codeguard's own conventions, so using + // it here made a first run on an unseen repository fail on this repo's + // workflow filenames, `go test ./...` marker, tests/ layout and cmd//pkg + // layering. + cfg, err = config.DefaultScanConfig(profile) if err != nil { return service.Config{}, err } @@ -67,6 +73,17 @@ func loadScanConfigWithFallback(path string, profile string, targetPath string, return cfg, nil } +// starterConfigForProfile returns the rich, opinionated config `codeguard +// init` writes into a repository. It is only correct for init: the user reads +// and edits the result. A scan must never fall back to it — see +// config.DefaultScanConfig. +func starterConfigForProfile(profile string) (service.Config, error) { + if strings.TrimSpace(profile) == "" { + return service.ExampleConfig(), nil + } + return service.ExampleConfigForProfile(profile) +} + func applyProfileOverride(cfg service.Config, profile string) (service.Config, error) { if strings.TrimSpace(profile) != "" { cfg.Profile = strings.TrimSpace(profile) @@ -77,10 +94,3 @@ func applyProfileOverride(cfg service.Config, profile string) (service.Config, e } return cfg, nil } - -func exampleConfigForProfile(profile string) (service.Config, error) { - if strings.TrimSpace(profile) == "" { - return service.ExampleConfig(), nil - } - return service.ExampleConfigForProfile(profile) -} diff --git a/internal/codeguard/config/defaults.go b/internal/codeguard/config/defaults.go index afa65f1b..e5a97292 100644 --- a/internal/codeguard/config/defaults.go +++ b/internal/codeguard/config/defaults.go @@ -15,7 +15,7 @@ func ApplyDefaults(cfg *core.Config) { } func defaultConfigForProfile(profile string) core.Config { - def := baseExampleConfig() + def := baseDefaultConfig() normalized := normalizeProfile(profile) if spec, ok := profileCatalog[normalized]; ok { spec.apply(&def) @@ -55,6 +55,7 @@ func applyRootDefaults(cfg *core.Config, def core.Config) { } func applyCheckDefaults(cfg *core.Config, def core.Config) { + applyProfileSectionActivation(&cfg.Checks, def.Checks) if cfg.Checks.Contracts == nil { cfg.Checks.Contracts = def.Checks.Contracts } @@ -99,6 +100,44 @@ func applyCheckDefaults(cfg *core.Config, def core.Config) { applyCheckActivationDefaults(&cfg.Checks) } +// applyProfileSectionActivation carries a profile's activation of the +// plain-bool sections through to the resolved config. +// +// Those six sections are plain bools, not *bool, so they have no unset state: +// applyCheckDefaults cannot copy def's value wholesale, because an omitted key +// and an explicit `supply_chain: false` are the same value. That is why they +// were never defaulted — with the consequence that a profile spec's +// `cfg.Checks.SupplyChain = true` (enterprise) or `cfg.Checks.Prompts = true` +// (ai-safe) was silently inert whenever the profile arrived through +// `profile:` in a config file or through -profile, leaving those profiles +// weaker than their documented description. +// +// Promoting only `true` is now safe and unambiguous: the conservative baseline +// (baseDefaultChecks) enables no section, so a true in def can only have come +// from the profile spec. A repository that wants a profile's section back off +// uses checks.disabled, which applyCheckActivationDefaults applies after this +// and which therefore still wins. +func applyProfileSectionActivation(checks *core.CheckConfig, def core.CheckConfig) { + if def.Quality { + checks.Quality = true + } + if def.Design { + checks.Design = true + } + if def.Security { + checks.Security = true + } + if def.Prompts { + checks.Prompts = true + } + if def.CI { + checks.CI = true + } + if def.SupplyChain { + checks.SupplyChain = true + } +} + func applyRulePackDefaults(cfg *core.Config) { for packIdx := range cfg.RulePacks { for ruleIdx := range cfg.RulePacks[packIdx].Rules { diff --git a/internal/codeguard/config/defaults_base.go b/internal/codeguard/config/defaults_base.go new file mode 100644 index 00000000..7495de77 --- /dev/null +++ b/internal/codeguard/config/defaults_base.go @@ -0,0 +1,244 @@ +package config + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +// baseDefaultConfig is the conservative policy baseline that ApplyDefaults +// fills omitted configuration keys from. Every value here silently becomes the +// effective policy for any repository that does not set that key, so it must +// hold only settings that are defensible for ANY repository: scan mechanics +// (cache/output paths, parser mode), section activation that is off or +// scan-mode derived, and thresholds that have no meaningful "unset" value +// (a max of 0 would fail everything). +// +// It deliberately holds NO repository-shape policy — no required filenames, no +// workflow content markers, no test-directory layout, no package-layering +// rules. codeguard cannot know those apply. Such policy belongs in +// applyStarterPolicy (see example.go), which only `codeguard init` writes, or +// in a named profile, which a repository opts into explicitly. +// +// Adding a value here is a decision to enforce it on every codeguard user. +func baseDefaultConfig() core.Config { + return core.Config{ + Name: "codeguard-default", + Checks: baseDefaultChecks(), + AI: baseDefaultAIConfig(), + Output: core.OutputConfig{Format: "text"}, + Cache: baseDefaultCacheConfig(), + Parsers: core.ParsersConfig{TreeSitter: core.TreeSitterModeOff}, + } +} + +// baseDefaultChecks holds section activation. The plain-bool sections +// (quality, design, security, prompts, ci, supply_chain) are NOT defaulted by +// applyCheckDefaults — an omitted key means off — so their value here is only +// a record of the recommended set; applyCheckActivationDefaults +// (checks.use_recommended_defaults) and the starter config are what turn them +// on. The *bool sections below ARE defaulted from here. +func baseDefaultChecks() core.CheckConfig { + return core.CheckConfig{ + Quality: false, + Design: false, + // Performance is opt-in while the rules settle into their own section; + // they previously ran (enabled) inside quality under quality.* ids. The + // explicit false (vs nil) writes the key into generated configs so new + // users discover it, and suppresses the upgrade hint in scan output. + Performance: boolPtr(false), + Security: false, + Prompts: false, + CI: false, + SupplyChain: false, + Delivery: boolPtr(false), + Reliability: boolPtr(false), + Data: boolPtr(false), + Observability: boolPtr(false), + Operations: boolPtr(false), + Change: boolPtr(false), + QualityRules: baseDefaultQualityRules(), + PerformanceRules: baseDefaultPerformanceRules(), + DesignRules: baseDefaultDesignRules(), + PromptRules: baseDefaultPromptRules(), + CIRules: baseDefaultCIRules(), + DeliveryRules: baseDefaultDeliveryRules(), + SecurityRules: baseDefaultSecurityRules(), + SupplyChainRules: baseDefaultSupplyChainRules(), + ReliabilityRules: baseDefaultReliabilityRules(), + DataRules: baseDefaultDataRules(), + ObservabilityRules: baseDefaultObservabilityRules(), + OperationsRules: baseDefaultOperationsRules(), + ChangeRules: baseDefaultChangeRules(), + ContractRules: baseDefaultContractRules(), + ContextRules: baseDefaultContextRules(), + ProductionRisk: baseDefaultProductionRisk(), + } +} + +func baseDefaultContextRules() core.ContextRulesConfig { + return core.ContextRulesConfig{ + DetectMissingAgentDocs: boolPtr(true), + DetectAgentDocsDrift: boolPtr(true), + DetectReadmeDrift: boolPtr(true), + DetectOversizedFiles: boolPtr(true), + DetectAmbiguousSymbols: boolPtr(true), + DetectUndocumentedCommands: boolPtr(true), + DetectOversizedAgentDocs: boolPtr(true), + DetectDocLinkRot: boolPtr(true), + MaxFileLines: 1500, + AmbiguousSymbolThreshold: 4, + MaxAgentDocLines: 600, + } +} + +func baseDefaultSupplyChainRules() core.SupplyChainRulesConfig { + return core.SupplyChainRulesConfig{ + RequireLockfile: boolPtr(true), + DetectLockfileDrift: boolPtr(true), + DetectUnpinned: boolPtr(true), + DetectProvenance: boolPtr(true), + } +} + +func baseDefaultDeliveryRules() core.DeliveryRulesConfig { + return core.DeliveryRulesConfig{ + DetectMissingRollbackStrategy: boolPtr(true), + DetectUnsafeMigrationOrder: boolPtr(true), + DetectHighRiskChangeWithoutKillSwitch: boolPtr(true), + DetectMissingPostDeployVerification: boolPtr(true), + RollbackEvidencePatterns: []string{"rollback", "roll back", "revert", "restore", "previous version"}, + KillSwitchPatterns: []string{"feature flag", "feature_flag", "kill switch", "killswitch", "rollout", "launchdarkly"}, + PostDeployVerificationPatterns: []string{"smoke", "health", "synthetic", "post-deploy", "post deploy", "curl", "slo"}, + MigrationPathPatterns: []string{"migrations/**", "db/migrate/**", "alembic/**"}, + HighRiskPathPatterns: []string{"**/payment/**", "**/payments/**", "**/auth/**", "**/checkout/**", "**/billing/**", "**/migration/**", "**/migrations/**"}, + BootstrapPathPatterns: []string{"cmd/**", "config/**", "configs/**", "**/config/**", "**/bootstrap/**", "scripts/**", ".github/**"}, + } +} + +func baseDefaultObservabilityRules() core.ObservabilityRulesConfig { + return core.ObservabilityRulesConfig{ + DetectUnstructuredLog: boolPtr(true), + DetectErrorWithoutContext: boolPtr(true), + DetectSensitiveLogData: boolPtr(true), + DetectHighCardinalityLabel: boolPtr(true), + DetectCriticalPathUninstrumented: boolPtr(true), + DetectLogAndIgnore: boolPtr(true), + DetectShallowHealthCheck: boolPtr(true), + StructuredLoggerPatterns: []string{"logger.", "logrus.", "zap.", "slog.", "zerolog.", "structlog.", "logging."}, + SensitiveNamePatterns: []string{"password", "passwd", "secret", "token", "api_key", "apikey", "authorization", "cookie", "ssn", "email"}, + HighCardinalityLabelPatterns: []string{"user_id", "userid", "email", "request_id", "requestid", "trace_id", "session_id", "uuid", "path", "url"}, + CriticalPathPatterns: []string{"handler", "controller", "consumer", "job", "worker", "payment", "checkout", "auth", "migration"}, + HealthcheckPathPatterns: []string{"health", "healthz", "ready", "readyz", "live", "livez"}, + InstrumentationEvidencePatterns: []string{"span", "trace", "metric", "counter", "histogram", "observe", "instrument", "prometheus"}, + } +} + +func baseDefaultOperationsRules() core.OperationsRulesConfig { + return core.OperationsRulesConfig{ + DetectMissingOwner: boolPtr(true), + DetectMissingRunbook: boolPtr(true), + OwnerFilePatterns: []string{"CODEOWNERS", ".github/CODEOWNERS", "OWNERS", "owners.yaml", "catalog-info.yaml", "service.yaml", "service.yml"}, + RunbookPathPatterns: []string{"runbook", "runbooks", "docs/runbooks", "ops", "operations"}, + CriticalPathPatterns: []string{"cmd/", "internal/", "service", "api", "worker", "consumer", "job", "payment", "auth", "deploy", "migrations"}, + } +} + +func baseDefaultReliabilityRules() core.ReliabilityRulesConfig { + return core.ReliabilityRulesConfig{ + DetectMissingTimeout: boolPtr(true), + DetectUnboundedRetry: boolPtr(true), + DetectRetryWithoutBackoff: boolPtr(true), + DetectNonIdempotentRetry: boolPtr(true), + DetectMissingCancellation: boolPtr(true), + DetectUnboundedWork: boolPtr(true), + DetectMissingConcurrencyLimit: boolPtr(true), + DetectResourceLeak: boolPtr(true), + DetectPartialFailureHidden: boolPtr(true), + DetectMissingGracefulShutdown: boolPtr(true), + DetectSwallowedError: boolPtr(true), + DetectLostErrorContext: boolPtr(true), + DetectRecoverablePanic: boolPtr(true), + MaxRetryAttempts: 3, + MaxInlineGoroutinesPerFunction: 4, + } +} + +func baseDefaultDataRules() core.DataRulesConfig { + return core.DataRulesConfig{ + DetectReadModifyWriteRace: boolPtr(true), + DetectMissingTransaction: boolPtr(true), + DetectSideEffectInTransaction: boolPtr(true), + DetectNonIdempotentConsumer: boolPtr(true), + DetectMissingDeduplication: boolPtr(true), + DetectUnsafeDualWrite: boolPtr(true), + DetectMissingOutboxStrategy: boolPtr(true), + DetectUnstablePagination: boolPtr(true), + DetectUnboundedRead: boolPtr(true), + DetectExactlyOnceAssumption: boolPtr(true), + DetectCacheWithoutPolicy: boolPtr(true), + MaxUnboundedReadRows: 1000, + MaxWritesWithoutTransaction: 1, + } +} + +func baseDefaultChangeRules() core.ChangeRulesConfig { + return core.ChangeRulesConfig{ + DetectBehaviorChangeWithoutTest: boolPtr(true), + DetectFailurePathMissing: boolPtr(true), + DetectHardwiredDependency: boolPtr(true), + DetectNondeterministicDomain: boolPtr(true), + DetectLegacyHotspotUncovered: boolPtr(true), + DetectMixedConcerns: boolPtr(true), + DetectOversizedDiff: boolPtr(true), + DetectMixedRefactorAndBehavior: boolPtr(true), + DetectTooManyConcerns: boolPtr(true), + DetectUnnecessarySurfaceArea: boolPtr(true), + DetectOneUseAbstraction: boolPtr(true), + DetectDuplicateHelper: boolPtr(true), + DetectCleanupRegression: boolPtr(true), + DetectComplexityIncreased: boolPtr(true), + DetectMoveWithoutVerification: boolPtr(true), + DetectRefactorBehaviorChange: boolPtr(true), + DetectRefactorPublicContract: boolPtr(true), + DetectRefactorTestCoverageDrop: boolPtr(true), + DetectRefactorErrorPathChange: boolPtr(true), + DetectRefactorSideEffectReorder: boolPtr(true), + DetectRefactorVisibilityExpand: boolPtr(true), + DetectRefactorDependencyWorsened: boolPtr(true), + DetectRefactorDuplicateLeftBehind: boolPtr(true), + DetectRefactorDeadPathLeftBehind: boolPtr(true), + MaxChangedFiles: 25, + MaxChangedDirectories: 8, + MaxChangedLines: 800, + MaxPublicInterfacesChanged: 3, + MaxConcernFamilies: 3, + MinTestToProductionRatioPercent: 20, + } +} + +func baseDefaultProductionRisk() core.ProductionRiskConfig { + return core.ProductionRiskConfig{ + Enabled: boolPtr(true), + WarnThreshold: 35, + FailThreshold: 70, + ReliabilityWeight: 12, + DataWeight: 15, + FailWeight: 25, + WarnWeight: 10, + } +} + +func baseDefaultContractRules() core.ContractRulesConfig { + return core.ContractRulesConfig{ + GoExportedBreaking: boolPtr(true), + CPPPublicBreaking: boolPtr(true), + OpenAPIBreaking: boolPtr(true), + ProtoBreaking: boolPtr(true), + MigrationDestructive: boolPtr(true), + MigrationPaths: []string{"migrations/", "db/migrate/", "alembic/"}, + } +} + +func baseDefaultCacheConfig() core.CacheConfig { + return core.CacheConfig{ + Enabled: boolPtr(true), + Path: ".codeguard/cache.json", + } +} diff --git a/internal/codeguard/config/example_ai.go b/internal/codeguard/config/defaults_base_ai.go similarity index 69% rename from internal/codeguard/config/example_ai.go rename to internal/codeguard/config/defaults_base_ai.go index bfb46b14..c790bd44 100644 --- a/internal/codeguard/config/example_ai.go +++ b/internal/codeguard/config/defaults_base_ai.go @@ -2,18 +2,18 @@ package config import "github.com/devr-tools/codeguard/internal/codeguard/core" -func exampleAIConfig() core.AIConfig { +func baseDefaultAIConfig() core.AIConfig { return core.AIConfig{ Enabled: boolPtr(false), - Provider: exampleAIProviderConfig(), + Provider: baseDefaultAIProviderConfig(), Cache: core.AICacheConfig{Path: ".codeguard/ai-cache.json"}, - HybridTriage: exampleAIHybridTriageConfig(), - Semantic: exampleAISemanticConfig(), - AutoFix: exampleAIAutoFixConfig(), + HybridTriage: baseDefaultAIHybridTriageConfig(), + Semantic: baseDefaultAISemanticConfig(), + AutoFix: baseDefaultAIAutoFixConfig(), } } -func exampleAIProviderConfig() core.AIProviderConfig { +func baseDefaultAIProviderConfig() core.AIProviderConfig { return core.AIProviderConfig{ //nolint:gosec // APIKeyEnv is an env var name, not a credential Type: "openai", Model: "gpt-5", @@ -22,7 +22,7 @@ func exampleAIProviderConfig() core.AIProviderConfig { } } -func exampleAIHybridTriageConfig() core.AIHybridTriageConfig { +func baseDefaultAIHybridTriageConfig() core.AIHybridTriageConfig { return core.AIHybridTriageConfig{ Enabled: boolPtr(true), SuppressDismissed: boolPtr(true), @@ -31,7 +31,7 @@ func exampleAIHybridTriageConfig() core.AIHybridTriageConfig { } } -func exampleAISemanticConfig() core.AISemanticConfig { +func baseDefaultAISemanticConfig() core.AISemanticConfig { return core.AISemanticConfig{ Enabled: boolPtr(true), FunctionContract: boolPtr(true), @@ -42,7 +42,7 @@ func exampleAISemanticConfig() core.AISemanticConfig { } } -func exampleAIAutoFixConfig() core.AIAutoFixConfig { +func baseDefaultAIAutoFixConfig() core.AIAutoFixConfig { return core.AIAutoFixConfig{ Enabled: boolPtr(false), VerifyTests: boolPtr(true), diff --git a/internal/codeguard/config/defaults_base_rules.go b/internal/codeguard/config/defaults_base_rules.go new file mode 100644 index 00000000..e2e4474c --- /dev/null +++ b/internal/codeguard/config/defaults_base_rules.go @@ -0,0 +1,110 @@ +package config + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +func baseDefaultQualityRules() core.QualityRulesConfig { + return core.QualityRulesConfig{ + MaxFileLines: 400, + MaxFunctionLines: 80, + MaxParameters: 5, + MaxCyclomaticComplexity: 10, + CloneTokenThreshold: 90, + AIProvenance: core.AIProvenanceConfig{ + Enabled: boolPtr(true), + EnvVars: []string{"CODEGUARD_AI_ASSISTED"}, + CommitTrailers: []string{"AI-Assisted", "AI-Generated"}, + SlopScoreWarnThreshold: 20, + SlopScoreFailThreshold: 40, + }, + AIChangeRisk: core.AIChangeRiskConfig{ + Enabled: boolPtr(true), + WarnThreshold: 30, + FailThreshold: 60, + }, + CPPTooling: core.CPPToolingConfig{ + ClangFormatMode: core.ExternalToolModeOff, + ClangFormatCommand: "clang-format", + CompilerMode: core.ExternalToolModeOff, + CompilerCommand: "clang++", + }, + Naming: core.QualityNamingConfig{ + AllowedAbbreviations: []string{"api", "cli", "cpp", "db", "html", "http", "https", "id", "io", "ip", "js", "json", "orm", "rpc", "sdk", "sql", "tcp", "ts", "ui", "url", "uuid", "xml", "yaml"}, + RoleSuffixWarnThreshold: 4, + }, + } +} + +func baseDefaultPerformanceRules() core.PerformanceRulesConfig { + return core.PerformanceRulesConfig{ + DetectPreallocInLoop: boolPtr(false), + } +} + +func baseDefaultDesignRules() core.DesignRulesConfig { + return core.DesignRulesConfig{ + // Package-layering policy: off by default. These four rules encode one + // specific repository layout (cmd/ delegating to internal/cli, a pkg/ + // facade that must not reach into internal/ or cmd/) and report at + // fail level. `design.cmd-through-internal-cli` additionally only ever + // matches an import of ".../pkg/codeguard", so it cannot fire outside + // this repository at all. A repository that wants the layering + // enforced states it in design_rules; the starter config and the + // self-scan config both do. + RequireCmdThroughInternalCLI: boolPtr(false), + ForbidInternalImportCmd: boolPtr(false), + ForbidServiceImportInternal: boolPtr(false), + ForbidServiceImportCmd: boolPtr(false), + MaxDeclsPerFile: 12, + GodModuleThreshold: 25, + HighImpactChangeThreshold: 10, + MaxMethodsPerType: 8, + MaxInterfaceMethods: 5, + ForbiddenPackageNames: []string{"util", "utils", "common", "helpers", "misc"}, + } +} + +func baseDefaultPromptRules() core.PromptRulesConfig { + return core.PromptRulesConfig{ + FileExtensions: []string{".prompt", ".md", ".txt", ".tmpl", ".yaml", ".yml", ".json"}, + PathContains: []string{"prompt", "system", "instruction", "template"}, + ForbidSecretInterpolation: boolPtr(true), + ForbidUnsafeInstructions: boolPtr(true), + } +} + +// baseDefaultCIRules is deliberately almost empty. Every list in +// CIRulesConfig names a repository convention — a workflow filename, a release +// artifact, a test directory, a literal command string — that codeguard cannot +// know applies. Each rule already treats an empty list as "not configured", so +// leaving them unset keeps the mechanism intact while enforcing nothing: +// +// - required_workflow_files / required_release_files / +// required_automation_paths: requiredPathFindings iterates the list. +// - workflow_content_rules: workflowContentFindings iterates the list. +// - required_gates: missingRequiredGateFindings returns early when empty. +// - allowed_test_paths: testFileLocationFindings returns early when empty. +// A non-empty default here hard-failed every repository with co-located +// tests (idiomatic Go `_test.go`, `*.spec.ts`, pytest `test_*.py`). +// +// The opinionated versions of all of these live in the starter config and in +// the enterprise/strict profiles. +func baseDefaultCIRules() core.CIRulesConfig { + return core.CIRulesConfig{ + // The only CI default: enabling the section is itself the assertion + // that the repository wants pipeline policy checked, and every other + // CI rule reads workflow files from .github/workflows. + RequireWorkflowDir: boolPtr(true), + } +} + +func baseDefaultSecurityRules() core.SecurityRulesConfig { + return core.SecurityRulesConfig{ + GovulncheckMode: "auto", + GovulncheckCommand: "govulncheck", + TypeScriptTaintMaxDepth: 8, + Secrets: &core.SecretsRulesConfig{ + Enabled: boolPtr(true), + AllowPaths: []string{"testdata/**", "**/testdata/**"}, + }, + } +} diff --git a/internal/codeguard/config/defaults_helpers.go b/internal/codeguard/config/defaults_helpers.go index f5556873..acd01f9f 100644 --- a/internal/codeguard/config/defaults_helpers.go +++ b/internal/codeguard/config/defaults_helpers.go @@ -43,6 +43,12 @@ func boolValueOrTrue(ptr *bool) bool { return *ptr } +// boolValue reads an optional bool, treating an unset (nil) pointer as false. +// Use it when reading a baseline value whose "not stated" answer is off. +func boolValue(ptr *bool) bool { + return ptr != nil && *ptr +} + // defaultStringSlice fills a string-slice setting with a copy of its default // when unset. func defaultStringSlice(dst *[]string, def []string) { diff --git a/internal/codeguard/config/defaults_rules.go b/internal/codeguard/config/defaults_rules.go index f86bdcc4..87f20c04 100644 --- a/internal/codeguard/config/defaults_rules.go +++ b/internal/codeguard/config/defaults_rules.go @@ -96,11 +96,13 @@ func applyDesignDefaults(dst *core.DesignRulesConfig, def core.DesignRulesConfig &dst.DetectImportCycles, &dst.DetectGodModules, &dst.DetectHighImpactChanges, - &dst.RequireCmdThroughInternalCLI, - &dst.ForbidInternalImportCmd, - &dst.ForbidServiceImportInternal, - &dst.ForbidServiceImportCmd, ) + // Package-layering policy is read from the baseline rather than forced on, + // so baseDefaultDesignRules stays the single place that decides it. + defaultBoolPtr(&dst.RequireCmdThroughInternalCLI, boolValue(def.RequireCmdThroughInternalCLI)) + defaultBoolPtr(&dst.ForbidInternalImportCmd, boolValue(def.ForbidInternalImportCmd)) + defaultBoolPtr(&dst.ForbidServiceImportInternal, boolValue(def.ForbidServiceImportInternal)) + defaultBoolPtr(&dst.ForbidServiceImportCmd, boolValue(def.ForbidServiceImportCmd)) defaultCommandMap(&dst.LanguageCommands, def.LanguageCommands) defaultCommandMap(&dst.LanguageDiffCommands, def.LanguageDiffCommands) } @@ -121,9 +123,7 @@ func applyPromptDefaults(dst *core.PromptRulesConfig, def core.PromptRulesConfig } func applyCIDefaults(dst *core.CIRulesConfig, def core.CIRulesConfig) { - if dst.RequireWorkflowDir == nil { - dst.RequireWorkflowDir = boolPtr(true) - } + defaultBoolPtr(&dst.RequireWorkflowDir, boolValue(def.RequireWorkflowDir)) if dst.RequiredWorkflowFiles == nil { dst.RequiredWorkflowFiles = append([]string(nil), def.RequiredWorkflowFiles...) } diff --git a/internal/codeguard/config/defaults_scan.go b/internal/codeguard/config/defaults_scan.go new file mode 100644 index 00000000..31648d38 --- /dev/null +++ b/internal/codeguard/config/defaults_scan.go @@ -0,0 +1,49 @@ +package config + +import ( + "fmt" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// DefaultScanConfig returns the configuration a scan uses when the repository +// has no config file at all — the `codeguard scan -folder ` path, which +// is the first thing a new user runs. +// +// It is the conservative baseline (baseDefaultConfig) plus section activation +// and one target. It is deliberately NOT the starter config that `codeguard +// init` writes: doing that made a user with no config file inherit codeguard's +// own house style — its workflow filenames, its `go test ./...` content +// marker, its `tests/**` test layout and its cmd//pkg layering rules — all at +// fail level, on a repository codeguard had never seen. +// +// Section activation is expressed as checks.use_recommended_defaults rather +// than by assembling the section booleans here. That keeps exactly one +// definition of "the recommended set" (enableRecommendedChecks), keeps +// checks.disabled working, and means a future baseline change reaches this +// path automatically instead of needing a second edit. +// +// The target keeps path "." and declares no language. The runner's +// ApplyTargetPath retargets the single target onto the operator-supplied scan +// folder, and the per-language dispatch switches treat an empty language +// exactly like "go" (`case "", "go":`) — so behavior is unchanged while the +// config stops asserting a language, and an entrypoint, it cannot know. +func DefaultScanConfig(profile string) (core.Config, error) { + normalized := normalizeProfile(profile) + if normalized != "" { + if _, ok := profileCatalog[normalized]; !ok { + return core.Config{}, fmt.Errorf("unknown profile %q", profile) + } + } + + cfg := core.Config{ + Profile: normalized, + Targets: []core.TargetConfig{{Name: "repository", Path: "."}}, + Checks: core.CheckConfig{UseRecommendedDefaults: true}, + } + ApplyDefaults(&cfg) + if err := Validate(cfg); err != nil { + return core.Config{}, err + } + return cfg, nil +} diff --git a/internal/codeguard/config/example.go b/internal/codeguard/config/example.go index 43b40cab..6d423d0f 100644 --- a/internal/codeguard/config/example.go +++ b/internal/codeguard/config/example.go @@ -2,19 +2,37 @@ package config import "github.com/devr-tools/codeguard/internal/codeguard/core" +// baseExampleConfig is the rich, opinionated starter configuration that +// `codeguard init` writes and that ExampleConfig exposes. It is the +// conservative default baseline (baseDefaultConfig) plus a starter policy +// overlay. +// +// The split matters because ApplyDefaults resolves omitted keys against +// baseDefaultConfig, NOT against this function. Before the split there was one +// constructor serving both roles, so every opinion written for the starter +// config — codeguard's own workflow filenames, release artifacts, `go test +// ./...` marker and tests/ layout — was silently the effective global default +// for anyone who omitted those keys. Anything added here is a suggestion the +// user reads and edits in their own config file; anything added to +// baseDefaultConfig is policy enforced on every user. func baseExampleConfig() core.Config { - return core.Config{ - Name: "codeguard-default", - Targets: exampleTargets(), - Checks: exampleChecks(), - AI: exampleAIConfig(), - Output: core.OutputConfig{Format: "text"}, - Cache: exampleCacheConfig(), - Parsers: core.ParsersConfig{TreeSitter: core.TreeSitterModeOff}, - } + cfg := baseDefaultConfig() + applyStarterPolicy(&cfg) + return cfg } -func exampleTargets() []core.TargetConfig { +// applyStarterPolicy layers codeguard's own house conventions onto the +// conservative baseline. Everything set here is written into the generated +// config file, so the user can see it and change it; none of it reaches a +// repository that does not have a config, or that omits the key. +func applyStarterPolicy(cfg *core.Config) { + cfg.Targets = starterTargets() + applyStarterSectionActivation(&cfg.Checks) + applyStarterCIPolicy(&cfg.Checks.CIRules) + applyStarterDesignLayering(&cfg.Checks.DesignRules) +} + +func starterTargets() []core.TargetConfig { return []core.TargetConfig{{ Name: "repository", Path: ".", @@ -23,211 +41,40 @@ func exampleTargets() []core.TargetConfig { }} } -func exampleChecks() core.CheckConfig { - return core.CheckConfig{ - Quality: true, - Design: true, - // Performance is opt-in while the rules settle into their own section; - // they previously ran (enabled) inside quality under quality.* ids. The - // explicit false (vs nil) writes the key into generated configs so new - // users discover it, and suppresses the upgrade hint in scan output. - Performance: boolPtr(false), - Security: true, - Prompts: true, - CI: true, - SupplyChain: false, - Delivery: boolPtr(false), - Reliability: boolPtr(false), - Data: boolPtr(false), - Observability: boolPtr(false), - Operations: boolPtr(false), - Change: boolPtr(false), - QualityRules: exampleQualityRules(), - PerformanceRules: examplePerformanceRules(), - DesignRules: exampleDesignRules(), - PromptRules: examplePromptRules(), - CIRules: exampleCIRules(), - DeliveryRules: exampleDeliveryRules(), - SecurityRules: exampleSecurityRules(), - SupplyChainRules: exampleSupplyChainRules(), - ReliabilityRules: exampleReliabilityRules(), - DataRules: exampleDataRules(), - ObservabilityRules: exampleObservabilityRules(), - OperationsRules: exampleOperationsRules(), - ChangeRules: exampleChangeRules(), - ContractRules: exampleContractRules(), - ContextRules: exampleContextRules(), - ProductionRisk: exampleProductionRisk(), - } -} - -func exampleContextRules() core.ContextRulesConfig { - return core.ContextRulesConfig{ - DetectMissingAgentDocs: boolPtr(true), - DetectAgentDocsDrift: boolPtr(true), - DetectReadmeDrift: boolPtr(true), - DetectOversizedFiles: boolPtr(true), - DetectAmbiguousSymbols: boolPtr(true), - DetectUndocumentedCommands: boolPtr(true), - DetectOversizedAgentDocs: boolPtr(true), - DetectDocLinkRot: boolPtr(true), - MaxFileLines: 1500, - AmbiguousSymbolThreshold: 4, - MaxAgentDocLines: 600, - } -} - -func exampleSupplyChainRules() core.SupplyChainRulesConfig { - return core.SupplyChainRulesConfig{ - RequireLockfile: boolPtr(true), - DetectLockfileDrift: boolPtr(true), - DetectUnpinned: boolPtr(true), - DetectProvenance: boolPtr(true), - } +// applyStarterSectionActivation turns on the recommended section set. The +// plain-bool sections get no defaulting in applyCheckDefaults (an omitted key +// means off), so a generated config has to state them. +func applyStarterSectionActivation(checks *core.CheckConfig) { + checks.Quality = true + checks.Design = true + checks.Security = true + checks.Prompts = true + checks.CI = true } -func exampleDeliveryRules() core.DeliveryRulesConfig { - return core.DeliveryRulesConfig{ - DetectMissingRollbackStrategy: boolPtr(true), - DetectUnsafeMigrationOrder: boolPtr(true), - DetectHighRiskChangeWithoutKillSwitch: boolPtr(true), - DetectMissingPostDeployVerification: boolPtr(true), - RollbackEvidencePatterns: []string{"rollback", "roll back", "revert", "restore", "previous version"}, - KillSwitchPatterns: []string{"feature flag", "feature_flag", "kill switch", "killswitch", "rollout", "launchdarkly"}, - PostDeployVerificationPatterns: []string{"smoke", "health", "synthetic", "post-deploy", "post deploy", "curl", "slo"}, - MigrationPathPatterns: []string{"migrations/**", "db/migrate/**", "alembic/**"}, - HighRiskPathPatterns: []string{"**/payment/**", "**/payments/**", "**/auth/**", "**/checkout/**", "**/billing/**", "**/migration/**", "**/migrations/**"}, - BootstrapPathPatterns: []string{"cmd/**", "config/**", "configs/**", "**/config/**", "**/bootstrap/**", "scripts/**", ".github/**"}, - } -} - -func exampleObservabilityRules() core.ObservabilityRulesConfig { - return core.ObservabilityRulesConfig{ - DetectUnstructuredLog: boolPtr(true), - DetectErrorWithoutContext: boolPtr(true), - DetectSensitiveLogData: boolPtr(true), - DetectHighCardinalityLabel: boolPtr(true), - DetectCriticalPathUninstrumented: boolPtr(true), - DetectLogAndIgnore: boolPtr(true), - DetectShallowHealthCheck: boolPtr(true), - StructuredLoggerPatterns: []string{"logger.", "logrus.", "zap.", "slog.", "zerolog.", "structlog.", "logging."}, - SensitiveNamePatterns: []string{"password", "passwd", "secret", "token", "api_key", "apikey", "authorization", "cookie", "ssn", "email"}, - HighCardinalityLabelPatterns: []string{"user_id", "userid", "email", "request_id", "requestid", "trace_id", "session_id", "uuid", "path", "url"}, - CriticalPathPatterns: []string{"handler", "controller", "consumer", "job", "worker", "payment", "checkout", "auth", "migration"}, - HealthcheckPathPatterns: []string{"health", "healthz", "ready", "readyz", "live", "livez"}, - InstrumentationEvidencePatterns: []string{"span", "trace", "metric", "counter", "histogram", "observe", "instrument", "prometheus"}, - } -} - -func exampleOperationsRules() core.OperationsRulesConfig { - return core.OperationsRulesConfig{ - DetectMissingOwner: boolPtr(true), - DetectMissingRunbook: boolPtr(true), - OwnerFilePatterns: []string{"CODEOWNERS", ".github/CODEOWNERS", "OWNERS", "owners.yaml", "catalog-info.yaml", "service.yaml", "service.yml"}, - RunbookPathPatterns: []string{"runbook", "runbooks", "docs/runbooks", "ops", "operations"}, - CriticalPathPatterns: []string{"cmd/", "internal/", "service", "api", "worker", "consumer", "job", "payment", "auth", "deploy", "migrations"}, - } -} - -func exampleReliabilityRules() core.ReliabilityRulesConfig { - return core.ReliabilityRulesConfig{ - DetectMissingTimeout: boolPtr(true), - DetectUnboundedRetry: boolPtr(true), - DetectRetryWithoutBackoff: boolPtr(true), - DetectNonIdempotentRetry: boolPtr(true), - DetectMissingCancellation: boolPtr(true), - DetectUnboundedWork: boolPtr(true), - DetectMissingConcurrencyLimit: boolPtr(true), - DetectResourceLeak: boolPtr(true), - DetectPartialFailureHidden: boolPtr(true), - DetectMissingGracefulShutdown: boolPtr(true), - DetectSwallowedError: boolPtr(true), - DetectLostErrorContext: boolPtr(true), - DetectRecoverablePanic: boolPtr(true), - MaxRetryAttempts: 3, - MaxInlineGoroutinesPerFunction: 4, - } -} - -func exampleDataRules() core.DataRulesConfig { - return core.DataRulesConfig{ - DetectReadModifyWriteRace: boolPtr(true), - DetectMissingTransaction: boolPtr(true), - DetectSideEffectInTransaction: boolPtr(true), - DetectNonIdempotentConsumer: boolPtr(true), - DetectMissingDeduplication: boolPtr(true), - DetectUnsafeDualWrite: boolPtr(true), - DetectMissingOutboxStrategy: boolPtr(true), - DetectUnstablePagination: boolPtr(true), - DetectUnboundedRead: boolPtr(true), - DetectExactlyOnceAssumption: boolPtr(true), - DetectCacheWithoutPolicy: boolPtr(true), - MaxUnboundedReadRows: 1000, - MaxWritesWithoutTransaction: 1, - } -} - -func exampleChangeRules() core.ChangeRulesConfig { - return core.ChangeRulesConfig{ - DetectBehaviorChangeWithoutTest: boolPtr(true), - DetectFailurePathMissing: boolPtr(true), - DetectHardwiredDependency: boolPtr(true), - DetectNondeterministicDomain: boolPtr(true), - DetectLegacyHotspotUncovered: boolPtr(true), - DetectMixedConcerns: boolPtr(true), - DetectOversizedDiff: boolPtr(true), - DetectMixedRefactorAndBehavior: boolPtr(true), - DetectTooManyConcerns: boolPtr(true), - DetectUnnecessarySurfaceArea: boolPtr(true), - DetectOneUseAbstraction: boolPtr(true), - DetectDuplicateHelper: boolPtr(true), - DetectCleanupRegression: boolPtr(true), - DetectComplexityIncreased: boolPtr(true), - DetectMoveWithoutVerification: boolPtr(true), - DetectRefactorBehaviorChange: boolPtr(true), - DetectRefactorPublicContract: boolPtr(true), - DetectRefactorTestCoverageDrop: boolPtr(true), - DetectRefactorErrorPathChange: boolPtr(true), - DetectRefactorSideEffectReorder: boolPtr(true), - DetectRefactorVisibilityExpand: boolPtr(true), - DetectRefactorDependencyWorsened: boolPtr(true), - DetectRefactorDuplicateLeftBehind: boolPtr(true), - DetectRefactorDeadPathLeftBehind: boolPtr(true), - MaxChangedFiles: 25, - MaxChangedDirectories: 8, - MaxChangedLines: 800, - MaxPublicInterfacesChanged: 3, - MaxConcernFamilies: 3, - MinTestToProductionRatioPercent: 20, - } -} - -func exampleProductionRisk() core.ProductionRiskConfig { - return core.ProductionRiskConfig{ - Enabled: boolPtr(true), - WarnThreshold: 35, - FailThreshold: 70, - ReliabilityWeight: 12, - DataWeight: 15, - FailWeight: 25, - WarnWeight: 10, - } -} - -func exampleContractRules() core.ContractRulesConfig { - return core.ContractRulesConfig{ - GoExportedBreaking: boolPtr(true), - CPPPublicBreaking: boolPtr(true), - OpenAPIBreaking: boolPtr(true), - ProtoBreaking: boolPtr(true), - MigrationDestructive: boolPtr(true), - MigrationPaths: []string{"migrations/", "db/migrate/", "alembic/"}, - } +// applyStarterCIPolicy writes codeguard's own pipeline conventions. These are +// starter suggestions, not defaults: a repository with a differently named +// workflow, a different release tool, no Makefile, or co-located tests should +// edit or delete them. See baseDefaultCIRules for why none of this is a +// default. +func applyStarterCIPolicy(rules *core.CIRulesConfig) { + rules.RequiredWorkflowFiles = []string{".github/workflows/ci.yml"} + rules.RequiredGates = []string{"test"} + rules.WorkflowContentRules = []core.WorkflowRuleConfig{{ + Path: ".github/workflows/ci.yml", + RequiredContains: []string{"actions/checkout", "go test ./..."}, + }} + rules.RequiredReleaseFiles = []string{".goreleaser.yaml"} + rules.RequiredAutomationPaths = []string{"Makefile"} + rules.AllowedTestPaths = []string{"tests/**"} } -func exampleCacheConfig() core.CacheConfig { - return core.CacheConfig{ - Enabled: boolPtr(true), - Path: ".codeguard/cache.json", - } +// applyStarterDesignLayering writes the cmd/ -> internal/cli and pkg/ facade +// layering rules. They describe this repository's structure; see +// baseDefaultDesignRules for why they are off by default. +func applyStarterDesignLayering(rules *core.DesignRulesConfig) { + rules.RequireCmdThroughInternalCLI = boolPtr(true) + rules.ForbidInternalImportCmd = boolPtr(true) + rules.ForbidServiceImportInternal = boolPtr(true) + rules.ForbidServiceImportCmd = boolPtr(true) } diff --git a/internal/codeguard/config/example_rules.go b/internal/codeguard/config/example_rules.go deleted file mode 100644 index 9482ef99..00000000 --- a/internal/codeguard/config/example_rules.go +++ /dev/null @@ -1,94 +0,0 @@ -package config - -import "github.com/devr-tools/codeguard/internal/codeguard/core" - -func exampleQualityRules() core.QualityRulesConfig { - return core.QualityRulesConfig{ - MaxFileLines: 400, - MaxFunctionLines: 80, - MaxParameters: 5, - MaxCyclomaticComplexity: 10, - CloneTokenThreshold: 90, - AIProvenance: core.AIProvenanceConfig{ - Enabled: boolPtr(true), - EnvVars: []string{"CODEGUARD_AI_ASSISTED"}, - CommitTrailers: []string{"AI-Assisted", "AI-Generated"}, - SlopScoreWarnThreshold: 20, - SlopScoreFailThreshold: 40, - }, - AIChangeRisk: core.AIChangeRiskConfig{ - Enabled: boolPtr(true), - WarnThreshold: 30, - FailThreshold: 60, - }, - CPPTooling: core.CPPToolingConfig{ - ClangFormatMode: core.ExternalToolModeOff, - ClangFormatCommand: "clang-format", - CompilerMode: core.ExternalToolModeOff, - CompilerCommand: "clang++", - }, - Naming: core.QualityNamingConfig{ - AllowedAbbreviations: []string{"api", "cli", "cpp", "db", "html", "http", "https", "id", "io", "ip", "js", "json", "orm", "rpc", "sdk", "sql", "tcp", "ts", "ui", "url", "uuid", "xml", "yaml"}, - RoleSuffixWarnThreshold: 4, - }, - } -} - -func examplePerformanceRules() core.PerformanceRulesConfig { - return core.PerformanceRulesConfig{ - DetectPreallocInLoop: boolPtr(false), - } -} - -func exampleDesignRules() core.DesignRulesConfig { - return core.DesignRulesConfig{ - RequireCmdThroughInternalCLI: boolPtr(true), - ForbidInternalImportCmd: boolPtr(true), - ForbidServiceImportInternal: boolPtr(true), - ForbidServiceImportCmd: boolPtr(true), - MaxDeclsPerFile: 12, - GodModuleThreshold: 25, - HighImpactChangeThreshold: 10, - MaxMethodsPerType: 8, - MaxInterfaceMethods: 5, - ForbiddenPackageNames: []string{"util", "utils", "common", "helpers", "misc"}, - } -} - -func examplePromptRules() core.PromptRulesConfig { - return core.PromptRulesConfig{ - FileExtensions: []string{".prompt", ".md", ".txt", ".tmpl", ".yaml", ".yml", ".json"}, - PathContains: []string{"prompt", "system", "instruction", "template"}, - ForbidSecretInterpolation: boolPtr(true), - ForbidUnsafeInstructions: boolPtr(true), - } -} - -func exampleCIRules() core.CIRulesConfig { - return core.CIRulesConfig{ - RequireWorkflowDir: boolPtr(true), - RequiredWorkflowFiles: []string{ - ".github/workflows/ci.yml", - }, - RequiredGates: []string{"test"}, - WorkflowContentRules: []core.WorkflowRuleConfig{{ - Path: ".github/workflows/ci.yml", - RequiredContains: []string{"actions/checkout", "go test ./..."}, - }}, - RequiredReleaseFiles: []string{".goreleaser.yaml"}, - RequiredAutomationPaths: []string{"Makefile"}, - AllowedTestPaths: []string{"tests/**"}, - } -} - -func exampleSecurityRules() core.SecurityRulesConfig { - return core.SecurityRulesConfig{ - GovulncheckMode: "auto", - GovulncheckCommand: "govulncheck", - TypeScriptTaintMaxDepth: 8, - Secrets: &core.SecretsRulesConfig{ - Enabled: boolPtr(true), - AllowPaths: []string{"testdata/**", "**/testdata/**"}, - }, - } -} diff --git a/internal/codeguard/config/profile.go b/internal/codeguard/config/profile.go index f4e56012..2f28e96a 100644 --- a/internal/codeguard/config/profile.go +++ b/internal/codeguard/config/profile.go @@ -31,6 +31,8 @@ var profileCatalog = map[string]profileSpec{ cfg.Checks.DesignRules.MaxDeclsPerFile = 16 cfg.Checks.DesignRules.MaxMethodsPerType = 10 cfg.Checks.DesignRules.MaxInterfaceMethods = 8 + // Clears the starter config's release-file policy when a generated + // config uses this profile; the conservative baseline sets none. cfg.Checks.CIRules.RequiredReleaseFiles = nil cfg.Checks.SecurityRules.GovulncheckMode = "auto" }, @@ -91,6 +93,12 @@ func applyStrictProfile(cfg *core.Config) { cfg.Checks.DesignRules.MaxMethodsPerType = 6 cfg.Checks.DesignRules.MaxInterfaceMethods = 4 cfg.Checks.SecurityRules.GovulncheckMode = "required" + // "Hard gates" has to be stated, not inherited: the conservative baseline + // configures no required CI gate. "test" is resolved semantically across + // ecosystems (go/npm/pnpm/yarn/pytest/cargo/dotnet/mvn/gradle or a job + // named test) and is skipped entirely when the repository has no workflow + // files, so it is safe to assert for a profile that asks for hard gates. + cfg.Checks.CIRules.RequiredGates = []string{"test"} cfg.Checks.Contracts = boolPtr(true) cfg.Checks.Reliability = boolPtr(true) cfg.Checks.Change = boolPtr(true) @@ -157,11 +165,14 @@ func profileComparisonColumns() []profileComparisonColumn { } } +// profileComparisonConfigs renders from the DEFAULTS path, not the starter +// config: the table documents the policy a repository gets by selecting a +// profile and omitting keys. Using ExampleConfig here would report the +// starter config's opinions as though they were defaults. func profileComparisonConfigs(profiles []profileComparisonColumn) []core.Config { configs := make([]core.Config, len(profiles)) - configs[0] = ExampleConfig() - for i := 1; i < len(profiles); i++ { - configs[i], _ = ExampleConfigForProfile(profiles[i].name) + for i := range profiles { + configs[i] = defaultConfigForProfile(profiles[i].name) } return configs } @@ -214,6 +225,9 @@ func writeProfileComparisonRows(b *strings.Builder, configs []core.Config) { writeProfileComparisonRow(b, "`ci_rules.required_automation_paths`", configs, func(cfg core.Config) string { return profileStringSlice(cfg.Checks.CIRules.RequiredAutomationPaths) }) + writeProfileComparisonRow(b, "`ci_rules.required_gates`", configs, func(cfg core.Config) string { + return profileStringSlice(cfg.Checks.CIRules.RequiredGates) + }) writeProfileComparisonRow(b, "`contracts`", configs, func(cfg core.Config) string { if cfg.Checks.Contracts == nil { return "scan-mode" diff --git a/tests/cli/scan_zero_config_test.go b/tests/cli/scan_zero_config_test.go new file mode 100644 index 00000000..2f77c786 --- /dev/null +++ b/tests/cli/scan_zero_config_test.go @@ -0,0 +1,198 @@ +package cli_test + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/internal/cli" +) + +// houseStylePolicyRules are the rules that only fire because of a repository +// convention codeguard cannot know applies. A scan with no config file must +// never report them: the zero-config path resolves the conservative defaults, +// not the opinionated config `codeguard init` writes. +var houseStylePolicyRules = []string{ + "ci.test-file-location", // ci_rules.allowed_test_paths + "ci.workflow-content", // ci_rules.workflow_content_rules + "ci.required-file", // ci_rules.required_{workflow,release}_files, required_automation_paths + "ci.missing-required-gate", // ci_rules.required_gates + "design.service-import-internal", + "design.service-import-cmd", + "design.cmd-through-internal-cli", +} + +// writeZeroConfigFixture lays down a small Go repository that trips every +// house-style rule if the starter config's policy is in effect: tests sit +// beside the source rather than under tests/, pkg/ imports internal/, the +// workflow does not contain `go test ./...`, and there is no Makefile and no +// .goreleaser.yaml. +func writeZeroConfigFixture(t *testing.T, root string) { + t.Helper() + writeScanTestFile(t, filepath.Join(root, "go.mod"), "module example.com/zeroconfig\n\ngo 1.23.0\n") + writeScanTestFile(t, filepath.Join(root, "internal", "calc", "calc.go"), + "package calc\n\n// Add returns the sum of a and b.\nfunc Add(a int, b int) int { return a + b }\n") + // Co-located test file: idiomatic Go, and a ci.test-file-location fail + // under `allowed_test_paths: ["tests/**"]`. + writeScanTestFile(t, filepath.Join(root, "internal", "calc", "calc_test.go"), + "package calc\n\nimport \"testing\"\n\nfunc TestAdd(t *testing.T) {\n\tif Add(1, 2) != 3 {\n\t\tt.Fatal(\"bad sum\")\n\t}\n}\n") + // pkg/ reaching into internal/ is ordinary Go, and a + // design.service-import-internal fail under the layering rules. + writeScanTestFile(t, filepath.Join(root, "pkg", "api", "api.go"), + "package api\n\nimport \"example.com/zeroconfig/internal/calc\"\n\n// Sum adds two numbers.\nfunc Sum(a int, b int) int { return calc.Add(a, b) }\n") + // A workflow that tests via make, not `go test ./...`: a + // ci.workflow-content fail under the starter's content markers. The action + // ref is SHA-pinned so ci.mutable-deployment-reference stays out of it. + writeScanTestFile(t, filepath.Join(root, ".github", "workflows", "ci.yml"), + "name: ci\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@8f3c2b1a4d5e6f7890abc1234567890abc123456\n - run: make check\n") + writeScanTestFile(t, filepath.Join(root, "README.md"), "# Zero config\n\nRun `make check`.\n") + writeScanTestFile(t, filepath.Join(root, "AGENTS.md"), "# Agent Notes\n\n## Build & test\n- `make check` runs the suite.\n") +} + +type zeroConfigReport struct { + Sections []struct { + ID string `json:"id"` + Name string `json:"name"` + Findings []struct { + RuleID string `json:"rule_id"` + Level string `json:"level"` + } `json:"findings"` + } `json:"sections"` +} + +func runZeroConfigScan(t *testing.T, args ...string) zeroConfigReport { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + // Run from a directory with no codeguard config, so resolveConfigPath + // cannot discover one in "." or ".codeguard" and the fallback is the path + // actually under test. + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir tempdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + writeZeroConfigFixture(t, filepath.Join(dir, "repo")) + + var stdout, stderr bytes.Buffer + full := append([]string{"scan", "-folder", "repo", "-format", "json"}, args...) + cli.Run(full, strings.NewReader(""), &stdout, &stderr) + + var report zeroConfigReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode report: %v\nstdout = %s\nstderr = %s", err, stdout.String(), stderr.String()) + } + if len(report.Sections) == 0 { + t.Fatalf("zero-config scan reported no sections; stderr = %s", stderr.String()) + } + return report +} + +// A scan with no config file must not enforce codeguard's own repository +// conventions. This is the half that catches the original defect: the fallback +// used to return the `codeguard init` starter config. +func TestZeroConfigScanDoesNotEnforceHouseStylePolicy(t *testing.T) { + report := runZeroConfigScan(t) + + forbidden := map[string]bool{} + for _, rule := range houseStylePolicyRules { + forbidden[rule] = true + } + for _, section := range report.Sections { + for _, finding := range section.Findings { + if forbidden[finding.RuleID] { + t.Errorf("zero-config scan reported %s (%s) in section %s: the no-config path must resolve the conservative defaults, not the init starter config", finding.RuleID, finding.Level, section.Name) + } + } + } +} + +// The other half: the fix must not be "make the zero-config path find +// nothing". The recommended sections have to actually run and actually report. +func TestZeroConfigScanStillEnablesRecommendedSections(t *testing.T) { + report := runZeroConfigScan(t) + + got := map[string]bool{} + total := 0 + for _, section := range report.Sections { + got[section.ID] = true + total += len(section.Findings) + } + for _, want := range []string{"quality", "design", "security", "prompts", "ci"} { + if !got[want] { + t.Errorf("zero-config scan did not run the %q section; sections = %v", want, sectionIDs(report)) + } + } + // Agent Context is enabled by scan mode (full scans) rather than by the + // recommended set, so a full zero-config scan must include it too. + if !got["context"] { + t.Errorf("zero-config full scan did not run the agent-context section; sections = %v", sectionIDs(report)) + } + if total == 0 { + t.Error("zero-config scan produced no findings at all; the defaults must still detect something, not silently pass") + } +} + +// A profile overlays the zero-config defaults, including the plain-bool +// section activations that applyCheckDefaults used to drop. +func TestZeroConfigScanProfileEnablesItsSections(t *testing.T) { + report := runZeroConfigScan(t, "-profile", "enterprise") + + got := map[string]bool{} + for _, section := range report.Sections { + got[section.ID] = true + } + // supply_chain is a plain bool that only the enterprise profile spec sets. + if !got["supply_chain"] { + t.Errorf("-profile enterprise did not enable the supply-chain section; sections = %v", sectionIDs(report)) + } + // delivery is a *bool the profile sets, which already worked; assert it so + // a regression in either mechanism is caught. + if !got["delivery"] { + t.Errorf("-profile enterprise did not enable the delivery section; sections = %v", sectionIDs(report)) + } + // The profile must not smuggle the house-style policy back in. + for _, section := range report.Sections { + for _, finding := range section.Findings { + if finding.RuleID == "ci.test-file-location" { + t.Errorf("-profile enterprise reported ci.test-file-location; no profile configures a test layout") + } + } + } +} + +func TestZeroConfigScanRejectsUnknownProfile(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir tempdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + writeZeroConfigFixture(t, filepath.Join(dir, "repo")) + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "-folder", "repo", "-profile", "nope"}, strings.NewReader(""), &stdout, &stderr) + if code != 1 { + t.Fatalf("expected exit 1 for an unknown profile, got %d; stdout = %s", code, stdout.String()) + } + if !strings.Contains(stderr.String(), `unknown profile "nope"`) { + t.Fatalf("expected an unknown-profile error, got %s", stderr.String()) + } +} + +func sectionIDs(report zeroConfigReport) []string { + ids := make([]string, 0, len(report.Sections)) + for _, section := range report.Sections { + ids = append(ids, section.ID) + } + return ids +} diff --git a/tests/codeguard/defaults_house_style_test.go b/tests/codeguard/defaults_house_style_test.go new file mode 100644 index 00000000..656f47ec --- /dev/null +++ b/tests/codeguard/defaults_house_style_test.go @@ -0,0 +1,88 @@ +package codeguard_test + +import ( + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// The starter config that `codeguard init` writes used to double as the source +// of built-in defaults, so every convention written for the starter — a +// workflow filename, a `go test ./...` marker, a `tests/**` test layout — was +// silently the effective policy for anyone who omitted that key. The two roles +// are now separate: ApplyDefaults resolves against a conservative baseline that +// asserts no repository shape at all. +// +// Each list below is one that its rule treats as "not configured" when empty, +// so an empty default means "mechanism intact, nothing enforced". Do not make +// any of them non-empty to express codeguard's own conventions; put those in +// the starter config (config.applyStarterPolicy) or in a named profile. +func TestDefaultsAssertNoRepositoryShapePolicy(t *testing.T) { + cfg := codeguard.Config{} + codeguard.ApplyDefaults(&cfg) + + ci := cfg.Checks.CIRules + for _, tc := range []struct { + key string + values []string + }{ + {"ci_rules.allowed_test_paths", ci.AllowedTestPaths}, + {"ci_rules.required_workflow_files", ci.RequiredWorkflowFiles}, + {"ci_rules.required_release_files", ci.RequiredReleaseFiles}, + {"ci_rules.required_automation_paths", ci.RequiredAutomationPaths}, + {"ci_rules.required_gates", ci.RequiredGates}, + } { + if len(tc.values) != 0 { + t.Errorf("default %s = %v, want empty: a default here enforces codeguard's own repository layout on every user", tc.key, tc.values) + } + } + if len(ci.WorkflowContentRules) != 0 { + t.Errorf("default ci_rules.workflow_content_rules = %v, want empty", ci.WorkflowContentRules) + } + + design := cfg.Checks.DesignRules + for _, tc := range []struct { + key string + value *bool + }{ + {"design_rules.require_cmd_through_internal_cli", design.RequireCmdThroughInternalCLI}, + {"design_rules.forbid_internal_import_cmd", design.ForbidInternalImportCmd}, + {"design_rules.forbid_service_import_internal", design.ForbidServiceImportInternal}, + {"design_rules.forbid_service_import_cmd", design.ForbidServiceImportCmd}, + } { + // The toggles must still be resolved (the checks dereference them), + // just resolved to off. + if tc.value == nil { + t.Errorf("default %s is nil; the design checks dereference it", tc.key) + continue + } + if *tc.value { + t.Errorf("default %s = true, want false: it encodes one specific repository layout at fail level", tc.key) + } + } +} + +// The starter config keeps stating codeguard's conventions: the split must not +// be achieved by thinning out what `codeguard init` writes. +func TestStarterConfigStillStatesHouseConventions(t *testing.T) { + cfg := codeguard.ExampleConfig() + + if got := cfg.Checks.CIRules.AllowedTestPaths; len(got) == 0 { + t.Error("starter config should still state a test layout") + } + if got := cfg.Checks.CIRules.RequiredWorkflowFiles; len(got) == 0 { + t.Error("starter config should still state required workflow files") + } + if got := cfg.Checks.CIRules.WorkflowContentRules; len(got) == 0 { + t.Error("starter config should still state workflow content rules") + } + if cfg.Checks.DesignRules.RequireCmdThroughInternalCLI == nil || !*cfg.Checks.DesignRules.RequireCmdThroughInternalCLI { + t.Error("starter config should still state the cmd/ layering rule") + } + if !cfg.Checks.Quality || !cfg.Checks.Design || !cfg.Checks.Security || !cfg.Checks.Prompts || !cfg.Checks.CI { + t.Errorf("starter config should enable the recommended sections, got %+v", cfg.Checks) + } + if len(cfg.Targets) == 0 { + t.Error("starter config should ship a target") + } +} From e4023f8ed4d4af0f62c599fb11e36973c1ecaa48 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 17 Sep 2026 15:52:07 -0400 Subject: [PATCH 08/16] docs(knowledge): note the zero-config test trap and scratchpad collisions Two process findings from the false-positive audit: a scan -folder measurement only exercises the no-config fallback when run from a directory with no discoverable config (cwd, not the scanned path, drives discovery), and parallel worktree agents sharing one scratchpad filename clobber each other's prepared commit messages. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/knowledge/testing-patterns.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.claude/knowledge/testing-patterns.md b/.claude/knowledge/testing-patterns.md index 146abdfd..d11b38f1 100644 --- a/.claude/knowledge/testing-patterns.md +++ b/.claude/knowledge/testing-patterns.md @@ -32,3 +32,5 @@ Testing strategies, test infrastructure quirks, how to run/debug specific test s - **For rules that can fire en masse, assert the EXACT finding/message set, not presence.** `assertFindingRulePresent` cannot distinguish a rule firing once correctly from firing 33 times wrongly, which is exactly how the `lockfile-drift` Yarn Berry bug survived existing coverage. Table-driven cases over `supplyChainTestConfig` + `supplyChainRuleMessages` comparing the full message slice are the cheap shape for this. The suite must also pin both directions of any "stay silent when unsure" behaviour: a garbage lockfile produces zero findings, AND a genuinely empty-but-valid one still reports drift — otherwise a later "fix" can silence the rule entirely and still look green. - **`golangci-lint` cannot run concurrently.** It takes a lock and aborts with `Error: parallel golangci-lint is running`, so parallel agents/worktrees linting at once all fail spuriously. Serialise lint runs, or pass a distinct `GOLANGCI_LINT_CACHE` and retry on that error — it is never a code problem. - **Corpus `vulnerable/` fixtures must not use vendor-documented placeholder credentials.** Three fixtures used AWS's published `AKIAIOSFODNN7EXAMPLE`, so the harness was scoring a world-readable non-secret as a true positive — wrong ground truth, not a detector gap. Use the split-literal form instead (`"AKIA" + "<16 chars>"`): `foldConcatenatedLiterals` joins adjacent literals before matching, so detection is exercised while no contiguous provider token is committed, which is also what gets a realistic fixture past GitHub push protection. The documented placeholders belong in `clean/`. When a corpus TP count *rises* after a precision fix, check whether new `must_fire` cases were added versus ground truth being loosened — both look the same in the scoreboard. +- **Zero-config behaviour can only be tested from a directory with no discoverable config.** `codeguard scan -folder ` resolves its config from the *current working directory* (`resolveConfigPath` finds `./codeguard.yaml` or `./.codeguard/`), not from the scanned folder, so running it from anywhere inside this repo silently uses `.codeguard/codeguard.yaml` and never exercises the fallback path at all. A measurement or test of the no-config path must `chdir` into a fresh empty dir first — otherwise it passes vacuously. This invalidated a measurement mid-review (a reported 144 fail-level findings was really the self-scan config; the true fallback figure was 149). +- **Parallel agents must not share one scratchpad filename.** Two worktree agents both wrote their prepared commit message to `/commitmsg.txt`; the second clobbered the first, and the surviving file carried the wrong branch's message. Scratchpad paths are shared across agents in a session — namespace per-agent files by branch or agent id. From 79392113ce2ea95fbe1654ed79cd81c8a93f3cb8 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Mon, 19 Oct 2026 15:31:26 -0400 Subject: [PATCH 09/16] perf(quality): stop recompiling regexes and re-splitting identifiers per call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiling a full scan of seven frozen repository snapshots (pprof, `make bench-cpu`) put ~45% of CPU inside the regexp engine and showed `regexp.MustCompile` running *during* the scan, accounting for 32% of every byte a scan allocated. None of the changes here alter a single finding. What the profile said, and what changed: * 23 detectors in `checks/quality` compiled a regex on every call. Patterns with constant text became package-level vars; the ones that interpolate scan input (an identifier name) go through a new bounded `cachedPattern` cache (`quality_pattern_cache.go`). `duplicatedKnowledgeLineIsEnumOrTypeLiteral` alone — one compile per source line — was half of that cost. * `naming.cross-layer-inconsistency` was ~15% of scan CPU and ~20% of its allocations on its own: it asked `containsAnyIdentifierWord` / `identifierContainsWord` up to 42 questions per identifier and each one re-split the identifier from scratch. Identifiers are now split once per file and shared with the other two naming detectors via `sourceIdentifiers`. * `identifiersInSource` and `splitIdentifierWords` dropped their regexes for byte scans. Both character classes are pure ASCII, so a byte scan yields the same matches RE2 does; `splitCamelWords` gained an ASCII path that slices the input instead of round-tripping through `[]rune`. * `scriptSourceLineAtModuleScope` split the whole file into a `[]string` on every call while only ever reading lines up to its target (~10% of scan allocations). It now walks lines lazily, with the same `\r\n` handling. * Literal pre-gates where an alternation's `\b`/`(?i)` prefix defeats RE2's literal-prefix scan — `mixedAbstractionLevel`, `assignmentLooksLocalAccumulator` — mirroring the secret scanner's `builtinGatePasses`. `roleSuffixOveruseFinding`'s `(?i)(...)$` became a suffix test and `environmentBranchLineIsDeploymentDecision`'s `\b(if|switch|case|when)\b` a word-boundary scan. * `unknownAbbreviation` rebuilt a 45-entry map from config for every identifier; the cheap suspicious-shortening test now runs first. Measured interleaved against a binary built from the merge base (the machine is shared, so A and B alternate every repetition), median of 7 cold scans: szr 1.32x slop 1.40x cleanr 1.37x merger 1.53x sentinel 1.11x guardhouse 1.25x opex 1.02x The warm-cache path is unchanged (0.97-1.09x, inside the noise): on a cache hit the evaluators never run, so none of this work happens. Allocation per cold scan drops ~2.4x: szr 2.48 GB / 35.6M allocs -> 1.34 GB / 8.0M, guardhouse 4.31 GB / 57.2M -> 1.22 GB / 5.6M, sentinel 976 MB / 13.3M -> 261 MB / 1.0M. Adds `make bench-cpu`, which profiles a full scan of real repository snapshots (`tests/codeguard/real_repo_scan_benchmark_test.go`). The existing synthetic full-scan benchmark disables dead code, local precision and the AI quality passes, which hides exactly the cost this change removes. Verified byte-identical findings (rule_id, path, line, message) across seven snapshots before and after. The one exception is `quality.duplicate-code` on one repository, which already differs between two runs of the *same* binary — reported separately, not touched here. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + Makefile | 36 +++- .../quality/quality_ai_dead_code_script.go | 4 +- .../checks/quality/quality_defensive.go | 33 ++- .../checks/quality/quality_effect_evidence.go | 20 +- .../checks/quality/quality_environment.go | 7 +- .../checks/quality/quality_errors.go | 5 +- .../checks/quality/quality_identifier_scan.go | 200 ++++++++++++++++++ .../checks/quality/quality_pattern_cache.go | 44 ++++ .../checks/quality/quality_precision.go | 14 +- .../quality/quality_precision_duplication.go | 7 +- .../quality_precision_mutation_targets.go | 49 +++-- .../quality_precision_ui_conventions.go | 37 +++- .../quality_precision_workstreams_cd.go | 159 +++++--------- .../checks/quality/quality_smells.go | 12 +- .../checks/quality/quality_text_scan.go | 101 +++++++++ .../real_repo_scan_benchmark_test.go | 148 +++++++++++++ 17 files changed, 721 insertions(+), 156 deletions(-) create mode 100644 internal/codeguard/checks/quality/quality_identifier_scan.go create mode 100644 internal/codeguard/checks/quality/quality_pattern_cache.go create mode 100644 internal/codeguard/checks/quality/quality_text_scan.go create mode 100644 tests/codeguard/real_repo_scan_benchmark_test.go diff --git a/.gitignore b/.gitignore index d7eeaa80..0fa73699 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ go.work.sum **/.codeguard/cache.perf-history.json **/.codeguard/cache.rule-stats-history.json **/.codeguard/cache.legibility-history.json +.bench/ diff --git a/Makefile b/Makefile index 657d0a98..719891ae 100644 --- a/Makefile +++ b/Makefile @@ -30,13 +30,20 @@ MENU_LDFLAGS := -X github.com/devr-tools/codeguard/internal/version.Number=v$(ME # ~206 (~+22 MB). Builds without these tags (plain `go build`, `go install`) # still work; they just embed every grammar. GRAMMAR_TAGS := grammar_subset,grammar_subset_typescript,grammar_subset_tsx,grammar_subset_javascript,grammar_subset_python,grammar_subset_cpp +# make bench-cpu knobs (see the bench-cpu target). +BENCH ?= BenchmarkRealRepositoryFullScan +BENCH_REPOS ?= +BENCH_CONFIGS ?= +BENCH_COUNT ?= 5 +BENCH_TIME ?= 1x +BENCH_PROFILE_DIR ?= $(CURDIR)/.bench export GOCACHE export GOMODCACHE .DEFAULT_GOAL := help -.PHONY: help fmt fmt-check lint lint-strict test codeguard-ci check ci build release release-snapshot release-check deploy commit menu table table-diff table-interactive clean +.PHONY: help fmt fmt-check lint lint-strict test bench-cpu codeguard-ci check ci build release release-snapshot release-check deploy commit menu table table-diff table-interactive clean help: @printf "\ncodeguard make targets\n\n" @@ -45,6 +52,7 @@ help: @printf " make lint Run go vet\n" @printf " make lint-strict Run golangci-lint (enforced in CI)\n" @printf " make test Run the Go test suite\n" + @printf " make bench-cpu Profile a full scan of real repo snapshots (BENCH_CONFIGS=/abs/codeguard.yaml,...)\n" @printf " make codeguard-ci Validate and scan this repository with codeguard\n" @printf " make check Run fmt-check, lint, test, and codeguard-ci\n" @printf " make ci Run the full local CI gate, including golangci-lint\n" @@ -81,6 +89,32 @@ lint-strict: test: @set -o pipefail; $(GO) test ./... 2>&1 | grep -v '\[no test files\]' +# bench-cpu profiles a full scan of real repository snapshots. Point BENCH_REPOS +# at frozen snapshots (`git archive HEAD | tar -x -C `), never a live +# working tree: a tree that mutates under the walk gives different findings run +# to run. Paths must be absolute — a relative target path resolves against the +# config directory, not the shell's cwd. +# +# BENCH_CONFIGS scans with a codeguard.yaml verbatim, so the profile covers the +# same rule set as the `codeguard scan` you time with it; BENCH_REPOS uses the +# default `codeguard init` check set. Prefer BENCH_CONFIGS — the check set moves +# full-scan cost by more than 2x. +# +# make bench-cpu BENCH_CONFIGS=/tmp/cfg/szr/codeguard.yaml +# make bench-cpu BENCH_REPOS=/tmp/snap/szr,/tmp/snap/sentinel +# make bench-cpu BENCH_REPOS=/tmp/snap/szr BENCH='.*/cold' +# go tool pprof -top -cum -nodecount=10 .bench/cpu.out +bench-cpu: + @test -n "$(BENCH_REPOS)$(BENCH_CONFIGS)" || { echo "set BENCH_CONFIGS=/abs/codeguard.yaml[,...] or BENCH_REPOS=/abs/snapshot[,...]"; exit 1; } + @mkdir -p $(BENCH_PROFILE_DIR) + @set -o pipefail; CODEGUARD_BENCH_REPOS='$(BENCH_REPOS)' CODEGUARD_BENCH_CONFIGS='$(BENCH_CONFIGS)' $(GO) test ./tests/codeguard \ + -tags $(GRAMMAR_TAGS) -run '^$$' -bench '$(BENCH)' -benchmem \ + -count=$(BENCH_COUNT) -benchtime=$(BENCH_TIME) \ + -cpuprofile $(BENCH_PROFILE_DIR)/cpu.out \ + -memprofile $(BENCH_PROFILE_DIR)/mem.out \ + -o $(BENCH_PROFILE_DIR)/bench.test 2>&1 | tee $(BENCH_PROFILE_DIR)/bench.txt + @echo "profiles: $(BENCH_PROFILE_DIR)/cpu.out $(BENCH_PROFILE_DIR)/mem.out (binary $(BENCH_PROFILE_DIR)/bench.test)" + codeguard-ci: build $(CODEGUARD_BIN) validate -config $(CI_CONFIG) $(CODEGUARD_BIN) scan -config $(CI_CONFIG) diff --git a/internal/codeguard/checks/quality/quality_ai_dead_code_script.go b/internal/codeguard/checks/quality/quality_ai_dead_code_script.go index 60801873..a3dc6550 100644 --- a/internal/codeguard/checks/quality/quality_ai_dead_code_script.go +++ b/internal/codeguard/checks/quality/quality_ai_dead_code_script.go @@ -86,7 +86,7 @@ func scriptUnusedFunctionFindings(env support.Context, file string, source strin func scriptLocalDeclarationIsReferenced(source string, name string) bool { // JSX identifiers are references, not opaque markup. Check them directly // before falling back to ordinary expression references. - jsxReference := regexp.MustCompile(`<\s*` + regexp.QuoteMeta(name) + `(?:\s|/|>)`) + jsxReference := cachedPattern(`<\s*` + regexp.QuoteMeta(name) + `(?:\s|/|>)`) return jsxReference.MatchString(source) || countWordOccurrences(source, name) > 1 } @@ -103,6 +103,6 @@ func lineEnd(source string, from int) int { } func countWordOccurrences(source string, word string) int { - pattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(word) + `\b`) + pattern := cachedPattern(`\b` + regexp.QuoteMeta(word) + `\b`) return len(pattern.FindAllStringIndex(source, -1)) } diff --git a/internal/codeguard/checks/quality/quality_defensive.go b/internal/codeguard/checks/quality/quality_defensive.go index ebded550..1de6eb85 100644 --- a/internal/codeguard/checks/quality/quality_defensive.go +++ b/internal/codeguard/checks/quality/quality_defensive.go @@ -8,6 +8,19 @@ import ( "github.com/devr-tools/codeguard/internal/codeguard/core" ) +// PERF: constant patterns that used to be compiled inside per-function and +// per-line detectors. See quality_pattern_cache.go. +var ( + parserLookupContractCall = regexp.MustCompile(`(?i)\b(parse|assert|guard|ensure|decode)[A-Z_][A-Za-z0-9_]*(?:Input|Payload|Body|Params|Query|Record|Request|Event|Config)?\s*\(`) + nullableArraySuffixUnion = regexp.MustCompile(`\]\s*\|\s*(null|undefined|none)\b`) + nullableTypeUnionPattern = regexp.MustCompile(`(^|\|)\s*(null|undefined|none)\b|\b(null|undefined|none)\s*\|`) + overflowSizeTermPattern = regexp.MustCompile(`(?i)\b(count|size|length|len|capacity|offset|total|bytes)\b`) + overflowArithmeticPattern = regexp.MustCompile(`[A-Za-z_][\w$]*\s*(\*|\+|<<)\s*[A-Za-z0-9_]`) + numericKeyOnlyPattern = regexp.MustCompile(`^\d+$`) + authorizeFailOpenPattern = regexp.MustCompile(`(?is)(?:if\s+)?(?:\w+\s*:=\s*)?authorize\([^)]*\)\s*;\s*err\s*!=\s*nil\s*\{[^}]*return\s+(true|nil)`) + errFailOpenReturnPattern = regexp.MustCompile(`(?is)err\s*!=\s*nil\s*\{[^}]*return\s+(true|nil)`) +) + const ( defensiveUnvalidatedBoundaryInputRuleID = "defensive.unvalidated-boundary-input" defensiveInvalidStateRepresentableRuleID = "defensive.invalid-state-representable" @@ -244,7 +257,7 @@ func validatedBoundaryInputPattern(fn precisionFunction, loweredBody string) boo if strings.Contains(loweredBody, "nextresponse.") && containsAny(loweredBody, []string{"return nextresponse", ".json(", "redirect("}) && containsAny(loweredBody, []string{"if (!", "if (!", "if(", "if "}) { return true } - if regexp.MustCompile(`(?i)\b(parse|assert|guard|ensure|decode)[A-Z_][A-Za-z0-9_]*(?:Input|Payload|Body|Params|Query|Record|Request|Event|Config)?\s*\(`).MatchString(functionRawBody(fn)) { + if parserLookupContractCall.MatchString(functionRawBody(fn)) { return true } return false @@ -327,10 +340,10 @@ func nullableParam(param support.ParsedParam) bool { return false } if strings.HasSuffix(strings.TrimSpace(typ), "[]") && - !regexp.MustCompile(`\]\s*\|\s*(null|undefined|none)\b`).MatchString(typ) { + !nullableArraySuffixUnion.MatchString(typ) { return false } - return regexp.MustCompile(`(^|\|)\s*(null|undefined|none)\b|\b(null|undefined|none)\s*\|`).MatchString(typ) || + return nullableTypeUnionPattern.MatchString(typ) || strings.Contains(typ, "?") } @@ -351,12 +364,12 @@ func nullableParamGuarded(loweredBody string, name string) bool { return true } quotedName := regexp.QuoteMeta(name) - return regexp.MustCompile(`if\s*\(\s*!\s*`+quotedName+`\s*\)\s*(?:return|throw|continue|break)\b`).MatchString(loweredBody) || + return cachedPattern(`if\s*\(\s*!\s*`+quotedName+`\s*\)\s*(?:return|throw|continue|break)\b`).MatchString(loweredBody) || nullableParamHasBlockExitGuard(loweredBody, quotedName) } func nullableParamHasBlockExitGuard(loweredBody string, quotedName string) bool { - guardStart := regexp.MustCompile(`if\s*\(\s*!\s*` + quotedName + `\s*\)\s*\{`).FindStringIndex(loweredBody) + guardStart := cachedPattern(`if\s*\(\s*!\s*` + quotedName + `\s*\)\s*\{`).FindStringIndex(loweredBody) if guardStart == nil { return false } @@ -397,7 +410,7 @@ func nullableStatementUsesOnlyNullSafeOperators(statement string, name string) b // check. Do not treat an arbitrary typeof occurrence elsewhere in the // function as a null guard. quotedName := regexp.QuoteMeta(name) - return regexp.MustCompile(`typeof\s+` + quotedName + `\s*={2,3}\s*['"](?:string|number|boolean|bigint|symbol|function)['"]\s*\?[^:]*\b` + quotedName + `(?:\.|\[)`).MatchString(statement) + return cachedPattern(`typeof\s+` + quotedName + `\s*={2,3}\s*['"](?:string|number|boolean|bigint|symbol|function)['"]\s*\?[^:]*\b` + quotedName + `(?:\.|\[)`).MatchString(statement) } func firstUseLine(fn precisionFunction, name string) int { @@ -417,13 +430,13 @@ func integerOverflowLine(file string, fn precisionFunction, loweredBody string) if containsAny(loweredBody, []string{"maxint", "math.max", "checked", "saturating", "overflow", "limits<", "safeint"}) { return 0, false } - if !regexp.MustCompile(`(?i)\b(count|size|length|len|capacity|offset|total|bytes)\b`).MatchString(loweredBody) { + if !overflowSizeTermPattern.MatchString(loweredBody) { return 0, false } if !resourceAllocationArithmeticContext(loweredBody) { return 0, false } - arithmetic := regexp.MustCompile(`[A-Za-z_][\w$]*\s*(\*|\+|<<)\s*[A-Za-z0-9_]`) + arithmetic := overflowArithmeticPattern for _, statement := range fn.Statements { if arithmetic.MatchString(firstNonEmptyString(statement.Raw, statement.Text)) { return statement.Line, true @@ -584,7 +597,7 @@ func indexExpressionLooksSequenceAccess(target string, key string, raw string) b if containsAny(loweredRaw, []string{"record<", "map<", "dictionary", "dict", "object.", "hasown", " in ", ".has("}) { return false } - if regexp.MustCompile(`^\d+$`).MatchString(loweredKey) { + if numericKeyOnlyPattern.MatchString(loweredKey) { return true } if sequenceIndexKeyPattern.MatchString(strings.TrimSpace(strings.Trim(key, `"'`))) { @@ -781,7 +794,7 @@ func failOpenAuthorizationLine(fn precisionFunction, loweredBody string) (int, b if !containsAny(strings.ToLower(fn.Name), []string{"auth", "allow", "permission", "policy"}) && !containsAny(loweredBody, []string{"authorize", "permission", "authz", "policy"}) { return 0, false } - if authFailOpenPattern.MatchString(loweredBody) || regexp.MustCompile(`(?is)(?:if\s+)?(?:\w+\s*:=\s*)?authorize\([^)]*\)\s*;\s*err\s*!=\s*nil\s*\{[^}]*return\s+(true|nil)`).MatchString(loweredBody) || regexp.MustCompile(`(?is)err\s*!=\s*nil\s*\{[^}]*return\s+(true|nil)`).MatchString(loweredBody) { + if authFailOpenPattern.MatchString(loweredBody) || authorizeFailOpenPattern.MatchString(loweredBody) || errFailOpenReturnPattern.MatchString(loweredBody) { return fn.StartLine, true } return 0, false diff --git a/internal/codeguard/checks/quality/quality_effect_evidence.go b/internal/codeguard/checks/quality/quality_effect_evidence.go index c5fd9e1f..c489a340 100644 --- a/internal/codeguard/checks/quality/quality_effect_evidence.go +++ b/internal/codeguard/checks/quality/quality_effect_evidence.go @@ -122,7 +122,7 @@ func functionMutationAnalysis(fn precisionFunction, language string) mutationAna if origin != originLocal { continue } - storePattern := regexp.MustCompile(`\b([A-Za-z_$][\w$]*)(?:\.[A-Za-z_$][\w$]*)?\s*=\s*` + regexp.QuoteMeta(name) + `\b`) + storePattern := cachedPattern(`\b([A-Za-z_$][\w$]*)(?:\.[A-Za-z_$][\w$]*)?\s*=\s*` + regexp.QuoteMeta(name) + `\b`) for _, match := range storePattern.FindAllStringSubmatch(fn.Body, -1) { if origins[match[1]] != originLocal { escapedNames[name] = true @@ -272,7 +272,7 @@ func assignmentAliasSource(fn precisionFunction, assignment support.ParsedAssign return match[1] } name := regexp.QuoteMeta(strings.TrimSpace(assignment.Name)) - match := regexp.MustCompile(`\b` + name + `\s*(?::?=)\s*&?([A-Za-z_$][\w$]*)\b`).FindStringSubmatch(assignmentStatement(fn, assignment.Line)) + match := cachedPattern(`\b` + name + `\s*(?::?=)\s*&?([A-Za-z_$][\w$]*)\b`).FindStringSubmatch(assignmentStatement(fn, assignment.Line)) if len(match) == 2 { return match[1] } @@ -285,9 +285,9 @@ func assignmentDeclaresLocal(fn precisionFunction, assignment support.ParsedAssi if name == "" { return false } - return regexp.MustCompile(`\b`+name+`\s*:=`).MatchString(statement) || - regexp.MustCompile(`(?i)\b(?:const|let|var|auto)\s+`+name+`\b`).MatchString(statement) || - regexp.MustCompile(`\b[A-Za-z_$][\w$:<>, ]*\s*&\s*`+name+`\b`).MatchString(statement) + return cachedPattern(`\b`+name+`\s*:=`).MatchString(statement) || + cachedPattern(`(?i)\b(?:const|let|var|auto)\s+`+name+`\b`).MatchString(statement) || + cachedPattern(`\b[A-Za-z_$][\w$:<>, ]*\s*&\s*`+name+`\b`).MatchString(statement) } func assignmentCanReceiveAlias(fn precisionFunction, assignment support.ParsedAssignment) bool { @@ -295,7 +295,7 @@ func assignmentCanReceiveAlias(fn precisionFunction, assignment support.ParsedAs return true } name := regexp.QuoteMeta(strings.TrimSpace(assignment.Name)) - return regexp.MustCompile(`(?m)\b(?:var|let|const|auto)\s+` + name + `\b`).MatchString(fn.Body) + return cachedPattern(`(?m)\b(?:var|let|const|auto)\s+` + name + `\b`).MatchString(fn.Body) } func looksLikeLocalObjectAllocation(fn precisionFunction, assignment support.ParsedAssignment) bool { @@ -307,10 +307,10 @@ func looksLikeLocalObjectAllocation(fn precisionFunction, assignment support.Par } lowerStatement := strings.ToLower(assignmentStatement(fn, assignment.Line)) name := regexp.QuoteMeta(strings.ToLower(strings.TrimSpace(assignment.Name))) - return regexp.MustCompile(`\b`+name+`\s*:=\s*(?:&|make\s*\()`).MatchString(lowerStatement) || - regexp.MustCompile(`\b`+name+`\s*=\s*new\b`).MatchString(lowerStatement) || - regexp.MustCompile(`\b[A-Za-z_$][\w$:<>, ]+\s+`+name+`\s*\{`).MatchString(lowerStatement) || - regexp.MustCompile(`\b`+name+`\s*:=\s*(?:new|create|build)[a-z0-9_$]*\s*\(`).MatchString(lowerStatement) + return cachedPattern(`\b`+name+`\s*:=\s*(?:&|make\s*\()`).MatchString(lowerStatement) || + cachedPattern(`\b`+name+`\s*=\s*new\b`).MatchString(lowerStatement) || + cachedPattern(`\b[A-Za-z_$][\w$:<>, ]+\s+`+name+`\s*\{`).MatchString(lowerStatement) || + cachedPattern(`\b`+name+`\s*:=\s*(?:new|create|build)[a-z0-9_$]*\s*\(`).MatchString(lowerStatement) } func observableCallEffect(callee string) string { diff --git a/internal/codeguard/checks/quality/quality_environment.go b/internal/codeguard/checks/quality/quality_environment.go index 1dde6a62..da558e70 100644 --- a/internal/codeguard/checks/quality/quality_environment.go +++ b/internal/codeguard/checks/quality/quality_environment.go @@ -93,6 +93,11 @@ func environmentBranchLine(text string) int { return 0 } +// branchKeywords replaces `\b(if|switch|case|when)\b`, which ran on every +// source line; containsWordAny applies the same ASCII word boundaries without +// entering the regexp engine. +var branchKeywords = []string{"if", "switch", "case", "when"} + func environmentBranchLineIsDeploymentDecision(line string) bool { if !environmentBranchPattern.MatchString(line) { return false @@ -103,7 +108,7 @@ func environmentBranchLineIsDeploymentDecision(line string) bool { }) { return true } - if !regexp.MustCompile(`\b(if|switch|case|when)\b`).MatchString(lowered) { + if !containsWordAny(lowered, branchKeywords) { return false } return containsAny(lowered, []string{" env", "env.", "env[", "environment", "deployment"}) && diff --git a/internal/codeguard/checks/quality/quality_errors.go b/internal/codeguard/checks/quality/quality_errors.go index 3f172fe1..9e30a576 100644 --- a/internal/codeguard/checks/quality/quality_errors.go +++ b/internal/codeguard/checks/quality/quality_errors.go @@ -182,10 +182,13 @@ func inconsistentWrappingLine(fn precisionFunction) (int, bool) { return 0, false } +// PERF: compiled once; bareErrorReturn runs per return statement. +var bareErrorReturnPattern = regexp.MustCompile(`^return\s+(.+,\s*)?(err|error|e)$`) + func bareErrorReturn(line string) bool { trimmed := strings.TrimSpace(strings.TrimSuffix(line, ";")) lowered := strings.ToLower(trimmed) - return regexp.MustCompile(`^return\s+(.+,\s*)?(err|error|e)$`).MatchString(lowered) || + return bareErrorReturnPattern.MatchString(lowered) || lowered == "raise" || lowered == "raise e" || lowered == "throw e" } diff --git a/internal/codeguard/checks/quality/quality_identifier_scan.go b/internal/codeguard/checks/quality/quality_identifier_scan.go new file mode 100644 index 00000000..ce079fe2 --- /dev/null +++ b/internal/codeguard/checks/quality/quality_identifier_scan.go @@ -0,0 +1,200 @@ +package quality + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +// sourceIdentifiers memoizes one file's identifier scan across the naming +// detectors. It is per-call state, never shared between goroutines. +type sourceIdentifiers struct { + source string + idents []identifierAtLine + words [][]string + scanned bool + splitDone bool +} + +func (s *sourceIdentifiers) all() []identifierAtLine { + if !s.scanned { + s.idents = identifiersInSource(s.source) + s.scanned = true + } + return s.idents +} + +// wordsPerIdentifier returns each identifier already split into words, in the +// same order as all(). Splitting once per identifier instead of once per +// candidate term is what made the cross-layer detector affordable. Kept +// separate from all() so a detector that only needs the names pays nothing. +func (s *sourceIdentifiers) wordsPerIdentifier() [][]string { + if s.splitDone { + return s.words + } + idents := s.all() + s.words = make([][]string, len(idents)) + for i, ident := range idents { + s.words[i] = splitIdentifierWords(ident.name) + } + s.splitDone = true + return s.words +} + +type identifierAtLine struct { + name string + line int +} + +// identifiersInSource scans for `[A-Za-z_$][A-Za-z0-9_$]*` by hand instead of +// through identifierTokenPattern. The character class is pure ASCII, and a +// UTF-8 continuation byte is never in it, so a byte scan yields exactly the +// matches RE2 does — at a fraction of the cost on whole-file input. +func identifiersInSource(source string) []identifierAtLine { + out := make([]identifierAtLine, 0, len(source)/8) + line := 1 + previousMatchStart := 0 + for i := 0; i < len(source); { + if !isIdentifierStartByte(source[i]) { + i++ + continue + } + start := i + i++ + for i < len(source) && isIdentifierPartByte(source[i]) { + i++ + } + line += strings.Count(source[previousMatchStart:start], "\n") + out = append(out, identifierAtLine{name: source[start:i], line: line}) + previousMatchStart = start + } + return out +} + +func isIdentifierStartByte(c byte) bool { + return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c == '_' || c == '$' +} + +func isIdentifierPartByte(c byte) bool { + return isIdentifierStartByte(c) || c >= '0' && c <= '9' +} + +// splitIdentifierWords splits on `[_\-\s]+` (Go regexp `\s` is exactly +// "\t\n\f\r "), then on camel-case boundaries. Both separator sets are ASCII, +// so splitting on bytes matches the regexp exactly; empty parts were discarded +// by the caller before and are simply never produced now. +func splitIdentifierWords(name string) []string { + trimmed := strings.Trim(name, "_$") + if trimmed == "" { + return nil + } + var words []string + start := 0 + for i := 0; i <= len(trimmed); i++ { + if i < len(trimmed) && !isIdentifierWordSeparator(trimmed[i]) { + continue + } + if i > start { + words = append(words, splitCamelWords(trimmed[start:i])...) + } + start = i + 1 + } + return words +} + +func isIdentifierWordSeparator(c byte) bool { + switch c { + case '_', '-', '\t', '\n', '\f', '\r', ' ': + return true + default: + return false + } +} + +func splitCamelWords(part string) []string { + if part == "" { + return nil + } + if isASCIIString(part) { + return splitCamelWordsASCII(part) + } + runes := []rune(part) + start := 0 + out := make([]string, 0, 3) + for idx := 1; idx < len(runes); idx++ { + prev := runes[idx-1] + cur := runes[idx] + nextLower := idx+1 < len(runes) && unicode.IsLower(runes[idx+1]) + if unicode.IsLower(prev) && unicode.IsUpper(cur) || unicode.IsUpper(prev) && unicode.IsUpper(cur) && nextLower { + out = append(out, string(runes[start:idx])) + start = idx + } + } + out = append(out, string(runes[start:])) + return out +} + +// splitCamelWordsASCII is the allocation-free form of splitCamelWords for the +// overwhelmingly common ASCII case: no []rune copy, and each word is a slice of +// the input rather than a fresh string. unicode.IsLower/IsUpper agree with the +// a-z / A-Z tests for every byte below 0x80, so the split points are identical. +func splitCamelWordsASCII(part string) []string { + out := make([]string, 0, 3) + start := 0 + for idx := 1; idx < len(part); idx++ { + prev := part[idx-1] + cur := part[idx] + nextLower := idx+1 < len(part) && isASCIILower(part[idx+1]) + if isASCIILower(prev) && isASCIIUpper(cur) || isASCIIUpper(prev) && isASCIIUpper(cur) && nextLower { + out = append(out, part[start:idx]) + start = idx + } + } + return append(out, part[start:]) +} + +func isASCIIString(value string) bool { + for i := 0; i < len(value); i++ { + if value[i] >= utf8.RuneSelf { + return false + } + } + return true +} + +func isASCIILower(c byte) bool { return c >= 'a' && c <= 'z' } + +func isASCIIUpper(c byte) bool { return c >= 'A' && c <= 'Z' } + +// wordsContain / wordsContainAny take an already-split identifier so callers in +// a loop can split once instead of once per candidate word. They replace +// identifierContainsWord / containsAnyIdentifierWord, which re-split the +// identifier on every question. +func wordsContain(identifierWords []string, word string) bool { + for _, candidate := range identifierWords { + if strings.EqualFold(candidate, word) { + return true + } + } + return false +} + +func wordsContainAny(identifierWords []string, words []string) bool { + for _, word := range words { + if wordsContain(identifierWords, word) { + return true + } + } + return false +} + +var roleSuffixes = []string{"manager", "helper", "util", "utils", "service", "processor"} + +func hasRoleSuffix(name string) bool { + for _, suffix := range roleSuffixes { + if len(name) >= len(suffix) && strings.EqualFold(name[len(name)-len(suffix):], suffix) { + return true + } + } + return false +} diff --git a/internal/codeguard/checks/quality/quality_pattern_cache.go b/internal/codeguard/checks/quality/quality_pattern_cache.go new file mode 100644 index 00000000..ddb3b262 --- /dev/null +++ b/internal/codeguard/checks/quality/quality_pattern_cache.go @@ -0,0 +1,44 @@ +package quality + +import ( + "regexp" + "sync" + "sync/atomic" +) + +// PERF: several precision detectors build a regex out of scan input (an +// identifier name, usually) and then match it once. Compiling per call made +// regexp.MustCompile responsible for ~32% of every byte a full scan allocated — +// one detector alone (duplicatedKnowledgeLineIsEnumOrTypeLiteral, which ran per +// source line) accounted for half of that. Patterns whose text is constant are +// package-level vars; the ones that interpolate scan input go through +// cachedPattern. +// +// The cache is process-global and `codeguard serve --mcp` is long-lived, so it +// is bounded: past maxCachedPatterns distinct expressions new entries are +// compiled but not retained. Distinct expressions track distinct identifier +// names, so real repositories stay well under the cap. +const maxCachedPatterns = 8192 + +var ( + dynamicPatternCache sync.Map // expression string -> *regexp.Regexp + dynamicPatternCount atomic.Int64 +) + +// cachedPattern compiles expr at most once per distinct expression. It panics +// on an invalid expression exactly like the regexp.MustCompile calls it +// replaces, so a malformed interpolation still surfaces through safeRun. +func cachedPattern(expr string) *regexp.Regexp { + if cached, ok := dynamicPatternCache.Load(expr); ok { + return cached.(*regexp.Regexp) + } + compiled := regexp.MustCompile(expr) + if dynamicPatternCount.Load() >= maxCachedPatterns { + return compiled + } + actual, loaded := dynamicPatternCache.LoadOrStore(expr, compiled) + if !loaded { + dynamicPatternCount.Add(1) + } + return actual.(*regexp.Regexp) +} diff --git a/internal/codeguard/checks/quality/quality_precision.go b/internal/codeguard/checks/quality/quality_precision.go index 6cc8857b..90169207 100644 --- a/internal/codeguard/checks/quality/quality_precision.go +++ b/internal/codeguard/checks/quality/quality_precision.go @@ -48,6 +48,12 @@ var ( queryFunctionPrefixPattern = regexp.MustCompile(`^(get|find|list|load|read|lookup|fetch|is|has|can|should|compute|calculate|build|format|parse)`) mutatingCallPattern = regexp.MustCompile(`(^|[.>:\-_])(?i:add|allocate|append|assign|clear|create|delete|emit|insert|mutate|persist|pop|publish|push|push_back|remove|reverse|save|send|set|sort|splice|store|update|upsert|with|write)([A-Z_:\-.]|$)`) lowLevelOperationPattern = regexp.MustCompile(`(?i)(\bsql\.|\.query\(|\.exec\(|\bhttp\.|\bfetch\(|\baxios\.|\brequests\.|\bjson\.|\bJSON\.|\bos\.Getenv\b|\bprocess\.env\b|\bfs\.|#include\b)`) + // lowLevelOperationLiterals must stay a superset of every branch above: a + // string containing none of them cannot match the pattern. + lowLevelOperationLiterals = []string{ + "sql.", ".query(", ".exec(", "http.", "fetch(", "axios.", "requests.", + "json.", "os.getenv", "process.env", "fs.", "#include", + } primitiveTypePattern = regexp.MustCompile(`(?i)\b(string|str|int|int64|float|float64|double|decimal|number|boolean|bool|char|long|short)\b`) domainPrimitiveNamePattern = regexp.MustCompile(`(?i)(id|status|state|type|kind|currency|amount|price|email|phone|country|role|permission|tenant|account|customer|order)`) mutableGlobalLinePattern = regexp.MustCompile(`(?m)^\s*(?:export\s+)?(?:let|var)\s+[A-Za-z_$][\w$]*\s*=|^\s*[A-Za-z_]\w*\s*=`) @@ -806,7 +812,13 @@ func hiddenSideEffect(file string, fn precisionFunction) bool { } func mixedAbstractionLevel(fn precisionFunction) bool { - if fn.EndLine-fn.StartLine < 5 || !lowLevelOperationPattern.MatchString(fn.Body) { + // PERF: lowLevelOperationPattern is a (?i) alternation whose branches all + // start with \b, which defeats RE2's literal-prefix scan, and it runs over + // every function body. Gate it on the literals it requires first — a body + // containing none of them cannot match (see the secret scanner's + // builtinGatePasses for the same technique). + if fn.EndLine-fn.StartLine < 5 || !containsAnyFold(fn.Body, lowLevelOperationLiterals) || + !lowLevelOperationPattern.MatchString(fn.Body) { return false } for _, call := range fn.Calls { diff --git a/internal/codeguard/checks/quality/quality_precision_duplication.go b/internal/codeguard/checks/quality/quality_precision_duplication.go index 4f81402b..2c89aa10 100644 --- a/internal/codeguard/checks/quality/quality_precision_duplication.go +++ b/internal/codeguard/checks/quality/quality_precision_duplication.go @@ -10,7 +10,10 @@ import ( "github.com/devr-tools/codeguard/internal/codeguard/core" ) -var domainKnowledgeLiteralPattern = regexp.MustCompile(`"([^"]{2,80})"|'([^']{2,80})'|\b\d+(?:\.\d+)?\b`) +var ( + domainKnowledgeLiteralPattern = regexp.MustCompile(`"([^"]{2,80})"|'([^']{2,80})'|\b\d+(?:\.\d+)?\b`) + enumOrTypeLiteralLinePattern = regexp.MustCompile(`^\s*["'][A-Za-z0-9_-]+["']\s*,?\s*$`) +) func parsedDuplicatedKnowledgeFindings(env support.Context, file string, parsed *support.ParsedFile) []core.Finding { return sourceDuplicatedKnowledgeFindings(env, file, parsed.Source) @@ -77,7 +80,7 @@ func duplicatedKnowledgeLineIsEnumOrTypeLiteral(line string) bool { if strings.Contains(line, "|") && containsAny(lowered, []string{"type ", "=", `"`, `'`}) { return true } - return regexp.MustCompile(`^\s*["'][A-Za-z0-9_-]+["']\s*,?\s*$`).MatchString(line) + return enumOrTypeLiteralLinePattern.MatchString(line) } func domainKnowledgeLiterals(line string) []string { diff --git a/internal/codeguard/checks/quality/quality_precision_mutation_targets.go b/internal/codeguard/checks/quality/quality_precision_mutation_targets.go index a15e3d9a..41ef9432 100644 --- a/internal/codeguard/checks/quality/quality_precision_mutation_targets.go +++ b/internal/codeguard/checks/quality/quality_precision_mutation_targets.go @@ -12,6 +12,24 @@ var conventionalMutationBoundaryPattern = regexp.MustCompile(`^(accept|apply|app var localAccumulatorExprPattern = regexp.MustCompile(`(?i)^(?:new\s+)?(?:array|date|filereader|formdata|image|map|object|set|url|urlsearchparams|weakmap|weakset)\b|^\[|^\{|^make\s*\(|^array\.from\b|\.map\s*\(|\.filter\s*\(|\.reduce\s*\(|\.split\s*\(|cheerio\.load\s*\(|document\.createelement\s*\(|^(?:bytes|strings)\.buffer\b|^strings\.builder\b`) +var ( + dateHelperCallPattern = regexp.MustCompile(`=\s*(?:startof|endof|datefrom|today|yesterday)[A-Za-z0-9_$]*\s*\(`) + dayHelperCallPattern = regexp.MustCompile(`=\s*[A-Za-z0-9_$]*day\s*\(`) + numericLiteralOnlyPattern = regexp.MustCompile(`^-?\d+(?:\.\d+)?$`) + numericAssignmentPattern = regexp.MustCompile(`=\s*-?\d+(?:\.\d+)?\b`) + bareIdentifierArgPattern = regexp.MustCompile(`^[A-Za-z_$][\w$]*$`) +) + +// Literal pre-gates for the name-interpolated patterns in +// assignmentLooksLocalAccumulator. Each list must stay a superset of what its +// pattern requires: a statement holding none of these literals cannot match, so +// the regex never has to be built. +var ( + declarationKeywordLiterals = []string{"const", "let", "var"} + varKeywordLiteral = []string{"var"} + cppNamespaceLiteral = []string{"std::"} +) + func localMutationTargets(fn precisionFunction) map[string]struct{} { params := paramNames(fn) targets := make(map[string]struct{}) @@ -112,11 +130,16 @@ func assignmentLooksLocalAccumulator(fn precisionFunction, assignment support.Pa return false } name := regexp.QuoteMeta(assignment.Name) - return regexp.MustCompile(`(?i)\b(?:const|let|var)\s+`+name+`\b.*=\s*(?:new\s+)?(?:array|date|filereader|formdata|image|map|object|set|url|urlsearchparams|weakmap|weakset)\b`).MatchString(statement) || - regexp.MustCompile(`(?i)\bvar\s+`+name+`\s+(?:bytes\.buffer|strings\.builder)\b`).MatchString(statement) || - regexp.MustCompile(`(?i)\bstd::(?:vector|map|set|unordered_map|unordered_set|stringstream)\b[^;\n]*\b`+name+`\b`).MatchString(statement) || - regexp.MustCompile(`\b`+name+`\s*:=\s*(?:\[\]|\{\}|make\s*\(|(?:bytes|strings)\.Buffer\b|strings\.Builder\b)`).MatchString(statement) || - regexp.MustCompile(`(?i)\b(?:const|let|var)\s+`+name+`\b.*=\s*(?:\[|\{|array\.from\b|[^;\n]+\.map\s*\(|[^;\n]+\.filter\s*\(|new\s+urlsearchparams\b)`).MatchString(statement) || + // PERF: each branch interpolates the assignment name, so the expression is + // distinct per identifier and cannot be a package-level var. Gate each one + // on a literal it requires before building (and caching) the regex — this + // was the single largest remaining source of runtime regexp.Compile. + declaresBinding := containsAnyFold(statement, declarationKeywordLiterals) + return declaresBinding && cachedPattern(`(?i)\b(?:const|let|var)\s+`+name+`\b.*=\s*(?:new\s+)?(?:array|date|filereader|formdata|image|map|object|set|url|urlsearchparams|weakmap|weakset)\b`).MatchString(statement) || + containsAnyFold(statement, varKeywordLiteral) && cachedPattern(`(?i)\bvar\s+`+name+`\s+(?:bytes\.buffer|strings\.builder)\b`).MatchString(statement) || + containsAnyFold(statement, cppNamespaceLiteral) && cachedPattern(`(?i)\bstd::(?:vector|map|set|unordered_map|unordered_set|stringstream)\b[^;\n]*\b`+name+`\b`).MatchString(statement) || + strings.Contains(statement, ":=") && cachedPattern(`\b`+name+`\s*:=\s*(?:\[\]|\{\}|make\s*\(|(?:bytes|strings)\.Buffer\b|strings\.Builder\b)`).MatchString(statement) || + declaresBinding && cachedPattern(`(?i)\b(?:const|let|var)\s+`+name+`\b.*=\s*(?:\[|\{|array\.from\b|[^;\n]+\.map\s*\(|[^;\n]+\.filter\s*\(|new\s+urlsearchparams\b)`).MatchString(statement) || assignmentContinuationLooksLocalCollection(fn, assignment) || assignmentLooksDateCloneOrHelper(assignment, statement) } @@ -143,7 +166,7 @@ func assignmentContinuationLooksLocalCollection(fn precisionFunction, assignment return false } name := strings.ToLower(regexp.QuoteMeta(assignment.Name)) - return regexp.MustCompile(`\b(?:const|let|var)\s+`+name+`\b`).MatchString(window) && + return cachedPattern(`\b(?:const|let|var)\s+`+name+`\b`).MatchString(window) && containsAny(window, []string{".map(", ".filter(", ".reduce(", "array.from(", "[...", ".slice("}) } @@ -157,8 +180,8 @@ func assignmentLooksDateCloneOrHelper(assignment support.ParsedAssignment, state return false } return strings.Contains(lowered, "new date(") || - regexp.MustCompile(`=\s*(?:startof|endof|datefrom|today|yesterday)[A-Za-z0-9_$]*\s*\(`).MatchString(lowered) || - name == "x" && regexp.MustCompile(`=\s*[A-Za-z0-9_$]*day\s*\(`).MatchString(lowered) + dateHelperCallPattern.MatchString(lowered) || + name == "x" && dayHelperCallPattern.MatchString(lowered) } func assignmentLooksLocalScalarAccumulator(assignment support.ParsedAssignment, statement string) bool { @@ -168,11 +191,11 @@ func assignmentLooksLocalScalarAccumulator(assignment support.ParsedAssignment, } expr := strings.TrimSpace(assignment.Expr) lowered := strings.ToLower(statement + " " + expr) - if !regexp.MustCompile(`(?i)\b(?:const|let|var)\s+` + regexp.QuoteMeta(assignment.Name) + `\b`).MatchString(statement) { + if !cachedPattern(`(?i)\b(?:const|let|var)\s+` + regexp.QuoteMeta(assignment.Name) + `\b`).MatchString(statement) { return false } - if !regexp.MustCompile(`^-?\d+(?:\.\d+)?$`).MatchString(expr) && - !regexp.MustCompile(`=\s*-?\d+(?:\.\d+)?\b`).MatchString(statement) && + if !numericLiteralOnlyPattern.MatchString(expr) && + !numericAssignmentPattern.MatchString(statement) && !strings.Contains(lowered, "score") && !strings.Contains(lowered, "total") && !strings.Contains(lowered, "count") && @@ -369,7 +392,7 @@ func firstCallArgName(call support.ParsedCall) string { if idx := strings.IndexAny(arg, ".[("); idx > 0 { arg = strings.TrimSpace(arg[:idx]) } - if regexp.MustCompile(`^[A-Za-z_$][\w$]*$`).MatchString(arg) { + if bareIdentifierArgPattern.MatchString(arg) { return arg } return "" @@ -448,7 +471,7 @@ func isLocalScalarAccumulatorAssignment(fn precisionFunction, name string) bool return false } quoted := regexp.QuoteMeta(name) - declaration := regexp.MustCompile(`(?i)\b(?:const|let|var)\s+` + quoted + `\b[^;\n]*=\s*-?\d+(?:\.\d+)?\b`) + declaration := cachedPattern(`(?i)\b(?:const|let|var)\s+` + quoted + `\b[^;\n]*=\s*-?\d+(?:\.\d+)?\b`) for _, statement := range directStatements(fn) { if declaration.MatchString(firstNonEmptyString(statement.Raw, statement.Text)) { return true diff --git a/internal/codeguard/checks/quality/quality_precision_ui_conventions.go b/internal/codeguard/checks/quality/quality_precision_ui_conventions.go index e7040944..e6d11696 100644 --- a/internal/codeguard/checks/quality/quality_precision_ui_conventions.go +++ b/internal/codeguard/checks/quality/quality_precision_ui_conventions.go @@ -282,18 +282,47 @@ func moduleStatementLooksTopLevel(statement support.ParsedStatement) bool { } func scriptSourceLineAtModuleScope(source string, targetIdx int) bool { + // PERF: this is called once per candidate symbol and only ever reads lines + // up to targetIdx, so splitting the whole file into a []string each time + // was ~10% of a full scan's allocations. eachSourceLine walks lazily. depth := 0 - lines := strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n") - for idx, line := range lines { + atModuleScope := true + eachSourceLine(source, func(idx int, line string) bool { if idx == targetIdx { - return depth == 0 && len(line) == len(strings.TrimLeft(line, " \t")) + atModuleScope = depth == 0 && len(line) == len(strings.TrimLeft(line, " \t")) + return false } depth += braceDelta(maskCommentSuffix(line)) if depth < 0 { depth = 0 } + return true + }) + return atModuleScope +} + +// eachSourceLine walks source line by line with exactly the normalization of +// strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n") — a trailing +// carriage return is dropped only where it preceded a newline, so a lone "\r" +// at end of file survives, as it does there — without building the slice. +// visit returns false to stop the walk. +func eachSourceLine(source string, visit func(idx int, line string) bool) { + for start, idx := 0, 0; ; idx++ { + offset := strings.IndexByte(source[start:], '\n') + if offset < 0 { + visit(idx, source[start:]) + return + } + end := start + offset + line := source[start:end] + if len(line) > 0 && line[len(line)-1] == '\r' { + line = line[:len(line)-1] + } + if !visit(idx, line) { + return + } + start = end + 1 } - return true } func maskCommentSuffix(line string) string { diff --git a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go index 80770d6c..65db2e94 100644 --- a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go +++ b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go @@ -6,7 +6,6 @@ import ( "regexp" "sort" "strings" - "unicode" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" @@ -31,19 +30,17 @@ const ( ) var ( - readCallPattern = regexp.MustCompile(`(^|[.>:\-_])(?i:count|fetch|find|get|list|load|lookup|query|read|select|search)([A-Z_:\-.]|$)`) - identifierTokenPattern = regexp.MustCompile(`[A-Za-z_$][A-Za-z0-9_$]*`) - roleSuffixPattern = regexp.MustCompile(`(?i)(manager|helper|util|utils|service|processor)$`) - durationNamePattern = regexp.MustCompile(`(?i)(timeout|duration|delay|interval|ttl|latency|elapsed|expiry|expiration|retention)`) - sizeNamePattern = regexp.MustCompile(`(?i)(size|limit|length|capacity|bytes?|mb|kb|gb)`) - moneyNamePattern = regexp.MustCompile(`(?i)(amount|price|cost|fee|total|subtotal|balance|money)`) - unitSuffixPattern = regexp.MustCompile(`(?i)(nanos?|micros?|millis?|ms|seconds?|secs?|s|minutes?|mins?|hours?|hrs?|days?|bytes?|kb|mb|gb|cents?|pennies|minor|bps|basispoints?|usd|eur|gbp|aud|cad)$`) - collectionTypePattern = regexp.MustCompile(`(?i)(\[\]|\[\s*\]|array|list|slice|map|dict|record|set|vector|collection|iterable|sequence|promise<[^>]*\[\])`) - scalarTypePattern = regexp.MustCompile(`(?i)\b(bool|boolean|char|double|float|float64|int|int32|int64|number|string|str|uint|uint64)\b`) - paramMutationPattern = regexp.MustCompile(`\b([A-Za-z_$][\w$]*)\s*(?:\.|->|\[)`) - returnLinePattern = regexp.MustCompile(`(?m)^\s*return(?:\s+([^;\n]+))?`) - partialReturnPattern = regexp.MustCompile(`(?i)\breturn\s+[^;\n,]+,\s*(err|error)\b|\breturn\s+\{[^}\n]*(data|result|value)[^}\n]*(err|error)[^}\n]*\}`) - identifierWordSplitPattern = regexp.MustCompile(`[_\-\s]+`) + readCallPattern = regexp.MustCompile(`(^|[.>:\-_])(?i:count|fetch|find|get|list|load|lookup|query|read|select|search)([A-Z_:\-.]|$)`) + identifierTokenPattern = regexp.MustCompile(`[A-Za-z_$][A-Za-z0-9_$]*`) + durationNamePattern = regexp.MustCompile(`(?i)(timeout|duration|delay|interval|ttl|latency|elapsed|expiry|expiration|retention)`) + sizeNamePattern = regexp.MustCompile(`(?i)(size|limit|length|capacity|bytes?|mb|kb|gb)`) + moneyNamePattern = regexp.MustCompile(`(?i)(amount|price|cost|fee|total|subtotal|balance|money)`) + unitSuffixPattern = regexp.MustCompile(`(?i)(nanos?|micros?|millis?|ms|seconds?|secs?|s|minutes?|mins?|hours?|hrs?|days?|bytes?|kb|mb|gb|cents?|pennies|minor|bps|basispoints?|usd|eur|gbp|aud|cad)$`) + collectionTypePattern = regexp.MustCompile(`(?i)(\[\]|\[\s*\]|array|list|slice|map|dict|record|set|vector|collection|iterable|sequence|promise<[^>]*\[\])`) + scalarTypePattern = regexp.MustCompile(`(?i)\b(bool|boolean|char|double|float|float64|int|int32|int64|number|string|str|uint|uint64)\b`) + paramMutationPattern = regexp.MustCompile(`\b([A-Za-z_$][\w$]*)\s*(?:\.|->|\[)`) + returnLinePattern = regexp.MustCompile(`(?m)^\s*return(?:\s+([^;\n]+))?`) + partialReturnPattern = regexp.MustCompile(`(?i)\breturn\s+[^;\n,]+,\s*(err|error)\b|\breturn\s+\{[^}\n]*(data|result|value)[^}\n]*(err|error)[^}\n]*\}`) ) func additionalPrecisionFunctionFindings(env support.Context, file string, fn precisionFunction) []core.Finding { @@ -159,13 +156,15 @@ func sourceNamingFindings(env support.Context, file string, source string) []cor return nil } findings := make([]core.Finding, 0, 3) - if finding, ok := glossaryDriftFinding(env, file, source); ok { + // PERF: all three detectors tokenize the same source. Scan it at most once. + idents := &sourceIdentifiers{source: source} + if finding, ok := glossaryDriftFinding(env, file, idents); ok { findings = append(findings, finding) } - if finding, ok := roleSuffixOveruseFinding(env, file, source); ok { + if finding, ok := roleSuffixOveruseFinding(env, file, idents); ok { findings = append(findings, finding) } - if finding, ok := crossLayerInconsistencyFinding(env, file, source); ok { + if finding, ok := crossLayerInconsistencyFinding(env, file, idents); ok { findings = append(findings, finding) } return findings @@ -517,9 +516,12 @@ func nextResponseNullableGuardHelper(fn precisionFunction) bool { return strings.Contains(body, "return null") && hasNextResponseBody } +// PERF: compiled once; this detector runs per function. +var lookupFunctionNamePattern = regexp.MustCompile(`^(as|exists|extract|find|get|lookup|parse|read|resolve|to)`) + func nullableParserLookupContract(fn precisionFunction) bool { loweredName := strings.ToLower(strings.Trim(fn.Name, "_$")) - if !regexp.MustCompile(`^(as|exists|extract|find|get|lookup|parse|read|resolve|to)`).MatchString(loweredName) { + if !lookupFunctionNamePattern.MatchString(loweredName) { return false } body := strings.ToLower(fn.Body) @@ -990,18 +992,23 @@ func numericExpr(expr string) bool { } func unknownAbbreviation(env support.Context, name string) string { - allowed := allowedAbbreviations(env) for _, word := range splitIdentifierWords(name) { candidate := strings.ToLower(strings.Trim(word, "_$")) if candidate == "" || len(candidate) < 2 { continue } - if _, ok := allowed[candidate]; ok { + // PERF: allowedAbbreviations rebuilds a ~45-entry map from config on + // every call, and this runs for every identifier in every function. + // The two tests commute — a candidate is reported only when it is + // suspicious AND not allowed — so apply the cheap one first and touch + // the map only for the handful of candidates that could be reported. + if !isSuspiciousShortening(candidate) { continue } - if isSuspiciousShortening(candidate) { - return word + if _, ok := allowedAbbreviations(env)[candidate]; ok { + continue } + return word } return "" } @@ -1027,18 +1034,19 @@ func isSuspiciousShortening(word string) bool { } } -func glossaryDriftFinding(env support.Context, file string, source string) (core.Finding, bool) { +func glossaryDriftFinding(env support.Context, file string, idents *sourceIdentifiers) (core.Finding, bool) { for concept, entry := range env.Config.Checks.QualityRules.Naming.Glossary { preferred := strings.ToLower(strings.TrimSpace(concept)) terms := append([]string{preferred}, entry.Avoid...) seen := map[string]int{} - for _, ident := range identifiersInSource(source) { + identWords := idents.wordsPerIdentifier() + for i, ident := range idents.all() { for _, term := range terms { normalized := strings.ToLower(strings.TrimSpace(term)) if normalized == "" { continue } - if identifierContainsWord(ident.name, normalized) { + if wordsContain(identWords[i], normalized) { seen[normalized] = ident.line } } @@ -1052,7 +1060,7 @@ func glossaryDriftFinding(env support.Context, file string, source string) (core return core.Finding{}, false } -func roleSuffixOveruseFinding(env support.Context, file string, source string) (core.Finding, bool) { +func roleSuffixOveruseFinding(env support.Context, file string, idents *sourceIdentifiers) (core.Finding, bool) { normalizedFile := strings.ToLower(strings.ReplaceAll(file, "\\", "/")) if isSeedOrScriptSourcePath(file) || isPackageAPIRouterPath(file) || strings.Contains(normalizedFile, "/infra/") || strings.HasPrefix(normalizedFile, "infra/") { @@ -1069,8 +1077,11 @@ func roleSuffixOveruseFinding(env support.Context, file string, source string) ( threshold = 4 } seen := map[string]int{} - for _, ident := range identifiersInSource(source) { - if roleSuffixPattern.MatchString(ident.name) { + for _, ident := range idents.all() { + // hasRoleSuffix replaces `(?i)(manager|helper|util|utils|service| + // processor)$`: without (?m) that anchor is end-of-text, and an + // identifier holds no newline, so a suffix test is exactly equivalent. + if hasRoleSuffix(ident.name) { seen[ident.name] = ident.line } } @@ -1081,8 +1092,8 @@ func roleSuffixOveruseFinding(env support.Context, file string, source string) ( fmt.Sprintf("file uses %d vague role suffix names such as Manager/Helper/Util/Service/Processor", len(seen)), core.ConfidenceMedium), true } -func crossLayerInconsistencyFinding(env support.Context, file string, source string) (core.Finding, bool) { - if strings.Contains(source, "userDeletionRequest") { +func crossLayerInconsistencyFinding(env support.Context, file string, idents *sourceIdentifiers) (core.Finding, bool) { + if strings.Contains(idents.source, "userDeletionRequest") { return core.Finding{}, false } groups := [][]string{ @@ -1091,15 +1102,21 @@ func crossLayerInconsistencyFinding(env support.Context, file string, source str {"order", "purchase", "transaction"}, } layerTerms := []string{"api", "request", "response", "dto", "entity", "record", "row", "model", "repository", "domain"} - idents := identifiersInSource(source) + // PERF: each identifier is split into words ONCE (shared with the other + // naming detectors). The nested loops below ask up to 3*(10+4) membership + // questions per identifier, and each one used to re-split the identifier + // from scratch — this single detector was ~15% of a full scan's CPU and + // ~20% of its allocations. + identWords := idents.wordsPerIdentifier() for _, group := range groups { seen := map[string]int{} - for _, ident := range idents { - if !containsAnyIdentifierWord(ident.name, layerTerms) { + for i, ident := range idents.all() { + words := identWords[i] + if !wordsContainAny(words, layerTerms) { continue } for _, term := range group { - if identifierContainsWord(ident.name, term) { + if wordsContain(words, term) { seen[term] = ident.line } } @@ -1112,80 +1129,6 @@ func crossLayerInconsistencyFinding(env support.Context, file string, source str return core.Finding{}, false } -type identifierAtLine struct { - name string - line int -} - -func identifiersInSource(source string) []identifierAtLine { - matches := identifierTokenPattern.FindAllStringIndex(source, -1) - out := make([]identifierAtLine, 0, len(matches)) - line := 1 - previousMatchStart := 0 - for _, match := range matches { - line += strings.Count(source[previousMatchStart:match[0]], "\n") - out = append(out, identifierAtLine{ - name: source[match[0]:match[1]], - line: line, - }) - previousMatchStart = match[0] - } - return out -} - -func splitIdentifierWords(name string) []string { - trimmed := strings.Trim(name, "_$") - if trimmed == "" { - return nil - } - var words []string - for _, part := range identifierWordSplitPattern.Split(trimmed, -1) { - if part == "" { - continue - } - words = append(words, splitCamelWords(part)...) - } - return words -} - -func splitCamelWords(part string) []string { - if part == "" { - return nil - } - runes := []rune(part) - start := 0 - out := make([]string, 0, 3) - for idx := 1; idx < len(runes); idx++ { - prev := runes[idx-1] - cur := runes[idx] - nextLower := idx+1 < len(runes) && unicode.IsLower(runes[idx+1]) - if unicode.IsLower(prev) && unicode.IsUpper(cur) || unicode.IsUpper(prev) && unicode.IsUpper(cur) && nextLower { - out = append(out, string(runes[start:idx])) - start = idx - } - } - out = append(out, string(runes[start:])) - return out -} - -func identifierContainsWord(identifier string, word string) bool { - for _, candidate := range splitIdentifierWords(identifier) { - if strings.EqualFold(candidate, word) { - return true - } - } - return false -} - -func containsAnyIdentifierWord(identifier string, words []string) bool { - for _, word := range words { - if identifierContainsWord(identifier, word) { - return true - } - } - return false -} - func firstSeenLine(seen map[string]int) int { line := 0 for _, candidate := range seen { diff --git a/internal/codeguard/checks/quality/quality_smells.go b/internal/codeguard/checks/quality/quality_smells.go index e852ec94..1ea40c2c 100644 --- a/internal/codeguard/checks/quality/quality_smells.go +++ b/internal/codeguard/checks/quality/quality_smells.go @@ -57,6 +57,12 @@ type structuralFunction struct { Body string } +// PERF: compiled once; these run per function body. +var ( + nestedSwitchPattern = regexp.MustCompile(`(?is)\bswitch\b.*\bswitch\b`) + branchThenWritePattern = regexp.MustCompile(`(?is)\b(?:if|switch)\b.*\b(?:save|update|delete|send|publish|emit|write)\b`) +) + func goStructuralSmellFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File, data []byte) []core.Finding { classes, functions := goStructuralModel(fset, parsed, data) return structuralSmellFindings(env, file, string(data), "go", classes, functions) @@ -457,7 +463,7 @@ func ownAccessCount(fn structuralFunction) int { } func accessCount(body string, name string) int { - pattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\s*(?:\.|->)`) + pattern := cachedPattern(`\b` + regexp.QuoteMeta(name) + `\s*(?:\.|->)`) return len(pattern.FindAllStringIndex(body, -1)) } @@ -723,7 +729,7 @@ func closedDomainDiscriminatorDispatch(source string) bool { lowered := strings.ToLower(source) return strings.Contains(lowered, "switch") && containsAny(lowered, []string{"type searchtype", ": searchtype", "detectsearchtype", "type:", "kind:"}) && - !regexp.MustCompile(`(?is)\bswitch\b.*\bswitch\b`).MatchString(lowered) + !nestedSwitchPattern.MatchString(lowered) } func centralizedEnumDispatchContext(file string, source string) bool { @@ -733,7 +739,7 @@ func centralizedEnumDispatchContext(file string, source string) bool { return true } if containsAny(lowered, []string{"label:", "labels", "record<", " as const", "satisfies record", "displayname", "display_name"}) && - !regexp.MustCompile(`(?is)\b(?:if|switch)\b.*\b(?:save|update|delete|send|publish|emit|write)\b`).MatchString(lowered) { + !branchThenWritePattern.MatchString(lowered) { return true } return false diff --git a/internal/codeguard/checks/quality/quality_text_scan.go b/internal/codeguard/checks/quality/quality_text_scan.go new file mode 100644 index 00000000..af196e5a --- /dev/null +++ b/internal/codeguard/checks/quality/quality_text_scan.go @@ -0,0 +1,101 @@ +package quality + +import "strings" + +// PERF helpers for detectors that would otherwise run the regexp engine over +// every line or function body. Profiling a full scan showed the regexp engine +// taking ~45% of CPU, and the repository already has the precedent: the secret +// scanner's literal pre-gate (builtinGatePasses) took it from 3.2 to 99 MB/s, +// because an alternation whose branches start with \b or (?i) defeats RE2's +// literal-prefix fast path. +// +// Both helpers below are exact, not approximate: +// - containsAnyFold is an ASCII case-insensitive strings.Contains, which is +// what (?i) means for the ASCII literals these gates hold. +// - containsWordAny applies Go regexp's own \b definition: a boundary sits +// where a word character ([0-9A-Za-z_]) meets a non-word character or an +// edge of the string. + +// containsAnyFold reports whether value may contain one of literals under +// (?i), comparing ASCII letters case-insensitively. literals must already be +// lowercase ASCII. +// +// Go's (?i) uses Unicode simple folding, so `s` also matches U+017F and `k` +// also matches U+212A. Rather than model that, any value holding a non-ASCII +// byte is reported as a possible match, which keeps the gate a strict superset +// of the pattern. Source bodies are ASCII in practice, so the gate still fires. +func containsAnyFold(value string, literals []string) bool { + lowered, ascii := asciiLower(value) + if !ascii { + return true + } + for _, literal := range literals { + if strings.Contains(lowered, literal) { + return true + } + } + return false +} + +// asciiLower lowercases ASCII letters, returning the input unchanged (and +// allocating nothing) when it holds none. The second result is false as soon as +// a byte >= 0x80 is seen, and the first result is then meaningless. +func asciiLower(value string) (string, bool) { + upper := -1 + for i := 0; i < len(value); i++ { + c := value[i] + if c >= 0x80 { + return "", false + } + if upper < 0 && c >= 'A' && c <= 'Z' { + upper = i + } + } + if upper < 0 { + return value, true + } + buf := []byte(value) + for i := upper; i < len(buf); i++ { + if buf[i] >= 'A' && buf[i] <= 'Z' { + buf[i] += 'a' - 'A' + } + } + return string(buf), true +} + +// containsWordAny reports whether value contains any of words delimited by +// regexp word boundaries, i.e. it is equivalent to `\b(w1|w2|...)\b`. Every +// word must be ASCII and already lowercase, and value must already be +// lowercased by the caller (the patterns this replaces matched lowered input). +func containsWordAny(value string, words []string) bool { + for _, word := range words { + if containsWordBoundary(value, word) { + return true + } + } + return false +} + +func containsWordBoundary(value string, word string) bool { + if word == "" { + return false + } + for offset := 0; ; { + idx := strings.Index(value[offset:], word) + if idx < 0 { + return false + } + start := offset + idx + end := start + len(word) + beforeOK := start == 0 || !isRegexpWordByte(value[start-1]) + afterOK := end == len(value) || !isRegexpWordByte(value[end]) + if beforeOK && afterOK { + return true + } + offset = start + 1 + } +} + +func isRegexpWordByte(c byte) bool { + return c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c == '_' +} diff --git a/tests/codeguard/real_repo_scan_benchmark_test.go b/tests/codeguard/real_repo_scan_benchmark_test.go new file mode 100644 index 00000000..eaff4b43 --- /dev/null +++ b/tests/codeguard/real_repo_scan_benchmark_test.go @@ -0,0 +1,148 @@ +package codeguard_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// BenchmarkRealRepositoryFullScan scans checked-out repository snapshots +// instead of a synthetic fixture, so a CPU profile shows where a real scan +// actually spends its time. +// +// Two ways to point it at a corpus; both are skipped when unset so +// `go test ./...` stays hermetic: +// +// CODEGUARD_BENCH_CONFIGS comma/colon-separated codeguard.yaml paths. The +// config is used verbatim, so the profile covers +// exactly the rule set a `codeguard scan` with that +// config runs. Preferred — check-set differences +// move full-scan cost by more than 2x. +// CODEGUARD_BENCH_REPOS comma/colon-separated repository roots, scanned +// with the default (`codeguard init`) check set. +// +// Point either at FROZEN snapshots (`git archive HEAD | tar -x -C `), +// never a live working tree: a tree that mutates under the walk yields +// different findings run to run. Target paths inside a config must be +// absolute — a relative path resolves against the config file's directory. +func BenchmarkRealRepositoryFullScan(b *testing.B) { + cases := realBenchmarkCases(b) + if len(cases) == 0 { + b.Skip("set CODEGUARD_BENCH_CONFIGS or CODEGUARD_BENCH_REPOS") + } + for _, bc := range cases { + b.Run(bc.name, func(b *testing.B) { + for _, variant := range []struct { + name string + cacheEnabled bool + }{ + {name: "cold", cacheEnabled: false}, + {name: "warm-cache", cacheEnabled: true}, + } { + b.Run(variant.name, func(b *testing.B) { + cfg := bc.cfg + cfg.Cache.Enabled = &variant.cacheEnabled + cfg.Cache.Path = filepath.Join(b.TempDir(), "benchmark-cache.json") + if variant.cacheEnabled { + if _, err := codeguard.RunWithOptions(context.Background(), cfg, codeguard.ScanOptions{Mode: codeguard.ScanModeFull}); err != nil { + b.Fatalf("warm cache for %s: %v", bc.name, err) + } + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := codeguard.RunWithOptions(context.Background(), cfg, codeguard.ScanOptions{Mode: codeguard.ScanModeFull}); err != nil { + b.Fatalf("full scan %s: %v", bc.name, err) + } + } + }) + } + }) + } +} + +type realBenchmarkCase struct { + name string + cfg codeguard.Config +} + +func realBenchmarkCases(tb testing.TB) []realBenchmarkCase { + tb.Helper() + var cases []realBenchmarkCase + for _, path := range benchmarkEnvPaths("CODEGUARD_BENCH_CONFIGS") { + cfg, err := codeguard.LoadConfigFile(path) + if err != nil { + tb.Fatalf("load benchmark config %q: %v", path, err) + } + cfg.Output.Format = "json" + cases = append(cases, realBenchmarkCase{name: benchmarkCaseName(path), cfg: cfg}) + } + for _, root := range benchmarkEnvPaths("CODEGUARD_BENCH_REPOS") { + cases = append(cases, realBenchmarkCase{name: filepath.Base(root), cfg: defaultBenchmarkConfig(root)}) + } + return cases +} + +func benchmarkEnvPaths(name string) []string { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return nil + } + fields := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ':' }) + paths := make([]string, 0, len(fields)) + for _, field := range fields { + if path := strings.TrimSpace(field); path != "" { + paths = append(paths, path) + } + } + return paths +} + +// benchmarkCaseName labels a config case by its parent directory, so +// /tmp/cfg/szr/codeguard.yaml reports as "szr". +func benchmarkCaseName(path string) string { + if dir := filepath.Base(filepath.Dir(path)); dir != "." && dir != string(filepath.Separator) { + return dir + } + return filepath.Base(path) +} + +// defaultBenchmarkConfig mirrors what `codeguard init` writes, with the target +// repointed at the snapshot. Nothing extra is disabled: the synthetic full-scan +// benchmark turns off dead code, local precision and the AI quality passes, +// which hides their cost. +func defaultBenchmarkConfig(root string) codeguard.Config { + cfg := codeguard.ExampleConfig() + cfg.Name = "real-repository-benchmark" + cfg.Targets = []codeguard.TargetConfig{{ + Name: "repository", + Path: root, + Language: detectBenchmarkLanguage(root), + }} + cfg.Checks.SecurityRules.GovulncheckMode = "off" + cfg.Output.Format = "json" + return cfg +} + +func detectBenchmarkLanguage(root string) string { + for _, candidate := range []struct { + marker string + language string + }{ + {marker: "go.mod", language: "go"}, + {marker: "tsconfig.json", language: "typescript"}, + {marker: "package.json", language: "typescript"}, + {marker: "pyproject.toml", language: "python"}, + {marker: "setup.py", language: "python"}, + {marker: "requirements.txt", language: "python"}, + } { + if _, err := os.Stat(filepath.Join(root, candidate.marker)); err == nil { + return candidate.language + } + } + return "go" +} From d1653bfb36de0f97be329926266dd9b53cce654c Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Mon, 19 Oct 2026 16:08:18 -0400 Subject: [PATCH 10/16] fix(scan): serve over-budget corpus work uncached instead of skipping it The shared per-scan fileCorpus bounds how much source and how many Go ASTs one scan retains. When a budget was reached it returned an error, and every caller treats a read or parse error as "skip this file" - so exceeding a *cache* budget removed files from the scan and reported fewer findings. The only signal was an info-level diagnostic that the default text report did not render at all. For a security scanner that is a correctness bug: a repository that floods the walk with generated files starves analysis of the real source. It was worse than silent. Which files won the remaining budget depended on goroutine scheduling, because concurrently running sections draw on one shared corpus, so the same frozen tree produced different findings run to run. Three scans of one unchanged 26,305-file target reported 22,921 / 21,941 / 21,498 unique findings - a 6.2% spread, 1,974 fingerprints flapping across 69 rules, not a clone-detector quirk. That breaks baselines (a finding present in two runs of three flaps in and out), makes CI irreproducible, and makes any before/after measurement on a budget-exhausting repository meaningless. The same three runs now report 107,854 / 107,855 / 107,856, with the remaining 343 unstable fingerprints confined to two rules whose own selection is unordered (quality.duplicate-code and smell.data-clump, tracked separately). Cross-file passes took the worst of it. VisitTargetFiles skips any file whose read errors, so clone detection, import graphs and the agent-context inventory were fed a truncated document stream: with a starved budget a 40-file target visited 2 files. Their own budgets were never the trigger; the corpus was. The corpus is a cache, so a miss now degrades instead of refusing: - read() past the retention budget still reads the file and returns its bytes, it just drops the slot. Retention stays bounded exactly as before; the cost of exceeding a budget is CPU and I/O, not findings. Dropping the slot rather than latching a scan-wide "exhausted" flag also keeps admission best-fit, so a later small file can use headroom an oversized one could not. - parseGo() past the AST budget parses without retaining, so AST-driven rules no longer lose files to a full cache. - Reads and parses keep their existing semaphores on the uncached path, so an overflowing scan cannot multiply the number of large buffers in flight. Because nothing is dropped, the file stream equals the walk, which is sorted and single-threaded, and a degraded scan is reproducible rather than merely slower. Three accounting fixes come with it: - The Go AST budget counted SOURCE bytes while retaining trees. A retained ParseComments tree measures ~9x its source (9.0x over this repository, 8.4x over a 13.8k-file module cache), so the nominal 64 MiB budget admitted roughly 576 MiB of live AST. Budgets are now expressed in retained bytes with the expansion factor applied; the new 256 MiB ceiling is a real bound and a reduction on the old effective one. - The budget diagnostic misreported its cause. Once the byte budget latched the exhausted flag, every later miss was recorded as an entry-budget overflow, so a scan that had cached 3,824 files was told its 50,000-entry limit was reached. Each overflow now names the budget that actually tripped. - Retention budgets scale down to a share of an explicit GOMEMLIMIT (1/8 for reads, 1/4 for Go ASTs, with floors). They never scale up: Go does not see a container limit that was not expressed as GOMEMLIMIT, so inferring headroom from host memory would be wrong exactly where memory is tightest. Walk truncation is the one corpus bound that still costs coverage - walkFilesBounded stops at maxScanFileCount and those files reach no check - so it is split out as scan.file-limit at warn level, marked operational so the section reports warn rather than a clean pass it has not earned. The text report now renders warn and fail diagnostics; previously it rendered none, so "the walk stopped early" and "govulncheck could not run" reached a terminal reader not at all. Informational diagnostics stay out of the default report. Findings are unchanged on every repository that stays inside the budgets (seven measured repositories, 46 to 2,755 unique findings each, identical before and after across repeated runs) and on this repository's own self-scan. On the overflowing target the scan now reports ~107,855 unique findings against the old 21,498-22,921, including 2,099 at fail level; spot checks confirm they are ordinary rule matches on real source that was previously never read. Peak RSS is unchanged except where a budget overflows, since reading the previously skipped files costs memory: +35% on one 43k-file repository. Under GOMEMLIMIT the scan degrades predictably - 677 to 194 MiB of RSS from unset down to a 128 MiB limit, +25% wall time, identical findings at every limit. Co-Authored-By: Claude Opus 5 (1M context) --- internal/codeguard/report/text_helpers.go | 25 ++ internal/codeguard/runner/support/corpus.go | 274 ++++++++++++----- .../runner/support/corpus_memory_test.go | 283 ++++++++++++++++-- tests/report/diagnostic_visibility_test.go | 81 +++++ 4 files changed, 562 insertions(+), 101 deletions(-) create mode 100644 tests/report/diagnostic_visibility_test.go diff --git a/internal/codeguard/report/text_helpers.go b/internal/codeguard/report/text_helpers.go index 0226ec2e..503f5bfa 100644 --- a/internal/codeguard/report/text_helpers.go +++ b/internal/codeguard/report/text_helpers.go @@ -63,6 +63,9 @@ func writeTextSection(w io.Writer, section core.SectionResult) error { if _, err := fmt.Fprintf(w, "\n[%s] %s\n", renderStatus(string(section.Status), true), section.Name); err != nil { return err } + if err := writeTextDiagnostics(w, section.Diagnostics); err != nil { + return err + } if len(section.Findings) == 0 { if _, err := io.WriteString(w, " ✅ no findings\n"); err != nil { return err @@ -92,6 +95,28 @@ func writeTextSection(w io.Writer, section core.SectionResult) error { return nil } +// writeTextDiagnostics surfaces the diagnostics that say the scan itself did +// not run as intended — a truncated walk, a vulnerability scanner that could +// not start. Until this existed the default report rendered no diagnostics at +// all -- only the JSON and SARIF writers did -- so "codeguard stopped scanning +// at 100,000 files" or "govulncheck could not run" reached a terminal reader +// not at all. The github annotation writer still does not render them. +// Informational diagnostics stay out of the default report: they are +// per-finding classifications and routine status, and a scan emits enough of +// them to bury the findings they sit beside. +func writeTextDiagnostics(w io.Writer, diagnostics []core.Diagnostic) error { + for _, diagnostic := range diagnostics { + if diagnostic.Level != "warn" && diagnostic.Level != "fail" { + continue + } + if _, err := fmt.Fprintf(w, " %s %s: %s\n", + renderStatus(diagnostic.Level, true), diagnostic.ID, diagnostic.Message); err != nil { + return err + } + } + return nil +} + type textFindingGroup struct { name string findings []core.Finding diff --git a/internal/codeguard/runner/support/corpus.go b/internal/codeguard/runner/support/corpus.go index d3236ce8..92c2501b 100644 --- a/internal/codeguard/runner/support/corpus.go +++ b/internal/codeguard/runner/support/corpus.go @@ -1,25 +1,21 @@ package support import ( - "errors" "fmt" "go/ast" "go/parser" "go/token" "io" + "math" "os" "path/filepath" + "runtime/debug" "sync" checkSupport "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" ) -var ( - errCorpusReadBudget = errors.New("scan corpus read budget exhausted") - errCorpusGoASTBudget = errors.New("scan corpus Go AST budget exhausted") -) - // readCappedFile reads path but refuses to buffer more than maxScanFileBytes, // bounding memory even if a file grew past the walk-time size filter (TOCTOU) or // is read outside the walk. It reads one byte past the cap to distinguish an @@ -50,41 +46,85 @@ func readCappedFile(path string) ([]byte, error) { // Each cached slot carries its own sync.Once, so concurrent callers racing on a // cold slot compute it exactly once and every caller observes the same result. type fileCorpus struct { - mu sync.Mutex - targets map[string]*targetListing - maxFiles int - reads map[string]*fileRead - readBytes int - maxReadBytes int - maxReadEntries int - readExhausted bool - readWork chan struct{} - readFile func(string) ([]byte, error) - asts map[string]*goParse - goASTBytes int - maxGoASTBytes int - maxGoASTEntries int - goASTExhausted bool - goParseWork chan struct{} - scripts map[string]*scriptParse - scriptBytes int - scriptCount int - scriptParse chan struct{} - diagnostics []core.Diagnostic - diagnosticSeen map[string]struct{} + mu sync.Mutex + targets map[string]*targetListing + maxFiles int + reads map[string]*fileRead + readBytes int + maxReadBytes int + maxReadEntries int + readBudgetExceeded bool + readWork chan struct{} + readFile func(string) ([]byte, error) + asts map[string]*goParse + goASTBytes int + maxGoASTBytes int + maxGoASTEntries int + goASTBudgetExceeded bool + goParseWork chan struct{} + scripts map[string]*scriptParse + scriptBytes int + scriptCount int + scriptParse chan struct{} + diagnostics []core.Diagnostic + diagnosticSeen map[string]struct{} } -// The corpus is an optimization, not the owner of repository contents. Bound -// its retained source and Go AST working sets so a full scan can keep making -// progress under GOMEMLIMIT instead of pinning every file until report output. -// Once a budget is reached, later uncached work is skipped and surfaced as an -// informational scan diagnostic rather than repeatedly allocating overflow -// data in concurrent check sections. +// The corpus is an optimization, not the owner of repository contents, so its +// budgets bound what a scan *retains*, never what a scan analyses. Past a +// budget the corpus stops caching and serves the same work uncached: the bytes +// (or AST) are still produced and handed to the caller, they are just dropped +// once the caller is done with them. Retained memory therefore stays bounded +// exactly as it was when exhaustion meant "skip", while coverage is unaffected +// — the price of exceeding a budget is CPU and I/O, not missing findings. +// +// Getting this wrong is a security bug, not just a performance one: codeguard +// is a scanner, and a repository that floods the walk with generated files +// (a build cache, a bundled .next tree) would otherwise starve analysis of the +// real source and report fewer findings, with the only signal an informational +// diagnostic that the default text report does not even render. +// +// Budgets are expressed in RETAINED BYTES. A go/parser tree built with +// ParseComments costs far more than its input — measured at 9.0x source over +// this repository (711 files) and 8.4x over a 13.8k-file module cache — so the +// Go AST budget converts source length through goASTBytesPerSourceByte instead +// of counting source bytes directly. The previous constant counted source, so +// a nominal "64 MiB" Go AST budget admitted roughly 576 MiB of live AST. const maxCorpusReadBytes = 128 << 20 -const maxCorpusGoASTSourceBytes = 64 << 20 +const maxCorpusGoASTBytes = 256 << 20 const maxCorpusReadEntries = 50_000 const maxCorpusGoASTEntries = 25_000 +// goASTBytesPerSourceByte is the measured heap cost of a retained +// ParseComments AST (plus its FileSet) per byte of source. +const goASTBytesPerSourceByte = 9 + +// Floors keep a usable cache even under a very small GOMEMLIMIT; exceeding a +// budget only costs speed, so a small cache is always safe. +const minCorpusReadBytes = 8 << 20 +const minCorpusGoASTBytes = 16 << 20 + +// corpusRetentionBudget scales a default retention budget down to its share of +// an explicit GOMEMLIMIT. It never scales up: the defaults are chosen for a CI +// runner, and Go does not see a container memory limit unless GOMEMLIMIT is +// set, so inferring headroom from host memory would be wrong exactly where it +// matters. With no limit set, debug.SetMemoryLimit(-1) reports math.MaxInt64 +// and the default applies unchanged. +func corpusRetentionBudget(def int, minimum int, share int64) int { + limit := debug.SetMemoryLimit(-1) + if limit <= 0 || limit == math.MaxInt64 { + return def + } + scaled := limit / share + if scaled >= int64(def) { + return def + } + if scaled < int64(minimum) { + return minimum + } + return int(scaled) +} + // maxTreeSitterScanBytes bounds the source represented by retained script // trees during one scan. Parsing is also serialized because the pure-Go // runtime's transient heap is much larger than its input. @@ -121,12 +161,12 @@ func newFileCorpus() *fileCorpus { targets: map[string]*targetListing{}, maxFiles: maxScanFileCount, reads: map[string]*fileRead{}, - maxReadBytes: maxCorpusReadBytes, + maxReadBytes: corpusRetentionBudget(maxCorpusReadBytes, minCorpusReadBytes, 8), maxReadEntries: maxCorpusReadEntries, readWork: make(chan struct{}, 2), readFile: readCappedFile, asts: map[string]*goParse{}, - maxGoASTBytes: maxCorpusGoASTSourceBytes, + maxGoASTBytes: corpusRetentionBudget(maxCorpusGoASTBytes, minCorpusGoASTBytes, 4), maxGoASTEntries: maxCorpusGoASTEntries, goParseWork: make(chan struct{}, 2), scripts: map[string]*scriptParse{}, @@ -152,23 +192,26 @@ func (c *fileCorpus) list(root string, excludes []string, opts FileWalkOptions) var truncated bool entry.files, truncated, entry.err = walkFilesBounded(root, excludes, opts, includeAll, c.maxFiles) if truncated { - c.recordBudget("files", c.maxFiles) + c.recordWalkTruncation(root, c.maxFiles) } }) return entry.files, entry.err } -// read returns the bytes of root/rel, reading each file at most once per scan. +// read returns the bytes of root/rel, reading each file at most once per scan +// while the retention budget allows it to be cached. Once the budget is full +// the file is still read and returned, just not retained, so no caller ever +// loses content because the cache ran out of room. func (c *fileCorpus) read(root string, rel string) ([]byte, error) { key := filepath.Clean(root) + "\x00" + rel + path := filepath.Join(root, rel) c.mu.Lock() entry, ok := c.reads[key] if !ok { - if c.readExhausted || len(c.reads) >= c.maxReadEntries { - c.readExhausted = true - c.recordBudgetLocked("read_entries", c.maxReadEntries) + if len(c.reads) >= c.maxReadEntries { + c.noteReadBudgetLocked("read_entries", c.maxReadEntries) c.mu.Unlock() - return nil, errCorpusReadBudget + return c.readUncached(path) } entry = &fileRead{} c.reads[key] = entry @@ -176,20 +219,23 @@ func (c *fileCorpus) read(root string, rel string) ([]byte, error) { c.mu.Unlock() entry.once.Do(func() { - c.readWork <- struct{}{} - entry.data, entry.err = c.readFile(filepath.Join(root, rel)) - <-c.readWork + entry.data, entry.err = c.readUncached(path) c.mu.Lock() defer c.mu.Unlock() if entry.err == nil && c.readBytes+len(entry.data) <= c.maxReadBytes { c.readBytes += len(entry.data) return } + // The slot is dropped either because the read failed or because keeping + // these bytes would exceed the retention budget. Note the asymmetry + // with the pre-budget cache: entry.data is deliberately left intact, so + // this caller and anyone racing on the same slot still receive the file + // contents. Only the retention is declined. Dropping the slot rather + // than latching a scan-wide "exhausted" flag also keeps admission + // best-fit: a later, smaller file can still use the headroom that an + // oversized one could not. if entry.err == nil { - entry.data = nil - entry.err = errCorpusReadBudget - c.readExhausted = true - c.recordBudgetLocked("read_bytes", c.maxReadBytes) + c.noteReadBudgetLocked("read_bytes", c.maxReadBytes) } if c.reads[key] == entry { delete(c.reads, key) @@ -198,6 +244,15 @@ func (c *fileCorpus) read(root string, rel string) ([]byte, error) { return entry.data, entry.err } +// readUncached performs the file read itself. Reads stay serialized through +// readWork whether or not the result will be retained, so an overflowing scan +// cannot multiply the number of large buffers in flight at once. +func (c *fileCorpus) readUncached(path string) ([]byte, error) { + c.readWork <- struct{}{} + defer func() { <-c.readWork }() + return c.readFile(path) +} + // parseGo returns a shared, read-only Go AST for the given source. The cache key // includes the content hash so patched (diff-mode) content is reparsed rather // than serving a stale tree. Callers must treat the returned *ast.File and @@ -205,41 +260,67 @@ func (c *fileCorpus) read(root string, rel string) ([]byte, error) { // already does. func (c *fileCorpus) parseGo(path string, data []byte) (*token.FileSet, *ast.File, error) { key := path + "\x00" + hashBytes(data) + retained := len(data) * goASTBytesPerSourceByte c.mu.Lock() entry, ok := c.asts[key] if !ok { - if c.goASTExhausted || len(c.asts) >= c.maxGoASTEntries || c.goASTBytes+len(data) > c.maxGoASTBytes { - c.goASTExhausted = true - if len(c.asts) >= c.maxGoASTEntries { - c.recordBudgetLocked("go_ast_entries", c.maxGoASTEntries) - } else { - c.recordBudgetLocked("go_ast_bytes", c.maxGoASTBytes) - } + switch { + case len(c.asts) >= c.maxGoASTEntries: + c.noteGoASTBudgetLocked("go_ast_entries", c.maxGoASTEntries) + case c.goASTBytes+retained > c.maxGoASTBytes: + c.noteGoASTBudgetLocked("go_ast_bytes", c.maxGoASTBytes) + default: + entry = &goParse{} + c.asts[key] = entry + c.goASTBytes += retained + } + if entry == nil { + // Over budget: parse without retaining. The caller gets a real + // tree that is collected as soon as it stops using it, instead of + // an error that would have silently removed this file from every + // AST-driven rule. c.mu.Unlock() - return nil, nil, errCorpusGoASTBudget + return c.parseGoUncached(path, data) } - entry = &goParse{} - c.asts[key] = entry - c.goASTBytes += len(data) } c.mu.Unlock() entry.once.Do(func() { - c.goParseWork <- struct{}{} - defer func() { <-c.goParseWork }() - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, path, data, parser.ParseComments) - entry.fset, entry.file, entry.err = fset, file, err + entry.fset, entry.file, entry.err = c.parseGoUncached(path, data) }) return entry.fset, entry.file, entry.err } -func (c *fileCorpus) recordBudget(kind string, limit int) { - c.mu.Lock() - defer c.mu.Unlock() +// parseGoUncached runs the parse itself. It keeps the goParseWork gate so the +// transient parser working set stays bounded whether or not the tree is kept. +func (c *fileCorpus) parseGoUncached(path string, data []byte) (*token.FileSet, *ast.File, error) { + c.goParseWork <- struct{}{} + defer func() { <-c.goParseWork }() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, data, parser.ParseComments) + return fset, file, err +} + +// noteReadBudgetLocked and noteGoASTBudgetLocked record one overflow of a +// retention budget. They take the kind that actually tripped: the previous code +// latched a scan-wide "exhausted" flag and then reported every later miss as an +// entry-budget overflow, so a scan that filled the byte budget was told its +// 50,000-entry limit had been reached after caching only a few thousand files. +func (c *fileCorpus) noteReadBudgetLocked(kind string, limit int) { + c.readBudgetExceeded = true + c.recordBudgetLocked(kind, limit) +} + +func (c *fileCorpus) noteGoASTBudgetLocked(kind string, limit int) { + c.goASTBudgetExceeded = true c.recordBudgetLocked(kind, limit) } +// recordBudgetLocked emits at most one diagnostic per budget kind. The level is +// informational because for the retention budgets it genuinely is: analysis +// continues over every file, only the caching stops. The walk's own file-count +// truncation is the exception -- it really does drop files from the scan -- and +// recordWalkTruncation reports that at warn level instead. func (c *fileCorpus) recordBudgetLocked(kind string, limit int) { if _, exists := c.diagnosticSeen[kind]; exists { return @@ -249,14 +330,63 @@ func (c *fileCorpus) recordBudgetLocked(kind string, limit int) { ID: "scan.corpus-budget", Level: "info", Kind: "analysis", - Message: "repository analysis reached a bounded corpus budget; remaining uncached work was skipped", + Message: "repository analysis exceeded a bounded corpus cache budget; the remaining files were analysed without caching, which is slower but reports the same findings", Metadata: map[string]string{ - "budget": kind, - "limit": fmt.Sprintf("%d", limit), + "budget": kind, + "limit": fmt.Sprintf("%d", limit), + "retained_bytes": fmt.Sprintf("%d", c.retainedBytesForLocked(kind)), + "retained_entries": fmt.Sprintf("%d", c.retainedEntriesForLocked(kind)), + "degradation": "uncached", + "findings_affected": "no", }, }) } +// recordWalkTruncation reports the one corpus bound that still costs coverage: +// walkFilesBounded stops enumerating at maxScanFileCount, so files past the cap +// are never offered to any check. That is a real reduction in what was scanned, +// so it is reported as a warning with its own id rather than folded into the +// informational cache-budget diagnostic. +func (c *fileCorpus) recordWalkTruncation(root string, limit int) { + c.mu.Lock() + defer c.mu.Unlock() + if _, exists := c.diagnosticSeen["files"]; exists { + return + } + c.diagnosticSeen["files"] = struct{}{} + c.diagnostics = append(c.diagnostics, core.Diagnostic{ + ID: "scan.file-limit", + Level: "warn", + Kind: "analysis", + Message: fmt.Sprintf("repository walk stopped at the %d file limit; files beyond it were not scanned, so findings may be missing - narrow the target or add generated directories to exclude", limit), + Path: root, + // Operational, so the section reports warn rather than a clean pass it + // has not earned: the walk did not finish, and a reader who sees only + // the status must not read it as "nothing to report here". + Operational: true, + Metadata: map[string]string{ + "budget": "files", + "limit": fmt.Sprintf("%d", limit), + "degradation": "truncated", + "findings_affected": "yes", + }, + }) +} + +func (c *fileCorpus) retainedBytesForLocked(kind string) int { + if kind == "go_ast_bytes" || kind == "go_ast_entries" { + return c.goASTBytes + } + return c.readBytes +} + +func (c *fileCorpus) retainedEntriesForLocked(kind string) int { + if kind == "go_ast_bytes" || kind == "go_ast_entries" { + return len(c.asts) + } + return len(c.reads) +} + func (c *fileCorpus) takeDiagnostics() []core.Diagnostic { if c == nil { return nil diff --git a/internal/codeguard/runner/support/corpus_memory_test.go b/internal/codeguard/runner/support/corpus_memory_test.go index c500032a..ae12aae3 100644 --- a/internal/codeguard/runner/support/corpus_memory_test.go +++ b/internal/codeguard/runner/support/corpus_memory_test.go @@ -1,15 +1,27 @@ package support import ( - "errors" + "fmt" + "math" "os" "path/filepath" + "runtime/debug" + "slices" + "strings" "sync" "sync/atomic" "testing" + + "github.com/devr-tools/codeguard/internal/codeguard/core" ) -func TestFileCorpusDoesNotRetainReadsBeyondAggregateBudget(t *testing.T) { +// The corpus budgets bound what a scan RETAINS, never what it analyses. Each +// test below therefore asserts two things together: retention stays inside the +// budget, and the over-budget caller still receives real content. Asserting +// only the first is what allowed budget exhaustion to silently suppress +// findings. + +func TestFileCorpusServesReadsBeyondAggregateBudgetWithoutRetainingThem(t *testing.T) { root := t.TempDir() first := []byte("package first\n") second := []byte("package second\n") @@ -24,8 +36,13 @@ func TestFileCorpusDoesNotRetainReadsBeyondAggregateBudget(t *testing.T) { if _, err := corpus.read(root, "first.go"); err != nil { t.Fatalf("read first file: %v", err) } - if _, err := corpus.read(root, "second.go"); !errors.Is(err, errCorpusReadBudget) { - t.Fatalf("read second file error = %v, want corpus budget error", err) + + data, err := corpus.read(root, "second.go") + if err != nil { + t.Fatalf("over-budget read error = %v, want the file contents", err) + } + if string(data) != string(second) { + t.Fatalf("over-budget read = %q, want %q", data, second) } if corpus.readBytes > corpus.maxReadBytes { @@ -34,37 +51,102 @@ func TestFileCorpusDoesNotRetainReadsBeyondAggregateBudget(t *testing.T) { if got := len(corpus.reads); got != 1 { t.Fatalf("retained read entries = %d, want 1", got) } + if !corpus.readBudgetExceeded { + t.Fatal("read budget overflow was not recorded") + } + + // A repeat request is served again rather than turning into a cache miss + // that reports nothing. + again, err := corpus.read(root, "second.go") + if err != nil { + t.Fatalf("repeated over-budget read error = %v, want the file contents", err) + } + if string(again) != string(second) { + t.Fatalf("repeated over-budget read = %q, want %q", again, second) + } + if corpus.readBytes > corpus.maxReadBytes { + t.Fatalf("retained read bytes after repeat = %d, budget = %d", corpus.readBytes, corpus.maxReadBytes) + } +} - if _, err := corpus.read(root, "second.go"); !errors.Is(err, errCorpusReadBudget) { - t.Fatalf("repeated overflow read error = %v, want corpus budget error", err) +func TestFileCorpusAdmitsASmallerFileAfterAnOversizedOne(t *testing.T) { + root := t.TempDir() + big := make([]byte, 64) + small := []byte("x") + for name, data := range map[string][]byte{"big.txt": big, "small.txt": small} { + if err := os.WriteFile(filepath.Join(root, name), data, 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + corpus := newFileCorpus() + corpus.maxReadBytes = 8 + if _, err := corpus.read(root, "big.txt"); err != nil { + t.Fatalf("read oversized file: %v", err) + } + if got := len(corpus.reads); got != 0 { + t.Fatalf("retained read entries after oversized read = %d, want 0", got) + } + // Overflow must not latch the cache shut: the budget still has headroom and + // a file that fits is admitted. + if _, err := corpus.read(root, "small.txt"); err != nil { + t.Fatalf("read small file: %v", err) + } + if got := len(corpus.reads); got != 1 { + t.Fatalf("retained read entries = %d, want the small file cached", got) } } -func TestFileCorpusDoesNotRetainGoASTsBeyondAggregateBudget(t *testing.T) { +func TestFileCorpusServesGoASTsBeyondAggregateBudgetWithoutRetainingThem(t *testing.T) { corpus := newFileCorpus() first := []byte("package first\n") second := []byte("package second\n") - corpus.maxGoASTBytes = len(first) + corpus.maxGoASTBytes = len(first) * goASTBytesPerSourceByte if _, _, err := corpus.parseGo("first.go", first); err != nil { t.Fatalf("parse first file: %v", err) } - if _, _, err := corpus.parseGo("second.go", second); !errors.Is(err, errCorpusGoASTBudget) { - t.Fatalf("parse second file error = %v, want corpus budget error", err) + + fset, file, err := corpus.parseGo("second.go", second) + if err != nil { + t.Fatalf("over-budget parse error = %v, want a parsed file", err) + } + if fset == nil || file == nil { + t.Fatal("over-budget parse returned no tree") + } + if file.Name.Name != "second" { + t.Fatalf("over-budget parse package = %q, want %q", file.Name.Name, "second") } if corpus.goASTBytes > corpus.maxGoASTBytes { - t.Fatalf("retained Go AST source bytes = %d, budget = %d", corpus.goASTBytes, corpus.maxGoASTBytes) + t.Fatalf("retained Go AST bytes = %d, budget = %d", corpus.goASTBytes, corpus.maxGoASTBytes) } if got := len(corpus.asts); got != 1 { t.Fatalf("retained Go AST entries = %d, want 1", got) } + if !corpus.goASTBudgetExceeded { + t.Fatal("Go AST budget overflow was not recorded") + } +} + +func TestFileCorpusGoASTBudgetCountsRetainedTreeNotSource(t *testing.T) { + corpus := newFileCorpus() + source := []byte("package fixture\n") + // Budget room for the source bytes but not for the tree they expand into. + corpus.maxGoASTBytes = len(source) * (goASTBytesPerSourceByte - 1) + + if _, _, err := corpus.parseGo("fixture.go", source); err != nil { + t.Fatalf("parse fixture: %v", err) + } + if got := len(corpus.asts); got != 0 { + t.Fatalf("retained Go AST entries = %d, want 0: the tree does not fit the budget", got) + } } -func TestFileCorpusBoundsZeroByteReadEntries(t *testing.T) { +func TestFileCorpusServesReadsBeyondEntryBudget(t *testing.T) { root := t.TempDir() for _, name := range []string{"first", "second", "third"} { - if err := os.WriteFile(filepath.Join(root, name), nil, 0o600); err != nil { + if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0o600); err != nil { t.Fatalf("write %s: %v", name, err) } } @@ -76,15 +158,55 @@ func TestFileCorpusBoundsZeroByteReadEntries(t *testing.T) { t.Fatalf("read %s: %v", name, err) } } - if _, err := corpus.read(root, "third"); !errors.Is(err, errCorpusReadBudget) { - t.Fatalf("third read error = %v, want corpus budget error", err) + data, err := corpus.read(root, "third") + if err != nil { + t.Fatalf("over-entry-budget read error = %v, want the file contents", err) + } + if string(data) != "third" { + t.Fatalf("over-entry-budget read = %q, want %q", data, "third") } if got := len(corpus.reads); got != 2 { t.Fatalf("retained read entries = %d, want 2", got) } } -func TestFileCorpusBoundsFileListingsAndReportsDiagnostic(t *testing.T) { +func TestFileCorpusBudgetDiagnosticNamesTheBudgetThatTripped(t *testing.T) { + root := t.TempDir() + first := []byte("package first\n") + second := []byte("package second\n") + for name, data := range map[string][]byte{"first.go": first, "second.go": second} { + if err := os.WriteFile(filepath.Join(root, name), data, 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + corpus := newFileCorpus() + corpus.maxReadBytes = len(first) + for _, name := range []string{"first.go", "second.go"} { + if _, err := corpus.read(root, name); err != nil { + t.Fatalf("read %s: %v", name, err) + } + } + + diagnostics := corpus.takeDiagnostics() + if len(diagnostics) != 1 { + t.Fatalf("diagnostics = %#v, want exactly one", diagnostics) + } + diagnostic := diagnostics[0] + if diagnostic.ID != "scan.corpus-budget" { + t.Fatalf("diagnostic id = %q, want scan.corpus-budget", diagnostic.ID) + } + // The byte budget is what ran out; reporting the (untouched) entry budget + // sent operators looking for 50,000 cached files that were never there. + if got := diagnostic.Metadata["budget"]; got != "read_bytes" { + t.Fatalf("diagnostic budget = %q, want read_bytes", got) + } + if got := diagnostic.Metadata["findings_affected"]; got != "no" { + t.Fatalf("diagnostic findings_affected = %q, want no", got) + } +} + +func TestFileCorpusBoundsFileListingsAndWarnsThatFilesWereNotScanned(t *testing.T) { root := t.TempDir() for _, name := range []string{"first.go", "second.go", "third.go"} { if err := os.WriteFile(filepath.Join(root, name), []byte("package fixture\n"), 0o600); err != nil { @@ -102,37 +224,52 @@ func TestFileCorpusBoundsFileListingsAndReportsDiagnostic(t *testing.T) { t.Fatalf("listed files = %d, want 2", got) } section := FinalizeSection(Context{corpus: corpus}, "quality", "Code Quality", nil) - if len(section.Diagnostics) != 1 || section.Diagnostics[0].ID != "scan.corpus-budget" { - t.Fatalf("diagnostics = %#v, want one corpus budget diagnostic", section.Diagnostics) + if len(section.Diagnostics) != 1 || section.Diagnostics[0].ID != "scan.file-limit" { + t.Fatalf("diagnostics = %#v, want one file-limit diagnostic", section.Diagnostics) + } + // Walk truncation is the one corpus bound that really does drop files from + // the scan, so unlike the cache budgets it is not informational. + if got := section.Diagnostics[0].Level; got != "warn" { + t.Fatalf("file-limit diagnostic level = %q, want warn", got) + } + if got := section.Diagnostics[0].Metadata["findings_affected"]; got != "yes" { + t.Fatalf("file-limit findings_affected = %q, want yes", got) } if again := corpus.takeDiagnostics(); len(again) != 0 { t.Fatalf("diagnostic was emitted more than once: %#v", again) } } -func TestFileCorpusRejectsConcurrentGoParsesBeyondBudget(t *testing.T) { +func TestFileCorpusServesConcurrentOverBudgetGoParses(t *testing.T) { corpus := newFileCorpus() corpus.maxGoASTBytes = 1 data := []byte("package fixture\n") const callers = 32 var wg sync.WaitGroup + var parsed atomic.Int32 errs := make(chan error, callers) for i := 0; i < callers; i++ { wg.Add(1) go func() { defer wg.Done() - _, _, err := corpus.parseGo("large.go", data) + _, file, err := corpus.parseGo("large.go", data) + if err == nil && file != nil { + parsed.Add(1) + } errs <- err }() } wg.Wait() close(errs) for err := range errs { - if !errors.Is(err, errCorpusGoASTBudget) { - t.Fatalf("parse error = %v, want corpus budget error", err) + if err != nil { + t.Fatalf("over-budget parse error = %v, want a parsed file", err) } } + if got := parsed.Load(); got != callers { + t.Fatalf("callers that received a tree = %d, want %d", got, callers) + } if got := len(corpus.asts); got != 0 { t.Fatalf("retained Go AST entries = %d, want 0", got) } @@ -155,13 +292,15 @@ func TestFileCorpusSingleflightsConcurrentOverflowReads(t *testing.T) { var done sync.WaitGroup entered.Add(callers) done.Add(callers) + results := make(chan string, callers) errs := make(chan error, callers) for i := 0; i < callers; i++ { go func() { defer done.Done() <-start entered.Done() - _, err := corpus.read("root", "large.go") + data, err := corpus.read("root", "large.go") + results <- string(data) errs <- err }() } @@ -170,14 +309,25 @@ func TestFileCorpusSingleflightsConcurrentOverflowReads(t *testing.T) { close(releaseRead) done.Wait() close(errs) + close(results) for err := range errs { - if !errors.Is(err, errCorpusReadBudget) { - t.Fatalf("read error = %v, want corpus budget error", err) + if err != nil { + t.Fatalf("over-budget read error = %v, want the file contents", err) + } + } + for data := range results { + if data != "too large" { + t.Fatalf("over-budget read = %q, want the file contents", data) } } - if got := readCalls.Load(); got != 1 { - t.Fatalf("overflow file reads = %d, want one singleflight read", got) + // Racing callers still collapse onto one read; only callers that arrive + // after the slot is dropped pay for their own. + if got := readCalls.Load(); got < 1 { + t.Fatalf("overflow file reads = %d, want at least one", got) + } + if got := len(corpus.reads); got != 0 { + t.Fatalf("retained read entries = %d, want 0", got) } } @@ -192,10 +342,85 @@ func TestFileCorpusBoundsGoASTEntriesIndependentOfBytes(t *testing.T) { t.Fatalf("parse %s: %v", name, err) } } - if _, _, err := corpus.parseGo("third.go", data); !errors.Is(err, errCorpusGoASTBudget) { - t.Fatalf("third parse error = %v, want corpus budget error", err) + if _, file, err := corpus.parseGo("third.go", data); err != nil || file == nil { + t.Fatalf("third parse = (%v, %v), want a parsed file", file, err) } if got := len(corpus.asts); got != 2 { t.Fatalf("retained Go AST entries = %d, want 2", got) } } + +func TestCorpusRetentionBudgetScalesDownWithGOMEMLIMITOnly(t *testing.T) { + previous := debug.SetMemoryLimit(-1) + t.Cleanup(func() { debug.SetMemoryLimit(previous) }) + + debug.SetMemoryLimit(math.MaxInt64) + if got := corpusRetentionBudget(maxCorpusReadBytes, minCorpusReadBytes, 8); got != maxCorpusReadBytes { + t.Fatalf("read budget with no memory limit = %d, want the default %d", got, maxCorpusReadBytes) + } + + // A limit with room to spare must not inflate the default: Go cannot see a + // container limit that was not expressed as GOMEMLIMIT, so scaling up would + // be wrong exactly where memory is tightest. + debug.SetMemoryLimit(64 << 30) + if got := corpusRetentionBudget(maxCorpusReadBytes, minCorpusReadBytes, 8); got != maxCorpusReadBytes { + t.Fatalf("read budget under a large limit = %d, want the default %d", got, maxCorpusReadBytes) + } + + debug.SetMemoryLimit(512 << 20) + if got, want := corpusRetentionBudget(maxCorpusReadBytes, minCorpusReadBytes, 8), 64<<20; got != want { + t.Fatalf("read budget under a 512 MiB limit = %d, want %d", got, want) + } + + debug.SetMemoryLimit(16 << 20) + if got := corpusRetentionBudget(maxCorpusReadBytes, minCorpusReadBytes, 8); got != minCorpusReadBytes { + t.Fatalf("read budget under a tiny limit = %d, want the floor %d", got, minCorpusReadBytes) + } +} + +// Cross-file passes (clone detection, import graphs) build their state from +// VisitTargetFiles, which skips any file whose read returns an error. While an +// exhausted budget produced that error, the document stream those passes saw +// depended on which files won a scheduling-dependent race for the remaining +// budget: concurrent sections share one corpus, so the same repository could +// hand the clone detector a different document set on every run and report +// different duplicate-code findings. Serving over-budget reads keeps the +// stream equal to the walk, which is sorted and single-threaded, so degraded +// scans stay reproducible instead of merely being wrong. +func TestVisitTargetFilesSeesEveryFileWhenTheReadBudgetIsFull(t *testing.T) { + root := t.TempDir() + want := make([]string, 0, 40) + for i := range 40 { + name := fmt.Sprintf("file%02d.txt", i) + if err := os.WriteFile(filepath.Join(root, name), []byte(strings.Repeat("x", 512)), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + want = append(want, name) + } + + visitOrder := func(budget int) []string { + corpus := newFileCorpus() + corpus.maxReadBytes = budget + sc := Context{corpus: corpus} + var seen []string + VisitTargetFiles(sc, core.TargetConfig{Name: "repo", Path: root}, func(string) bool { return true }, + func(rel string, data []byte) { + if len(data) == 0 { + t.Errorf("visited %s with no content", rel) + } + seen = append(seen, rel) + }) + return seen + } + + // A budget large enough for everything and one that runs out after a + // couple of files must produce the same stream. + full := visitOrder(1 << 20) + starved := visitOrder(1024) + if !slices.Equal(full, want) { + t.Fatalf("unbudgeted visit order = %v, want %v", full, want) + } + if !slices.Equal(starved, want) { + t.Fatalf("budget-starved visit skipped files: got %d of %d", len(starved), len(want)) + } +} diff --git a/tests/report/diagnostic_visibility_test.go b/tests/report/diagnostic_visibility_test.go new file mode 100644 index 00000000..a17352bc --- /dev/null +++ b/tests/report/diagnostic_visibility_test.go @@ -0,0 +1,81 @@ +package report_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/devr-tools/codeguard/internal/codeguard/core" + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func diagnosticReport(diagnostics ...core.Diagnostic) codeguard.Report { + return codeguard.Report{ + Sections: []codeguard.SectionResult{{ + ID: "quality", + Name: "Code Quality", + Status: codeguard.StatusWarn, + Diagnostics: diagnostics, + }}, + } +} + +func renderDiagnosticText(t *testing.T, report codeguard.Report) string { + t.Helper() + var buf bytes.Buffer + if err := codeguard.WriteReport(&buf, report, "text"); err != nil { + t.Fatalf("write text report: %v", err) + } + return buf.String() +} + +// A diagnostic that says the scan did not cover everything has to reach the +// operator in the format they actually read. The text report used to drop every +// diagnostic, so "the walk stopped early" or "govulncheck could not run" was +// visible only in JSON or SARIF output. +func TestTextReportShowsDiagnosticsThatSayTheScanWasIncomplete(t *testing.T) { + output := renderDiagnosticText(t, diagnosticReport(core.Diagnostic{ + ID: "scan.file-limit", + Level: "warn", + Kind: "analysis", + Message: "repository walk stopped at the 100000 file limit", + Operational: true, + })) + + if !strings.Contains(output, "scan.file-limit") { + t.Fatalf("text report omits the diagnostic id:\n%s", output) + } + if !strings.Contains(output, "repository walk stopped at the 100000 file limit") { + t.Fatalf("text report omits the diagnostic message:\n%s", output) + } +} + +func TestTextReportShowsFailingOperationalDiagnostics(t *testing.T) { + output := renderDiagnosticText(t, diagnosticReport(core.Diagnostic{ + ID: "scan.govulncheck.module-unavailable", + Level: "fail", + Kind: "infrastructure", + Message: "no Go module is available for govulncheck", + Operational: true, + })) + + if !strings.Contains(output, "scan.govulncheck.module-unavailable") { + t.Fatalf("text report omits a failing operational diagnostic:\n%s", output) + } +} + +// Informational diagnostics stay out of the default report. They are routine +// per-finding classifications and status lines, and a scan emits enough of them +// to bury the findings they sit next to. +func TestTextReportKeepsInformationalDiagnosticsOutOfTheDefaultOutput(t *testing.T) { + output := renderDiagnosticText(t, diagnosticReport(core.Diagnostic{ + ID: "scan.corpus-budget", + Level: "info", + Kind: "analysis", + Message: "repository analysis exceeded a bounded corpus cache budget", + })) + + if strings.Contains(output, "scan.corpus-budget") { + t.Fatalf("text report should not render informational diagnostics:\n%s", output) + } +} From 394550bb2d9599d0b9cc6d910a26009e3f5a427d Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 22 Oct 2026 11:40:34 -0400 Subject: [PATCH 11/16] fix(quality): make clone and data-clump selection deterministic Two independent findings-order bugs, both from iterating a Go map and then making a choice that depends on the order. Go randomizes map iteration, so the same input produced different findings between runs. collectCloneCandidates walked the window-hash index as a map and stopped early at maxClonePairComparisons/maxCloneCandidates, so the iteration order decided *which* hashes were examined before the cap. sortCloneCandidates orders the result but cannot recover candidates that were never collected. It now walks the hashes in ascending order; any fixed order is sufficient and the key order is the cheapest available. dataClumpFindings returned the first parameter group with three or more occurrences straight out of a map range, so a file with several qualifying groups reported a different group, line and message each run - and could report a weaker clump than one it had already passed over. It now reports the clump appearing in the most functions, tie-broken by group key so the ordering is total. Measured on a 120-file synthetic repo that saturates the clone cap, five runs each: before, three distinct finding sets (all capped at 2000 duplicate-code findings, differing in which); after, one. On a file with a four-function and a three-function clump, five runs each: before, the weaker clump reported in one run and the stronger in four; after, the stronger every time. Also drops roleSuffixPattern and identifierWordSplitPattern, left unused by this branch's merge of the regex-hoisting and rule-narrowing work, which golangci-lint's unused check flagged. Verified: go test ./... 29 packages ok; golangci-lint run ./... 0 issues; gofmt clean; make codeguard-ci 2 pass / 4 warn / 0 fail; szr and guardhouse full-tree scans byte-identical across three runs each. Co-Authored-By: Claude Opus 5 (1M context) --- .../codeguard/checks/quality/quality_clone.go | 22 +++++++++++++++++-- .../quality_precision_workstreams_cd.go | 2 -- .../checks/quality/quality_smells.go | 21 ++++++++++++++---- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/internal/codeguard/checks/quality/quality_clone.go b/internal/codeguard/checks/quality/quality_clone.go index 621217f8..0f34bdd3 100644 --- a/internal/codeguard/checks/quality/quality_clone.go +++ b/internal/codeguard/checks/quality/quality_clone.go @@ -209,14 +209,32 @@ func collectCloneCandidates(index cloneIndex, docs []cloneDocument, threshold in // into a scan of just the (usually tiny) bucket for that file pair. byPair := make(map[[2]int][]cloneCandidate) comparisons := 0 - for _, occurrences := range index { - if addClonePairs(byPair, occurrences, docs, threshold, &comparisons) { + // Walk the index in a fixed order. Collection stops early once + // maxClonePairComparisons or maxCloneCandidates is reached, so the + // iteration order decides *which* window hashes are examined before the + // cap - and Go randomizes map iteration, which made the surviving + // candidate set differ between runs on the same input. sortCloneCandidates + // orders the result but cannot recover what was never collected. Any fixed + // order fixes this; the key order is the cheapest one available. + for _, hash := range sortedCloneIndexKeys(index) { + if addClonePairs(byPair, index[hash], docs, threshold, &comparisons) { break } } return flattenCloneCandidates(byPair) } +// sortedCloneIndexKeys returns the index's window hashes in ascending order so +// candidate collection visits them deterministically. +func sortedCloneIndexKeys(index cloneIndex) []uint64 { + keys := make([]uint64, 0, len(index)) + for hash := range index { + keys = append(keys, hash) + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + return keys +} + // addClonePairs reports whether candidate collection has reached a safety // limit and should stop. func addClonePairs(byPair map[[2]int][]cloneCandidate, occurrences []cloneOccurrence, docs []cloneDocument, threshold int, comparisons *int) bool { diff --git a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go index 09a3cefe..cff65008 100644 --- a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go +++ b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go @@ -32,7 +32,6 @@ const ( var ( readCallPattern = regexp.MustCompile(`(^|[.>:\-_])(?i:count|fetch|find|get|list|load|lookup|query|read|select|search)([A-Z_:\-.]|$)`) identifierTokenPattern = regexp.MustCompile(`[A-Za-z_$][A-Za-z0-9_$]*`) - roleSuffixPattern = regexp.MustCompile(`(?i)(manager|helper|util|utils|service|processor)$`) durationNamePattern = regexp.MustCompile(`(?i)(timeout|duration|delay|interval|ttl|latency|elapsed|expiry|expiration|retention)`) sizeNamePattern = regexp.MustCompile(`(?i)(size|limit|length|capacity|bytes?|mb|kb|gb)`) moneyNamePattern = regexp.MustCompile(`(?i)(amount|price|cost|fee|total|subtotal|balance|money)`) @@ -49,7 +48,6 @@ var ( paramMutationPattern = regexp.MustCompile(`\b([A-Za-z_$][\w$]*)\s*(?:\.|->|\[)`) returnLinePattern = regexp.MustCompile(`(?m)^\s*return(?:\s+([^;\n]+))?`) partialReturnPattern = regexp.MustCompile(`(?i)\breturn\s+[^;\n,]+,\s*(err|error)\b|\breturn\s+\{[^}\n]*(data|result|value)[^}\n]*(err|error)[^}\n]*\}`) - identifierWordSplitPattern = regexp.MustCompile(`[_\-\s]+`) ) func additionalPrecisionFunctionFindings(env support.Context, file string, fn precisionFunction) []core.Finding { diff --git a/internal/codeguard/checks/quality/quality_smells.go b/internal/codeguard/checks/quality/quality_smells.go index 1ea40c2c..3bda4486 100644 --- a/internal/codeguard/checks/quality/quality_smells.go +++ b/internal/codeguard/checks/quality/quality_smells.go @@ -647,13 +647,26 @@ func dataClumpFindings(env support.Context, file string, functions []structuralF } groups[key] = append(groups[key], occurrence{line: fn.StartLine, fn: fn.Name}) } + // One finding per file, so when several parameter groups qualify the + // choice must not come from map iteration order - that reported a + // different group, line and message between runs on the same file. Report + // the worst clump (most functions), tie-broken by group key so the result + // is total. + best := "" for key, occurrences := range groups { - if len(occurrences) >= 3 { - return []core.Finding{precisionWarnFinding(env, smellDataClumpRuleID, file, occurrences[2].line, - fmt.Sprintf("parameter group [%s] appears in %d functions; extract a value object/options type", key, len(occurrences)), - core.ConfidenceHigh)} + if len(occurrences) < 3 { + continue + } + if best == "" || len(occurrences) > len(groups[best]) || (len(occurrences) == len(groups[best]) && key < best) { + best = key } } + if best != "" { + occurrences := groups[best] + return []core.Finding{precisionWarnFinding(env, smellDataClumpRuleID, file, occurrences[2].line, + fmt.Sprintf("parameter group [%s] appears in %d functions; extract a value object/options type", best, len(occurrences)), + core.ConfidenceHigh)} + } return nil } From 47a4cd13bcdae801e589e2f583e0a3f4b0a96e7b Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 22 Oct 2026 12:49:31 -0400 Subject: [PATCH 12/16] fix(rules): add the five orphan performance rules to the catalog performance.go.nested-loop-scan, performance.go.slice-membership-in-loop, performance.go.lock-held-across-blocking-call, performance.cpp.range-for-copy and performance.cpp.flush-in-loop emitted findings with no catalog entry, so the scanner printed a finding and `codeguard explain` then answered "unknown rule". They also carried no metadata, no fix template, no OWASP mapping and no SARIF rule descriptor. All five were already documented in docs/checks.md and already carried weights in performance_score.go; only the catalog was missing them. Descriptions follow the existing docs text, including each rule's documented narrowness, so `explain` tells a reader the same thing the reference does. Catalog-to-implementation was already enforced; nothing enforced the reverse, which is how five rules shipped this way. tests/rules adds that invariant: it walks checks/** for rule-id literals and fails on any that rules.Catalog() does not resolve. Diagnostics are excluded structurally - they are deliberately separate from findings, never eligible for baselines, and correctly have no catalog entry - by matching core.Diagnostic literals rather than by an allowlist that would need updating for each new diagnostic. Prefix fragments used with strings.HasPrefix are excluded by their trailing separator. Confirmed non-vacuous by removing one entry and seeing the test name it. Co-Authored-By: Claude Opus 5 (1M context) --- .../codeguard/rules/catalog_performance.go | 50 ++++++++ tests/rules/catalog_emission_coverage_test.go | 120 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 tests/rules/catalog_emission_coverage_test.go diff --git a/internal/codeguard/rules/catalog_performance.go b/internal/codeguard/rules/catalog_performance.go index c2da2e4e..9e93c984 100644 --- a/internal/codeguard/rules/catalog_performance.go +++ b/internal/codeguard/rules/catalog_performance.go @@ -158,6 +158,56 @@ var performanceCatalog = map[string]core.RuleMetadata{ Description: "Warns when std::thread::sleep or thread::sleep runs inside a Rust loop body, which usually marks a poll that wants a signal, timer primitive, or bounded backoff helper (performance_rules.detect_sleep_in_loop).", HowToFix: "Replace the polling sleep with a channel/Condvar signal, a timer primitive, or a bounded backoff helper.", }, + "performance.go.nested-loop-scan": { + ID: "performance.go.nested-loop-scan", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelGoNative, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageGo), + Title: "Nested loop membership scan", + Description: "Warns when nested range loops compare an outer loop variable directly against an inner loop variable, which scans the inner collection linearly on every outer iteration (performance_rules.detect_hot_path_patterns). Intentionally narrow: it does not infer indexed lookups, helper wrappers, or non-equality membership checks.", + HowToFix: "If this is a membership test on hot data, precompute a map or set for the inner collection and look up the outer value instead.", + }, + "performance.go.slice-membership-in-loop": { + ID: "performance.go.slice-membership-in-loop", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelGoNative, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageGo), + Title: "Slice membership scan in loop", + Description: "Warns when slices.Contains, ContainsFunc, Index, or IndexFunc is called inside a loop, so the slice is scanned linearly each iteration (performance_rules.detect_hot_path_patterns). It does not prove the slice is large enough to matter, so treat it as a hot-path review cue rather than a universal ban.", + HowToFix: "Precompute a map or set outside the loop, or otherwise hoist the lookup if this path is hot.", + }, + "performance.go.lock-held-across-blocking-call": { + ID: "performance.go.lock-held-across-blocking-call", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelGoNative, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageGo), + Title: "Lock held across blocking call", + Description: "Warns when a sync.Mutex or sync.RWMutex is held across an obvious blocking call - synchronous file I/O, time.Sleep, package-level HTTP calls, SQL-style Query/Exec methods, or client.Do - which serializes callers and amplifies tail latency (performance_rules.detect_hot_path_patterns). The check is lexical rather than flow-sensitive, so treat it as a review cue when a branch unlocks before the call.", + HowToFix: "Copy the state the call needs, release the lock, then make the blocking call.", + }, + "performance.cpp.range-for-copy": { + ID: "performance.cpp.range-for-copy", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ range-for copies each element", + Description: "Warns when a C++ range-for binds each element by value, copying it every iteration (performance_rules.detect_hot_path_patterns). Covers structured bindings by value and explicit by-value iteration over heavy standard-library types such as std::string, std::vector, and map/set pair types; plain auto value : items is intentionally out of scope.", + HowToFix: "Bind by const reference (const auto& or const T&) unless a per-iteration copy is intentional.", + }, + "performance.cpp.flush-in-loop": { + ID: "performance.cpp.flush-in-loop", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), + Title: "C++ stream flush inside loop", + Description: "Warns when std::endl or std::flush is written inside a C++ loop, defeating stream buffering and often dominating hot-path latency (performance_rules.detect_hot_path_patterns). Ordinary newline writes such as '\n' are not flagged.", + HowToFix: "Write a newline and flush once after the loop, unless immediate delivery is required.", + }, "performance.cpp.sleep-in-loop": { ID: "performance.cpp.sleep-in-loop", Section: "Performance", diff --git a/tests/rules/catalog_emission_coverage_test.go b/tests/rules/catalog_emission_coverage_test.go new file mode 100644 index 00000000..0711f4b1 --- /dev/null +++ b/tests/rules/catalog_emission_coverage_test.go @@ -0,0 +1,120 @@ +// Package rules_test asserts catalog invariants that are otherwise unenforced. +package rules_test + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "github.com/devr-tools/codeguard/internal/codeguard/rules" +) + +// ruleIDLiteral matches a quoted rule id in check source. Rule ids are +// lowercase, dot-separated, and always carry at least one dot. +var ruleIDLiteral = regexp.MustCompile(`"([a-z][a-z0-9]*(?:\.[a-z0-9][a-z0-9_-]*)+)"`) + +// diagnosticIDLiteral finds ids attached to a core.Diagnostic. Diagnostics are +// deliberately separate from findings - they describe scanner operation, are +// never eligible for baselines, and correctly have no catalog entry - so they +// must not be held to the catalog invariant. Detected structurally rather than +// by an allowlist, so a new diagnostic does not need this test updated. +var diagnosticIDLiteral = regexp.MustCompile(`core\.Diagnostic\{(?:[^{}]|\n)*?ID:\s*"([^"]+)"`) + +// knownSectionPrefixes bounds the scan to strings that are actually rule ids. +// A literal like "config.yaml" matches the shape above but is not a rule. +var knownSectionPrefixes = []string{ + "quality.", "performance.", "design.", "security.", "prompts.", "ci.", + "supply_chain.", "context.", "contracts.", "data.", "delivery.", + "observability.", "operations.", "reliability.", "change.", "refactor.", + "defensive.", "error.", "function.", "naming.", "smell.", "maintainability.", +} + +// TestEveryEmittedRuleIDIsInTheCatalog is the invariant that was missing when +// five performance rules shipped emitting findings with no catalog entry: the +// scanner printed them, then `codeguard explain` answered "unknown rule" and +// they carried no metadata, fix template, OWASP mapping, or SARIF descriptor. +// Catalog-to-implementation was already enforced; this is the reverse. +func TestEveryEmittedRuleIDIsInTheCatalog(t *testing.T) { + catalog := rules.Catalog() + root := filepath.Join("..", "..", "internal", "codeguard", "checks") + + emitted := map[string][]string{} + diagnostics := map[string]bool{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + data, readErr := os.ReadFile(path) //nolint:gosec // test walks a fixed in-repo tree + if readErr != nil { + return readErr + } + source := string(data) + for _, match := range diagnosticIDLiteral.FindAllStringSubmatch(source, -1) { + diagnostics[match[1]] = true + } + for _, match := range ruleIDLiteral.FindAllStringSubmatch(source, -1) { + id := match[1] + if !hasKnownSectionPrefix(id) { + continue + } + // A trailing separator means the literal is a prefix fragment used + // with strings.HasPrefix, not a rule id in its own right. + if strings.HasSuffix(id, "-") || strings.HasSuffix(id, ".") { + continue + } + emitted[id] = append(emitted[id], path) + } + return nil + }) + if err != nil { + t.Fatalf("walk checks tree: %v", err) + } + if len(emitted) == 0 { + t.Fatal("found no rule id literals under checks/; the scan is not working") + } + + missing := make([]string, 0) + for id := range emitted { + if diagnostics[id] { + continue + } + if _, ok := catalog[id]; !ok { + missing = append(missing, id) + } + } + sort.Strings(missing) + for _, id := range missing { + t.Errorf("rule id %q is referenced in %s but has no entry in rules.Catalog(); `codeguard explain %s` would fail for a finding the scanner just printed", + id, strings.Join(dedupe(emitted[id]), ", "), id) + } +} + +func hasKnownSectionPrefix(id string) bool { + for _, prefix := range knownSectionPrefixes { + if strings.HasPrefix(id, prefix) { + return true + } + } + return false +} + +func dedupe(values []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(values)) + for _, value := range values { + if seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + sort.Strings(out) + return out +} From e13daf6743b85a3ad8b179ea526ff502440d67cc Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 22 Oct 2026 14:05:04 -0400 Subject: [PATCH 13/16] fix(design): resolve the SDK facade per repository instead of hardcoding pkg/codeguard `support.IsSDKFacadeFile` was `strings.HasPrefix(path, "pkg/codeguard/")`, so the exemption that keeps `design.service-import-internal` (fail) quiet applied to exactly one repository on earth: this one. Two fixtures identical but for the directory name, both doing the legal, idiomatic same-module `pkg/` -> `internal/` import, measured before this change: pkg/myapp -> design.service-import-internal (fail) pkg/codeguard -> no findings `design.cmd-through-internal-cli` (fail) was the same hardcoding in mirror image: it required `/pkg/codeguard` in the *imported* path, so it could only ever fire inside this tree and shipped as a guaranteed no-op for every other user. The facade is now a property of the scanned repository: - `runner/support.SDKFacadeResolver` resolves one `SDKFacade` per target, memoized for the scan (sections run concurrently). It matches both files (target-relative directory prefix) and Go import paths (module-qualified from go.mod, with the old path-segment behaviour as fallback). - New config key `design_rules.sdk_facade_paths` names the facade package directories explicitly; a glob tail is trimmed, absolute/escaping paths are rejected. - Unset, the facade is discovered: every directory directly under `pkg/`. Permissive on purpose - within a single Go module `pkg/` importing `/internal/...` is legal, idiomatic Go, so treating a public package as a non-facade costs a fail-level finding on correct code. - `design.service-import-cmd` loses the facade exemption entirely: a public package importing `cmd/` is a layering inversion under any layout. - `checks/change` dropped its `IsSDKFacadeFile` call; it was a strict subset of the `IsPublicPackageFile` test on the next line. Measured on frozen snapshots with the four layering toggles on: merger 513 -> 506 findings (7 fail-level false positives removed) cleanr 1578 -> 1578 (no pkg/ layout) slop 1348 -> 1346 (3 fail false positives removed, 1 genuine design.cmd-through-internal-cli gained: cmd/slop/main.go imports github.com/devr-tools/slop/pkg/slop) This repository's own self-scan still passes; `.codeguard/codeguard.yaml` now states `sdk_facade_paths: [pkg/codeguard]` so the rule stays strict for any future `pkg/` package that is not the SDK facade. Co-Authored-By: Claude Opus 5 (1M context) --- .codeguard/codeguard.yaml | 6 + docs/architecture.md | 2 +- docs/checks.md | 40 +++- internal/codeguard/checks/change/change.go | 6 +- internal/codeguard/checks/design/design_go.go | 39 ++-- internal/codeguard/checks/support/context.go | 50 ++++- .../codeguard/config/defaults_base_rules.go | 16 +- internal/codeguard/config/defaults_rules.go | 4 + internal/codeguard/config/validate_design.go | 27 +++ internal/codeguard/core/config_rule_types.go | 59 +++--- internal/codeguard/runner/checks/checks.go | 7 +- .../codeguard/runner/support/sdk_facade.go | 178 ++++++++++++++++++ .../codeguard/runner/support/utils_match.go | 5 - tests/checks/design_sdk_facade_test.go | 140 ++++++++++++++ tests/checks/design_test.go | 14 +- tests/support/sdk_facade_test.go | 120 ++++++++++++ 16 files changed, 649 insertions(+), 64 deletions(-) create mode 100644 internal/codeguard/runner/support/sdk_facade.go create mode 100644 tests/checks/design_sdk_facade_test.go create mode 100644 tests/support/sdk_facade_test.go diff --git a/.codeguard/codeguard.yaml b/.codeguard/codeguard.yaml index 1f6214d7..e8eea0e0 100644 --- a/.codeguard/codeguard.yaml +++ b/.codeguard/codeguard.yaml @@ -62,6 +62,12 @@ checks: forbid_internal_import_cmd: true forbid_service_import_internal: true forbid_service_import_cmd: true + # Which package is the facade is a property of the repository being + # scanned, not a literal in codeguard's source. Unset, it is discovered + # (every directory under pkg/); stating it keeps the layering rule strict + # for any future pkg/ package that is not the SDK facade. + sdk_facade_paths: + - pkg/codeguard performance_rules: hot_package_importer_threshold: 32 rebuild_amplifier_threshold: 40 diff --git a/docs/architecture.md b/docs/architecture.md index 783d7161..67a51163 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,7 +41,7 @@ This keeps the runner tree organized with the same directory-first style as `int ## Current check behavior - `quality` enforces parseability, `gofmt` cleanliness, AST-derived maintainability thresholds, cyclomatic complexity, and dependency-direction checks -- `design` enforces configurable layer boundaries between `cmd/`, `internal/`, and reusable `pkg/codeguard/` packages, plus principle checks for separation of concerns, clean-code naming, and SOLID-oriented heuristics +- `design` enforces configurable layer boundaries between `cmd/`, `internal/`, and the scanned repository's public `pkg/` facade (`design_rules.sdk_facade_paths`, or discovered from the layout), plus principle checks for separation of concerns, clean-code naming, and SOLID-oriented heuristics - `prompts` discovers prompt-oriented files and enforces configurable checks for secret interpolation and unsafe instruction patterns - `ci` enforces configurable repository policy for workflow directories, workflow files, workflow contents, release files, and automation entrypoints - `security` runs local heuristic scanning first and can optionally run `govulncheck` in `off`, `auto`, or `required` mode with per-vulnerability findings when output is available diff --git a/docs/checks.md b/docs/checks.md index 3755606a..27052339 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -1606,6 +1606,7 @@ Config keys: "forbid_internal_import_cmd": true, "forbid_service_import_internal": true, "forbid_service_import_cmd": true, + "sdk_facade_paths": ["pkg/myapp"], "max_decls_per_file": 12, "max_methods_per_type": 8, "max_interface_methods": 5, @@ -1627,14 +1628,45 @@ Defaults: the four Go package-layering toggles (`require_cmd_through_internal_cli`, `forbid_internal_import_cmd`, `forbid_service_import_internal`, `forbid_service_import_cmd`) are **off by default**. They describe one specific repository layout — `cmd/` delegating to -`internal/cli`, and a `pkg/` facade that must not reach into `internal/` or -`cmd/` — and report at fail level, and a `pkg/` package importing its own -module's `internal/` is ordinary Go. Set them to `true` to enforce that layout; -the config `codeguard init` writes does, and so does this repository's own +`internal/cli`, and a `pkg/` facade that must not reach into `cmd/` — and +report at fail level, and a `pkg/` package importing its own module's +`internal/` is ordinary Go. Set them to `true` to enforce that layout; the +config `codeguard init` writes does, and so does this repository's own self-scan config. The remaining design rules (import cycles, god modules, high-impact changes, the numeric thresholds, generic package names) stay on by default. +### `sdk_facade_paths` — which package is your public facade + +Two of the layering rules need to know which package is the repository's +published API surface — the one public package whose job is to wrap the +module's internals: + +- `design.service-import-internal` does **not** fire inside the facade. A + facade importing `internal/` is the point of a facade. +- `design.cmd-through-internal-cli` fires when a `cmd/` package imports the + facade instead of going through `internal/cli`. + +`sdk_facade_paths` is a list of target-root-relative package directories, for +example `["pkg/myapp"]`. A glob tail is accepted and trimmed, so `"pkg/myapp"` +and `"pkg/myapp/**"` mean the same thing; absolute paths and `..` are rejected. + +**When it is unset, the facade is discovered from the repository layout: every +directory directly under `pkg/` counts as facade.** That default is +deliberately permissive. Within a single Go module, `pkg/` importing +`/internal/...` is legal, idiomatic Go, so treating a public package as +a non-facade costs a fail-level finding on correct code. Set +`sdk_facade_paths` when your repository really does distinguish one facade from +other public packages and you want `design.service-import-internal` enforced on +the rest. + +`design.service-import-cmd` has no facade exemption at all: a public package +importing `cmd/` is a layering inversion under any layout, the facade included. + +`design.cmd-through-internal-cli` matches the facade import by module path when +the target has a readable `go.mod` (`/pkg/` and anything under +it), and falls back to a path-segment match otherwise. + Current behavior: - fails on configured layer, domain, data-ownership, capability, public-surface, and production/test boundary violations diff --git a/internal/codeguard/checks/change/change.go b/internal/codeguard/checks/change/change.go index dc2ecb3e..9480ec99 100644 --- a/internal/codeguard/checks/change/change.go +++ b/internal/codeguard/checks/change/change.go @@ -637,9 +637,9 @@ func isPublicSurfaceFile(env support.Context, rel string) bool { lower := strings.ToLower(normalizePath(rel)) base := path.Base(lower) ext := strings.ToLower(filepath.Ext(lower)) - if env.IsSDKFacadeFile != nil && env.IsSDKFacadeFile(rel) { - return true - } + // The SDK facade used to be tested here too; it is a strict subset of + // IsPublicPackageFile ("pkg/"), so the call only ever duplicated the next + // one. Dropping it also keeps this predicate target-free. if env.IsPublicPackageFile != nil && env.IsPublicPackageFile(rel) { return true } diff --git a/internal/codeguard/checks/design/design_go.go b/internal/codeguard/checks/design/design_go.go index 6e250d39..8c522b7b 100644 --- a/internal/codeguard/checks/design/design_go.go +++ b/internal/codeguard/checks/design/design_go.go @@ -12,14 +12,17 @@ import ( ) func goTargetFindings(env support.Context, target core.TargetConfig) []core.Finding { + // Resolved once per target rather than per file: it reads go.mod and lists + // pkg/ on the first call. + facade := support.SDKFacadeForTarget(env, target) return env.ScanTargetFiles(target, "design", func(rel string) bool { return strings.HasSuffix(rel, ".go") }, func(file string, data []byte) []core.Finding { - return goFindingsForFile(env, file, data) + return goFindingsForFile(env, facade, file, data) }) } -func goFindingsForFile(env support.Context, file string, data []byte) []core.Finding { +func goFindingsForFile(env support.Context, facade support.SDKFacade, file string, data []byte) []core.Finding { fset, parsed, err := support.ParseGoSource(env, file, data) if err != nil { return nil @@ -31,7 +34,7 @@ func goFindingsForFile(env support.Context, file string, data []byte) []core.Fin methodCounts, interfaceFindings := typeFindings(env, file, fset, parsed) findings = append(findings, interfaceFindings...) findings = append(findings, methodFindings(env, file, methodCounts)...) - findings = append(findings, importFindings(env, file, fset, parsed)...) + findings = append(findings, importFindings(env, facade, file, fset, parsed)...) return findings } @@ -122,23 +125,31 @@ func methodFindings(env support.Context, file string, methodCounts map[string]in return findings } -func importFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File) []core.Finding { +func importFindings(env support.Context, facade support.SDKFacade, file string, fset *token.FileSet, parsed *ast.File) []core.Finding { findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each import appends a variable number normalized := filepath.ToSlash(file) for _, imp := range parsed.Imports { pathValue := strings.Trim(imp.Path.Value, `"`) pos := fset.Position(imp.Pos()) - findings = append(findings, cmdImportFindings(env, file, normalized, pathValue, pos)...) - findings = append(findings, publicPackageImportFindings(env, file, normalized, pathValue, pos)...) + findings = append(findings, cmdImportFindings(env, facade, file, normalized, pathValue, pos)...) + findings = append(findings, publicPackageImportFindings(env, facade, file, normalized, pathValue, pos)...) } return findings } -func cmdImportFindings(env support.Context, file string, normalized string, pathValue string, pos token.Position) []core.Finding { +// cmdImportFindings reports a cmd/ package reaching for the repository's own +// public facade instead of the internal CLI layer. +// +// The facade used to be the literal "/pkg/codeguard", so this rule could only +// ever fire inside codeguard's own tree - it shipped as a fail-level default +// that was a guaranteed no-op for every other user. It now resolves the +// facade from the scanned repository, module-qualified when a go.mod is +// readable so an unrelated third-party package of the same name cannot match. +func cmdImportFindings(env support.Context, facade support.SDKFacade, file string, normalized string, pathValue string, pos token.Position) []core.Finding { if !*env.Config.Checks.DesignRules.RequireCmdThroughInternalCLI { return nil } - if !env.IsCmdFile(normalized) || !strings.Contains(pathValue, "/pkg/codeguard") || strings.Contains(pathValue, "/internal/") { + if !env.IsCmdFile(normalized) || !facade.MatchesImport(pathValue) || strings.Contains(pathValue, "/internal/") { return nil } return []core.Finding{env.NewFinding(support.FindingInput{ @@ -151,13 +162,19 @@ func cmdImportFindings(env support.Context, file string, normalized string, path })} } -func publicPackageImportFindings(env support.Context, file string, normalized string, pathValue string, pos token.Position) []core.Finding { - if !env.IsPublicPackageFile(normalized) || env.IsSDKFacadeFile(normalized) { +func publicPackageImportFindings(env support.Context, facade support.SDKFacade, file string, normalized string, pathValue string, pos token.Position) []core.Finding { + if !env.IsPublicPackageFile(normalized) { return nil } findings := make([]core.Finding, 0, 2) - if *env.Config.Checks.DesignRules.ForbidServiceImportInternal && strings.Contains(pathValue, "/internal/") { + // The facade exemption applies to the internal-import rule only. A public + // package wrapping internal/ is the whole point of a facade, and within a + // single Go module it is legal, idiomatic code - so the exemption has to be + // generous or the rule reports correct code at fail level. Importing cmd/ + // is a layering inversion under any layout, facade included, so that rule + // keeps no exemption. + if *env.Config.Checks.DesignRules.ForbidServiceImportInternal && !facade.MatchesFile(normalized) && strings.Contains(pathValue, "/internal/") { findings = append(findings, env.NewFinding(support.FindingInput{ RuleID: "design.service-import-internal", Level: "fail", diff --git a/internal/codeguard/checks/support/context.go b/internal/codeguard/checks/support/context.go index c69e0a96..e2eb9739 100644 --- a/internal/codeguard/checks/support/context.go +++ b/internal/codeguard/checks/support/context.go @@ -40,6 +40,35 @@ type GovulncheckResult struct { Diagnostics []core.Diagnostic } +// SDKFacade identifies the package(s) that make up the scanned repository's +// published API surface. It is resolved per target from +// design_rules.sdk_facade_paths, or discovered from the repository layout when +// that is unset - never from a path literal baked into codeguard. +type SDKFacade interface { + // MatchesFile reports whether a target-root-relative path is inside the + // facade. + MatchesFile(rel string) bool + // MatchesImport reports whether a Go import path refers to the facade. + MatchesImport(importPath string) bool +} + +type emptySDKFacade struct{} + +func (emptySDKFacade) MatchesFile(string) bool { return false } +func (emptySDKFacade) MatchesImport(string) bool { return false } + +// SDKFacadeForTarget resolves env's facade for target, returning a matcher +// that matches nothing when the hook is unwired. +func SDKFacadeForTarget(env Context, target core.TargetConfig) SDKFacade { + if env.SDKFacadeFor == nil { + return emptySDKFacade{} + } + if facade := env.SDKFacadeFor(target); facade != nil { + return facade + } + return emptySDKFacade{} +} + type Context struct { Config core.Config AIEnabled bool @@ -78,13 +107,16 @@ type Context struct { IsInternalOrCmdFile func(path string) bool IsCmdFile func(path string) bool IsPublicPackageFile func(path string) bool - IsSDKFacadeFile func(path string) bool - IsPromptFile func(rel string) bool - RunGovulncheck func(ctx context.Context, dir string, cmdName string) GovulncheckResult - RunCPPFormat func(ctx context.Context, dir string, cfg core.CPPToolingConfig, files []string) CPPToolResult - RunCPPSyntax func(ctx context.Context, dir string, cfg core.CPPToolingConfig) CPPToolResult - RunCommandCheck func(ctx context.Context, dir string, check core.CommandCheckConfig) (string, error) - RunCommandCheckWithEnv func(ctx context.Context, dir string, check core.CommandCheckConfig, env []string) (string, error) - RunDiffCommandCheck func(ctx context.Context, dir string, baseRef string, check core.CommandCheckConfig) (string, error) - NormalizedSeverity func(level string) string + // SDKFacadeFor resolves the scanned repository's published facade package + // for one target. Nil in unit-test contexts; use SDKFacadeForTarget, which + // degrades to a matcher that matches nothing. + SDKFacadeFor func(target core.TargetConfig) SDKFacade + IsPromptFile func(rel string) bool + RunGovulncheck func(ctx context.Context, dir string, cmdName string) GovulncheckResult + RunCPPFormat func(ctx context.Context, dir string, cfg core.CPPToolingConfig, files []string) CPPToolResult + RunCPPSyntax func(ctx context.Context, dir string, cfg core.CPPToolingConfig) CPPToolResult + RunCommandCheck func(ctx context.Context, dir string, check core.CommandCheckConfig) (string, error) + RunCommandCheckWithEnv func(ctx context.Context, dir string, check core.CommandCheckConfig, env []string) (string, error) + RunDiffCommandCheck func(ctx context.Context, dir string, baseRef string, check core.CommandCheckConfig) (string, error) + NormalizedSeverity func(level string) string } diff --git a/internal/codeguard/config/defaults_base_rules.go b/internal/codeguard/config/defaults_base_rules.go index e2e4474c..e7e21088 100644 --- a/internal/codeguard/config/defaults_base_rules.go +++ b/internal/codeguard/config/defaults_base_rules.go @@ -44,12 +44,16 @@ func baseDefaultDesignRules() core.DesignRulesConfig { return core.DesignRulesConfig{ // Package-layering policy: off by default. These four rules encode one // specific repository layout (cmd/ delegating to internal/cli, a pkg/ - // facade that must not reach into internal/ or cmd/) and report at - // fail level. `design.cmd-through-internal-cli` additionally only ever - // matches an import of ".../pkg/codeguard", so it cannot fire outside - // this repository at all. A repository that wants the layering - // enforced states it in design_rules; the starter config and the - // self-scan config both do. + // facade that must not reach into cmd/) and report at fail level. A + // repository that wants the layering enforced states it in + // design_rules; the starter config and the self-scan config both do. + // + // Which package counts as the facade is resolved per repository (see + // design_rules.sdk_facade_paths and SDKFacadeResolver); it used to be + // the literal "pkg/codeguard", which exempted this repository from + // design.service-import-internal while every other repository was held + // to it, and made design.cmd-through-internal-cli unable to fire + // anywhere else. RequireCmdThroughInternalCLI: boolPtr(false), ForbidInternalImportCmd: boolPtr(false), ForbidServiceImportInternal: boolPtr(false), diff --git a/internal/codeguard/config/defaults_rules.go b/internal/codeguard/config/defaults_rules.go index 87f20c04..64096b49 100644 --- a/internal/codeguard/config/defaults_rules.go +++ b/internal/codeguard/config/defaults_rules.go @@ -92,6 +92,10 @@ func applyDesignDefaults(dst *core.DesignRulesConfig, def core.DesignRulesConfig defaultInt(&dst.GodModuleThreshold, def.GodModuleThreshold) defaultInt(&dst.HighImpactChangeThreshold, def.HighImpactChangeThreshold) defaultStringSlice(&dst.ForbiddenPackageNames, def.ForbiddenPackageNames) + // sdk_facade_paths has no baseline value on purpose: the facade is a + // property of the repository being scanned, so an unset list means + // "discover it from the layout" rather than "use codeguard's own". + defaultStringSlice(&dst.SDKFacadePaths, def.SDKFacadePaths) applyDefaultBoolPtrs( &dst.DetectImportCycles, &dst.DetectGodModules, diff --git a/internal/codeguard/config/validate_design.go b/internal/codeguard/config/validate_design.go index 95347956..269594e7 100644 --- a/internal/codeguard/config/validate_design.go +++ b/internal/codeguard/config/validate_design.go @@ -32,9 +32,36 @@ func validateDesignArchitectureRules(rules core.DesignRulesConfig) error { if err != nil { return err } + err = validateSDKFacadePaths(rules.SDKFacadePaths) + if err != nil { + return err + } return validateDesignBoundaryToggles(rules) } +// validateSDKFacadePaths checks that each configured facade entry is a +// repository-relative directory. The entries are prefix-matched against +// target-root-relative paths, so an absolute or escaping path could never +// match anything and is almost certainly a mistake. +func validateSDKFacadePaths(paths []string) error { + for idx, value := range paths { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fmt.Errorf("design_rules.sdk_facade_paths[%d] must not be empty", idx) + } + normalized := strings.ReplaceAll(trimmed, "\\", "/") + if strings.HasPrefix(normalized, "/") || strings.Contains(normalized, ":") { + return fmt.Errorf("design_rules.sdk_facade_paths[%d] %q must be relative to the target root", idx, value) + } + for _, segment := range strings.Split(normalized, "/") { + if segment == ".." { + return fmt.Errorf("design_rules.sdk_facade_paths[%d] %q must not escape the target root", idx, value) + } + } + } + return nil +} + func validateDesignLayers(ruleLayers []core.DesignLayerConfig) (map[string]struct{}, error) { layers := make(map[string]struct{}, len(ruleLayers)) for idx, layer := range ruleLayers { diff --git a/internal/codeguard/core/config_rule_types.go b/internal/codeguard/core/config_rule_types.go index da7a462f..414becd1 100644 --- a/internal/codeguard/core/config_rule_types.go +++ b/internal/codeguard/core/config_rule_types.go @@ -216,29 +216,42 @@ type RiskScoringConfig struct { } type DesignRulesConfig struct { - RequireCmdThroughInternalCLI *bool `json:"require_cmd_through_internal_cli,omitempty" yaml:"require_cmd_through_internal_cli,omitempty"` - ForbidInternalImportCmd *bool `json:"forbid_internal_import_cmd,omitempty" yaml:"forbid_internal_import_cmd,omitempty"` - ForbidServiceImportInternal *bool `json:"forbid_service_import_internal,omitempty" yaml:"forbid_service_import_internal,omitempty"` - ForbidServiceImportCmd *bool `json:"forbid_service_import_cmd,omitempty" yaml:"forbid_service_import_cmd,omitempty"` - MaxDeclsPerFile int `json:"max_decls_per_file" yaml:"max_decls_per_file"` - MaxMethodsPerType int `json:"max_methods_per_type" yaml:"max_methods_per_type"` - MaxInterfaceMethods int `json:"max_interface_methods" yaml:"max_interface_methods"` - DetectImportCycles *bool `json:"detect_import_cycles,omitempty" yaml:"detect_import_cycles,omitempty"` - DetectGodModules *bool `json:"detect_god_modules,omitempty" yaml:"detect_god_modules,omitempty"` - GodModuleThreshold int `json:"god_module_threshold" yaml:"god_module_threshold"` - DetectHighImpactChanges *bool `json:"detect_high_impact_changes,omitempty" yaml:"detect_high_impact_changes,omitempty"` - HighImpactChangeThreshold int `json:"high_impact_change_threshold" yaml:"high_impact_change_threshold"` - ForbiddenPackageNames []string `json:"forbidden_package_names,omitempty" yaml:"forbidden_package_names,omitempty"` - LanguageCommands map[string][]CommandCheckConfig `json:"language_commands,omitempty" yaml:"language_commands,omitempty"` - LanguageDiffCommands map[string][]CommandCheckConfig `json:"language_diff_commands,omitempty" yaml:"language_diff_commands,omitempty"` - RequireBoundaryAssignment *bool `json:"require_boundary_assignment,omitempty" yaml:"require_boundary_assignment,omitempty"` - Layers []DesignLayerConfig `json:"layers,omitempty" yaml:"layers,omitempty"` - Domains []DesignDomainConfig `json:"domains,omitempty" yaml:"domains,omitempty"` - Capabilities []DesignCapabilityConfig `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` - PublicSurfaces []DesignPublicSurfaceConfig `json:"public_surfaces,omitempty" yaml:"public_surfaces,omitempty"` - ProductionTest *DesignProductionTestConfig `json:"production_test,omitempty" yaml:"production_test,omitempty"` - Reachability *DesignReachabilityConfig `json:"reachability,omitempty" yaml:"reachability,omitempty"` - Stability *DesignStabilityConfig `json:"stability,omitempty" yaml:"stability,omitempty"` + RequireCmdThroughInternalCLI *bool `json:"require_cmd_through_internal_cli,omitempty" yaml:"require_cmd_through_internal_cli,omitempty"` + ForbidInternalImportCmd *bool `json:"forbid_internal_import_cmd,omitempty" yaml:"forbid_internal_import_cmd,omitempty"` + ForbidServiceImportInternal *bool `json:"forbid_service_import_internal,omitempty" yaml:"forbid_service_import_internal,omitempty"` + ForbidServiceImportCmd *bool `json:"forbid_service_import_cmd,omitempty" yaml:"forbid_service_import_cmd,omitempty"` + // SDKFacadePaths names the package directories that make up this + // repository's published API surface ("pkg/"), relative to the target + // root. The facade is the one public package that is *meant* to wrap the + // module's internals, so it is exempt from + // design.service-import-internal, and it is the import that + // design.cmd-through-internal-cli watches for in cmd/ packages. + // + // Unset means "discover it": every directory directly under pkg/ is treated + // as facade. That is deliberately permissive, because within a single Go + // module pkg/ importing internal/ is legal, idiomatic Go, and + // guessing wrong costs a fail-level finding on correct code. A repository + // that wants the strict reading names its facade here. + SDKFacadePaths []string `json:"sdk_facade_paths,omitempty" yaml:"sdk_facade_paths,omitempty"` + MaxDeclsPerFile int `json:"max_decls_per_file" yaml:"max_decls_per_file"` + MaxMethodsPerType int `json:"max_methods_per_type" yaml:"max_methods_per_type"` + MaxInterfaceMethods int `json:"max_interface_methods" yaml:"max_interface_methods"` + DetectImportCycles *bool `json:"detect_import_cycles,omitempty" yaml:"detect_import_cycles,omitempty"` + DetectGodModules *bool `json:"detect_god_modules,omitempty" yaml:"detect_god_modules,omitempty"` + GodModuleThreshold int `json:"god_module_threshold" yaml:"god_module_threshold"` + DetectHighImpactChanges *bool `json:"detect_high_impact_changes,omitempty" yaml:"detect_high_impact_changes,omitempty"` + HighImpactChangeThreshold int `json:"high_impact_change_threshold" yaml:"high_impact_change_threshold"` + ForbiddenPackageNames []string `json:"forbidden_package_names,omitempty" yaml:"forbidden_package_names,omitempty"` + LanguageCommands map[string][]CommandCheckConfig `json:"language_commands,omitempty" yaml:"language_commands,omitempty"` + LanguageDiffCommands map[string][]CommandCheckConfig `json:"language_diff_commands,omitempty" yaml:"language_diff_commands,omitempty"` + RequireBoundaryAssignment *bool `json:"require_boundary_assignment,omitempty" yaml:"require_boundary_assignment,omitempty"` + Layers []DesignLayerConfig `json:"layers,omitempty" yaml:"layers,omitempty"` + Domains []DesignDomainConfig `json:"domains,omitempty" yaml:"domains,omitempty"` + Capabilities []DesignCapabilityConfig `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` + PublicSurfaces []DesignPublicSurfaceConfig `json:"public_surfaces,omitempty" yaml:"public_surfaces,omitempty"` + ProductionTest *DesignProductionTestConfig `json:"production_test,omitempty" yaml:"production_test,omitempty"` + Reachability *DesignReachabilityConfig `json:"reachability,omitempty" yaml:"reachability,omitempty"` + Stability *DesignStabilityConfig `json:"stability,omitempty" yaml:"stability,omitempty"` } type PromptRulesConfig struct { diff --git a/internal/codeguard/runner/checks/checks.go b/internal/codeguard/runner/checks/checks.go index f6e324f1..ec72d3b3 100644 --- a/internal/codeguard/runner/checks/checks.go +++ b/internal/codeguard/runner/checks/checks.go @@ -132,6 +132,9 @@ func contextEnabled(sc runnersupport.Context) bool { // closure signatures carry no context of their own; they are only invoked // while Build's sections run, so the scan ctx is the correct lifetime. func buildCheckContext(ctx context.Context, sc runnersupport.Context) checkSupport.Context { + // One resolver per scan: it memoizes a go.mod read and a directory listing + // per target, and sections consult it concurrently. + facades := runnersupport.NewSDKFacadeResolver(sc.Cfg) return checkSupport.Context{ Config: sc.Cfg, AIEnabled: sc.Opts.EnableAI || (sc.Cfg.AI.Enabled != nil && *sc.Cfg.AI.Enabled), @@ -199,7 +202,9 @@ func buildCheckContext(ctx context.Context, sc runnersupport.Context) checkSuppo IsInternalOrCmdFile: runnersupport.IsInternalOrCmdFile, IsCmdFile: runnersupport.IsCmdFile, IsPublicPackageFile: runnersupport.IsPublicPackageFile, - IsSDKFacadeFile: runnersupport.IsSDKFacadeFile, + SDKFacadeFor: func(target core.TargetConfig) checkSupport.SDKFacade { + return facades.For(target) + }, IsPromptFile: func(rel string) bool { return runnersupport.IsPromptFile(sc, rel) }, diff --git a/internal/codeguard/runner/support/sdk_facade.go b/internal/codeguard/runner/support/sdk_facade.go new file mode 100644 index 00000000..1b822492 --- /dev/null +++ b/internal/codeguard/runner/support/sdk_facade.go @@ -0,0 +1,178 @@ +package support + +import ( + "bufio" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// SDKFacade is the resolved identity of a repository's published API surface: +// the package(s) under pkg/ that exist to wrap the module's internals. +// +// It used to be the literal prefix "pkg/codeguard/", which exempted this +// repository from two fail-level layering rules that every other repository +// was held to - and made design.cmd-through-internal-cli a guaranteed no-op +// everywhere else. Both halves are now a property of the scanned repository: +// either stated in design_rules.sdk_facade_paths or discovered from its layout. +type SDKFacade struct { + // prefixes are target-root-relative directory prefixes in slash form, each + // with a trailing "/" so a prefix test cannot match a sibling directory + // whose name merely starts the same way. + prefixes []string + // modulePath is the go.mod module path of the target, used to recognise an + // import of the facade. Empty when the target has no readable go.mod. + modulePath string +} + +// MatchesFile reports whether a target-root-relative file lives inside the +// facade. +func (facade SDKFacade) MatchesFile(rel string) bool { + if len(facade.prefixes) == 0 { + return false + } + normalized := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(rel)), "./") + for _, prefix := range facade.prefixes { + if strings.HasPrefix(normalized, prefix) { + return true + } + } + return false +} + +// MatchesImport reports whether a Go import path refers to the facade. +// +// When the module path is known the match is module-qualified, so a third +// party's similarly named package cannot trigger it. Without a go.mod the +// check degrades to a path-segment match, which is what the hardcoded +// predicate did. +func (facade SDKFacade) MatchesImport(importPath string) bool { + candidate := strings.TrimSuffix(filepath.ToSlash(strings.TrimSpace(importPath)), "/") + if candidate == "" { + return false + } + for _, prefix := range facade.prefixes { + dir := strings.TrimSuffix(prefix, "/") + if facade.modulePath != "" { + qualified := facade.modulePath + "/" + dir + if candidate == qualified || strings.HasPrefix(candidate, qualified+"/") { + return true + } + continue + } + if strings.HasSuffix(candidate, "/"+dir) || strings.Contains(candidate, "/"+dir+"/") { + return true + } + } + return false +} + +// SDKFacadeResolver resolves one SDKFacade per scan target. Resolution touches +// the filesystem (go.mod plus a single directory listing), so results are +// memoized for the life of a scan; the lock is needed because check sections +// run concurrently. +type SDKFacadeResolver struct { + configured []string + mu sync.Mutex + cache map[string]SDKFacade +} + +// NewSDKFacadeResolver binds a resolver to one scan's configuration. +func NewSDKFacadeResolver(cfg core.Config) *SDKFacadeResolver { + return &SDKFacadeResolver{ + configured: NormalizeSDKFacadePaths(cfg.Checks.DesignRules.SDKFacadePaths), + cache: map[string]SDKFacade{}, + } +} + +// For returns the facade of target. +func (resolver *SDKFacadeResolver) For(target core.TargetConfig) SDKFacade { + if resolver == nil { + return SDKFacade{} + } + root := filepath.Clean(strings.TrimSpace(target.Path)) + if root == "" || root == "." { + root = "." + } + resolver.mu.Lock() + defer resolver.mu.Unlock() + if facade, ok := resolver.cache[root]; ok { + return facade + } + facade := SDKFacade{modulePath: goModuleDirective(root)} + if len(resolver.configured) > 0 { + facade.prefixes = resolver.configured + } else { + facade.prefixes = discoverPublicPackageDirs(root) + } + resolver.cache[root] = facade + return facade +} + +// NormalizeSDKFacadePaths turns configured entries into comparable directory +// prefixes. Entries are directories, but a user who writes a glob tail +// ("pkg/sdk/**") means the same directory, so those tails are trimmed rather +// than rejected. +func NormalizeSDKFacadePaths(values []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, value := range values { + cleaned := strings.Trim(filepath.ToSlash(strings.TrimSpace(value)), "/") + cleaned = strings.TrimPrefix(cleaned, "./") + for _, tail := range []string{"/**", "/*"} { + cleaned = strings.TrimSuffix(cleaned, tail) + } + cleaned = strings.Trim(cleaned, "/") + if cleaned == "" || cleaned == "." { + continue + } + prefix := cleaned + "/" + if _, duplicate := seen[prefix]; duplicate { + continue + } + seen[prefix] = struct{}{} + out = append(out, prefix) + } + sort.Strings(out) + return out +} + +// discoverPublicPackageDirs is the zero-config heuristic: every directory +// directly under pkg/ is this module's public surface. +func discoverPublicPackageDirs(root string) []string { + entries, err := os.ReadDir(filepath.Join(root, "pkg")) + if err != nil { + return nil + } + out := make([]string, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if !entry.IsDir() || strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { + continue + } + out = append(out, "pkg/"+name+"/") + } + sort.Strings(out) + return out +} + +// goModuleDirective returns the module path declared by dir/go.mod, or "". +func goModuleDirective(dir string) string { + file, err := os.Open(filepath.Join(dir, "go.mod")) //nolint:gosec // fixed filename joined under the configured scan root + if err != nil { + return "" + } + defer func() { _ = file.Close() }() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(strings.SplitN(scanner.Text(), "//", 2)[0]) + if rest, ok := strings.CutPrefix(line, "module "); ok { + return strings.Trim(strings.TrimSpace(rest), `"`) + } + } + return "" +} diff --git a/internal/codeguard/runner/support/utils_match.go b/internal/codeguard/runner/support/utils_match.go index 969dbc0f..0f71ad65 100644 --- a/internal/codeguard/runner/support/utils_match.go +++ b/internal/codeguard/runner/support/utils_match.go @@ -26,11 +26,6 @@ func IsPublicPackageFile(path string) bool { return strings.HasPrefix(normalized, "pkg/") } -func IsSDKFacadeFile(path string) bool { - normalized := filepath.ToSlash(path) - return strings.HasPrefix(normalized, "pkg/codeguard/") -} - // ShouldExclude reports whether a target-root-relative path is skipped by the // built-in defaults or by the user's exclude list. func ShouldExclude(rel string, excludes []string) bool { diff --git a/tests/checks/design_sdk_facade_test.go b/tests/checks/design_sdk_facade_test.go new file mode 100644 index 00000000..218fe12a --- /dev/null +++ b/tests/checks/design_sdk_facade_test.go @@ -0,0 +1,140 @@ +package checks_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// facadeFixture writes a minimal single-module Go repository whose pkg/ +// package imports its own internal/ tree - the legal, idiomatic facade +// pattern that used to be reported at fail level for every repository whose +// facade directory was not literally named "codeguard". +func facadeFixture(t *testing.T, name string, module string) string { + t.Helper() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "go.mod"), "module "+module+"\n\ngo 1.23\n") + writeFile(t, filepath.Join(dir, "internal", "engine", "engine.go"), + "package engine\n\nfunc Run() string { return \"ok\" }\n") + writeFile(t, filepath.Join(dir, "pkg", name, "facade.go"), + "package "+name+"\n\nimport \""+module+"/internal/engine\"\n\nfunc Run() string { return engine.Run() }\n") + return dir +} + +func designLayeringConfig(t *testing.T, name string, dir string) codeguard.Config { + t.Helper() + cfg := codeguard.ExampleConfig() + cfg.Name = name + cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: "go"}} + cfg.Checks.Design = true + cfg.Checks.Quality = false + cfg.Checks.Security = false + cfg.Checks.Prompts = false + cfg.Checks.CI = false + return cfg +} + +func runDesignScan(t *testing.T, cfg codeguard.Config) codeguard.Report { + t.Helper() + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + return report +} + +func assertNoFindings(t *testing.T, report codeguard.Report, ruleID string) { + t.Helper() + if found := findingsForRule(report, ruleID); len(found) > 0 { + t.Fatalf("%s reported %d finding(s), want none: %+v", ruleID, len(found), found) + } +} + +func assertHasFindings(t *testing.T, report codeguard.Report, ruleID string) { + t.Helper() + if found := findingsForRule(report, ruleID); len(found) == 0 { + t.Fatalf("%s reported no findings, want at least one", ruleID) + } +} + +// TestDesignServiceImportInternalExemptsDiscoveredFacade is the fixture that +// proved the defect: an ordinary repository whose facade directory happens not +// to be named "codeguard" used to collect a fail-level +// design.service-import-internal for a legal same-module import. +func TestDesignServiceImportInternalExemptsDiscoveredFacade(t *testing.T) { + dir := facadeFixture(t, "myapp", "example.com/myapp") + cfg := designLayeringConfig(t, "facade-ordinary-repo", dir) + // quality.dependency-direction consumed the same predicate, so it is + // asserted here too; that needs the quality section on. + cfg.Checks.Quality = true + report := runDesignScan(t, cfg) + assertNoFindings(t, report, "design.service-import-internal") + assertNoFindings(t, report, "quality.dependency-direction") +} + +// TestDesignServiceImportInternalExemptsCodeguardLayout pins that generalising +// the predicate did not change this repository's own result. +func TestDesignServiceImportInternalExemptsCodeguardLayout(t *testing.T) { + dir := facadeFixture(t, "codeguard", "github.com/devr-tools/codeguard") + report := runDesignScan(t, designLayeringConfig(t, "facade-codeguard-layout", dir)) + assertNoFindings(t, report, "design.service-import-internal") +} + +// TestDesignServiceImportInternalFiresForNonFacadePackage keeps the rule alive: +// once a repository states which package is its facade, every other public +// package reaching into internal/ is still a fail. +func TestDesignServiceImportInternalFiresForNonFacadePackage(t *testing.T) { + dir := facadeFixture(t, "myapp", "example.com/myapp") + writeFile(t, filepath.Join(dir, "pkg", "widgets", "widgets.go"), + "package widgets\n\nimport \"example.com/myapp/internal/engine\"\n\nfunc Run() string { return engine.Run() }\n") + + cfg := designLayeringConfig(t, "facade-configured", dir) + cfg.Checks.DesignRules.SDKFacadePaths = []string{"pkg/myapp"} + report := runDesignScan(t, cfg) + + assertHasFindings(t, report, "design.service-import-internal") + for _, finding := range findingsForRule(report, "design.service-import-internal") { + if filepath.ToSlash(finding.Path) != "pkg/widgets/widgets.go" { + t.Fatalf("unexpected finding path %q; the configured facade must stay exempt", finding.Path) + } + } +} + +// TestDesignServiceImportCmdHasNoFacadeExemption documents the deliberate +// asymmetry: a public package importing cmd/ is a layering inversion under any +// layout, so the facade exemption does not cover it. +func TestDesignServiceImportCmdHasNoFacadeExemption(t *testing.T) { + dir := facadeFixture(t, "myapp", "example.com/myapp") + writeFile(t, filepath.Join(dir, "cmd", "myapp", "main.go"), "package main\n\nfunc main() {}\n") + writeFile(t, filepath.Join(dir, "pkg", "myapp", "shell.go"), + "package myapp\n\nimport _ \"example.com/myapp/cmd/myapp\"\n") + + report := runDesignScan(t, designLayeringConfig(t, "facade-import-cmd", dir)) + assertHasFindings(t, report, "design.service-import-cmd") +} + +// TestDesignCmdThroughInternalCLIFiresOutsideCodeguard covers the inverse +// defect: the rule required "/pkg/codeguard" in the imported path, so it was a +// guaranteed no-op for every other repository. +func TestDesignCmdThroughInternalCLIFiresOutsideCodeguard(t *testing.T) { + dir := facadeFixture(t, "myapp", "example.com/myapp") + writeFile(t, filepath.Join(dir, "cmd", "myapp", "main.go"), + "package main\n\nimport \"example.com/myapp/pkg/myapp\"\n\nfunc main() { println(myapp.Run()) }\n") + + report := runDesignScan(t, designLayeringConfig(t, "cmd-through-cli-ordinary-repo", dir)) + assertHasFindings(t, report, "design.cmd-through-internal-cli") +} + +// TestDesignCmdThroughInternalCLIIgnoresForeignFacade pins the module +// qualification: a third party's pkg/ is not this repository's +// facade. +func TestDesignCmdThroughInternalCLIIgnoresForeignFacade(t *testing.T) { + dir := facadeFixture(t, "myapp", "example.com/myapp") + writeFile(t, filepath.Join(dir, "cmd", "myapp", "main.go"), + "package main\n\nimport _ \"github.com/someone/else/pkg/myapp\"\n\nfunc main() {}\n") + + report := runDesignScan(t, designLayeringConfig(t, "cmd-through-cli-foreign", dir)) + assertNoFindings(t, report, "design.cmd-through-internal-cli") +} diff --git a/tests/checks/design_test.go b/tests/checks/design_test.go index 2e01b336..454931f5 100644 --- a/tests/checks/design_test.go +++ b/tests/checks/design_test.go @@ -12,10 +12,17 @@ import ( func TestDesignCheckFailsWhenServiceImportsInternal(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "pkg", "publicapi", "service.go"), "package publicapi\n\nimport _ \"github.com/devr-tools/codeguard/internal/cli\"\n") + // pkg/publicapi is only a rule violation when it is NOT the repository's + // facade, so the facade has to be stated. With sdk_facade_paths unset, + // every directory under pkg/ is treated as facade and this import is the + // ordinary same-module facade pattern; see + // TestDesignServiceImportInternalExemptsDiscoveredFacade. + writeFile(t, filepath.Join(dir, "pkg", "sdk", "sdk.go"), "package sdk\n") cfg := codeguard.ExampleConfig() cfg.Name = "design-service-internal" cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: "go"}} + cfg.Checks.DesignRules.SDKFacadePaths = []string{"pkg/sdk"} cfg.Checks.Design = true cfg.Checks.Quality = false cfg.Checks.Security = false @@ -32,7 +39,12 @@ func TestDesignCheckFailsWhenServiceImportsInternal(t *testing.T) { func TestDesignCheckFailsWhenCmdImportsServiceDirectly(t *testing.T) { dir := t.TempDir() - writeFile(t, filepath.Join(dir, "cmd", "tool", "main.go"), "package main\n\nimport _ \"github.com/devr-tools/codeguard/pkg/codeguard\"\n") + // The facade is resolved from the scanned repository, so the fixture has + // to be a self-consistent module rather than borrowing codeguard's own + // import path. + writeFile(t, filepath.Join(dir, "go.mod"), "module example.com/tool\n\ngo 1.23\n") + writeFile(t, filepath.Join(dir, "pkg", "toolsdk", "sdk.go"), "package toolsdk\n") + writeFile(t, filepath.Join(dir, "cmd", "tool", "main.go"), "package main\n\nimport _ \"example.com/tool/pkg/toolsdk\"\n") cfg := codeguard.ExampleConfig() cfg.Name = "design-cmd-service" diff --git a/tests/support/sdk_facade_test.go b/tests/support/sdk_facade_test.go new file mode 100644 index 00000000..a0a4a01b --- /dev/null +++ b/tests/support/sdk_facade_test.go @@ -0,0 +1,120 @@ +package support_test + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/devr-tools/codeguard/internal/codeguard/core" + runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" +) + +func writeFacadeRepo(t *testing.T, module string, packages ...string) string { + t.Helper() + dir := t.TempDir() + if module != "" { + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+module+"\n\ngo 1.23\n"), 0o600); err != nil { + t.Fatalf("write go.mod: %v", err) + } + } + for _, name := range packages { + if err := os.MkdirAll(filepath.Join(dir, "pkg", name), 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + } + return dir +} + +func facadeFor(t *testing.T, dir string, configured []string) runnersupport.SDKFacade { + t.Helper() + cfg := core.Config{} + cfg.Checks.DesignRules.SDKFacadePaths = configured + return runnersupport.NewSDKFacadeResolver(cfg).For(core.TargetConfig{Name: "repo", Path: dir}) +} + +// TestSDKFacadeDiscoversPublicPackageDirs pins the zero-config heuristic: the +// facade is read off the repository layout, not off the literal +// "pkg/codeguard" the predicate used to hardcode. +func TestSDKFacadeDiscoversPublicPackageDirs(t *testing.T) { + for _, name := range []string{"myapp", "codeguard", "sdk"} { + dir := writeFacadeRepo(t, "example.com/"+name, name) + facade := facadeFor(t, dir, nil) + if !facade.MatchesFile("pkg/" + name + "/facade.go") { + t.Fatalf("pkg/%s should be discovered as the facade", name) + } + if facade.MatchesFile("internal/engine/engine.go") { + t.Fatalf("internal/ must never be facade") + } + if facade.MatchesFile("pkg/other/other.go") { + t.Fatalf("pkg/other does not exist and must not match") + } + } +} + +func TestSDKFacadeConfigOverridesDiscovery(t *testing.T) { + dir := writeFacadeRepo(t, "example.com/myapp", "myapp", "widgets") + facade := facadeFor(t, dir, []string{"pkg/myapp/**"}) + if !facade.MatchesFile("pkg/myapp/facade.go") { + t.Fatalf("configured facade must match") + } + if facade.MatchesFile("pkg/widgets/widgets.go") { + t.Fatalf("an explicit sdk_facade_paths list must exclude every other public package") + } +} + +// TestSDKFacadeFilePrefixIsDirectoryScoped guards against a bare prefix test +// matching a sibling directory whose name merely starts the same way. +func TestSDKFacadeFilePrefixIsDirectoryScoped(t *testing.T) { + dir := writeFacadeRepo(t, "example.com/app", "api") + facade := facadeFor(t, dir, nil) + if facade.MatchesFile("pkg/apiclient/client.go") { + t.Fatalf("pkg/apiclient must not match the pkg/api facade") + } +} + +func TestSDKFacadeMatchesImportIsModuleQualified(t *testing.T) { + dir := writeFacadeRepo(t, "example.com/myapp", "myapp") + facade := facadeFor(t, dir, nil) + cases := map[string]bool{ + "example.com/myapp/pkg/myapp": true, + "example.com/myapp/pkg/myapp/sub": true, + "github.com/someone/else/pkg/myapp": false, + "example.com/myapp/internal/engine": false, + "": false, + } + for importPath, want := range cases { + if got := facade.MatchesImport(importPath); got != want { + t.Fatalf("MatchesImport(%q) = %v, want %v", importPath, got, want) + } + } +} + +// TestSDKFacadeMatchesImportWithoutGoMod keeps the pre-existing path-segment +// behaviour for targets with no readable module path. +func TestSDKFacadeMatchesImportWithoutGoMod(t *testing.T) { + dir := writeFacadeRepo(t, "", "codeguard") + facade := facadeFor(t, dir, nil) + if !facade.MatchesImport("github.com/devr-tools/codeguard/pkg/codeguard") { + t.Fatalf("suffix fallback must still recognise the facade import") + } + if facade.MatchesImport("github.com/devr-tools/codeguard/internal/cli") { + t.Fatalf("internal import must not look like the facade") + } +} + +func TestSDKFacadeEmptyWhenNoPublicPackages(t *testing.T) { + dir := writeFacadeRepo(t, "example.com/app") + facade := facadeFor(t, dir, nil) + if facade.MatchesFile("pkg/app/app.go") || facade.MatchesImport("example.com/app/pkg/app") { + t.Fatalf("a repository with no pkg/ tree has no facade") + } +} + +func TestNormalizeSDKFacadePaths(t *testing.T) { + got := runnersupport.NormalizeSDKFacadePaths([]string{" pkg/sdk/** ", "pkg/sdk", "./pkg/api/", "", " ", "pkg/zz/*"}) + want := []string{"pkg/api/", "pkg/sdk/", "pkg/zz/"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("NormalizeSDKFacadePaths = %v, want %v", got, want) + } +} From 6a0fd311b4cd1900363e91e7d69195175efb79ce Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 22 Oct 2026 14:15:38 -0400 Subject: [PATCH 14/16] fix(rules): add fix templates for the five catalogued performance rules TestSDKRuleMetadataFixTemplatesPopulated asserts every catalog rule carries a populated fix template with a kind; the five entries added in the previous commit had none, so adding them to the catalog broke that invariant. Deterministic for the two mechanical C++ fixes (bind by const reference; move the flush after the loop) and guided for the three Go ones, where the change is a shape rather than an exact rewrite. Co-Authored-By: Claude Opus 5 (1M context) --- .../catalog_fix_templates_performance.go | 63 ++++++++++--------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/internal/codeguard/rules/catalog_fix_templates_performance.go b/internal/codeguard/rules/catalog_fix_templates_performance.go index bf897fc0..0e5f2a54 100644 --- a/internal/codeguard/rules/catalog_fix_templates_performance.go +++ b/internal/codeguard/rules/catalog_fix_templates_performance.go @@ -4,33 +4,38 @@ import "github.com/devr-tools/codeguard/internal/codeguard/core" // performanceFixTemplates covers the performance section heuristics. var performanceFixTemplates = map[string]core.FixTemplate{ - "performance.sync-io-in-request-path": {Kind: guided, Text: "Load the data once at startup or cache it instead of reading the filesystem per request.\n\nBefore:\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tdata, _ := os.ReadFile(\"config.json\")\n\tw.Write(data)\n}\n\nAfter:\nvar config = mustLoadConfig(\"config.json\") // loaded once at startup\n\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tw.Write(config)\n}"}, - "performance.unbounded-goroutines-in-loop": {Kind: guided, Text: "Bound loop-driven concurrency with a worker pool, semaphore, or errgroup limit.\n\nBefore:\nfor _, job := range jobs {\n\tgo process(job)\n}\n\nAfter:\ngroup, ctx := errgroup.WithContext(ctx)\ngroup.SetLimit(8)\nfor _, job := range jobs {\n\tgroup.Go(func() error { return process(ctx, job) })\n}\nif err := group.Wait(); err != nil {\n\treturn err\n}"}, - "performance.n-plus-one-query": {Kind: guided, Text: "Batch the per-item lookups into one query issued before the loop.\n\nBefore:\nfor _, id := range ids {\n\tuser, _ := db.GetUser(ctx, id)\n\tusers = append(users, user)\n}\n\nAfter:\nusers, err := db.GetUsersByIDs(ctx, ids) // single batched query"}, - "performance.go.alloc-in-loop": {Kind: guided, Text: "Use strings.Builder for string accumulation and preallocate slice capacity before the loop.\n\nBefore:\nvar out string\nfor _, line := range lines {\n\tout += line + \"\\n\"\n}\n\nAfter:\nvar b strings.Builder\nfor _, line := range lines {\n\tb.WriteString(line)\n\tb.WriteByte('\\n')\n}\nout := b.String()"}, - "performance.rust.alloc-in-loop": {Kind: guided, Text: "Preallocate the String or collect parts and join once.\n\nBefore:\nlet mut out = String::new();\nfor row in rows {\n out.push_str(render(row).as_str());\n}\n\nAfter:\nlet mut out = String::with_capacity(rows.len() * 32);\nfor row in rows {\n out.push_str(render(row).as_str());\n}\n// or: let out = rows.iter().map(render).collect::>().join(\"\")"}, - "performance.cpp.alloc-in-loop": {Kind: guided, Text: "Reserve string capacity before the loop, or collect fragments and concatenate once after it.\n\nBefore:\nstd::string out;\nfor (const auto& row : rows) {\n out += render(row);\n}\n\nAfter:\nstd::string out;\nout.reserve(rows.size() * 32);\nfor (const auto& row : rows) {\n out += render(row);\n}\n// or collect into std::vector and join once"}, - "performance.typescript.sync-io-in-handler": {Kind: guided, Text: "Switch to the promise-based API inside request handlers so the event loop is not blocked.\n\nBefore:\napp.get(\"/report\", (req, res) => {\n const data = fs.readFileSync(\"report.json\", \"utf8\");\n res.send(data);\n});\n\nAfter:\napp.get(\"/report\", async (req, res) => {\n const data = await fs.promises.readFile(\"report.json\", \"utf8\");\n res.send(data);\n});"}, - "performance.javascript.sync-io-in-handler": {Kind: guided, Text: "Switch to the promise-based API inside request handlers so the event loop is not blocked.\n\nBefore:\napp.get(\"/report\", (req, res) => {\n res.send(fs.readFileSync(\"report.json\"));\n});\n\nAfter:\nconst fsp = require(\"fs/promises\");\napp.get(\"/report\", async (req, res) => {\n res.send(await fsp.readFile(\"report.json\"));\n});"}, - "performance.typescript.unbounded-concurrency": {Kind: guided, Text: "Process the work in bounded chunks or wrap calls with a concurrency limiter.\n\nBefore:\nfor (const url of urls) {\n promises.push(fetch(url));\n}\nawait Promise.all(promises);\n\nAfter:\nimport pLimit from \"p-limit\";\nconst limit = pLimit(8);\nawait Promise.all(urls.map((url) => limit(() => fetch(url))));"}, - "performance.javascript.unbounded-concurrency": {Kind: guided, Text: "Process the work in bounded chunks or wrap calls with a concurrency limiter.\n\nBefore:\nfor (const url of urls) {\n promises.push(fetch(url));\n}\nawait Promise.all(promises);\n\nAfter:\nfor (let i = 0; i < urls.length; i += 8) {\n await Promise.all(urls.slice(i, i + 8).map((url) => fetch(url)));\n}"}, - "performance.python.sync-io-in-async": {Kind: guided, Text: "Use async clients and asyncio.sleep inside async functions so the event loop is not blocked.\n\nBefore:\nasync def refresh():\n data = requests.get(url).json()\n time.sleep(1)\n\nAfter:\nasync def refresh():\n async with httpx.AsyncClient() as client:\n data = (await client.get(url)).json()\n await asyncio.sleep(1)"}, - "performance.regex-compile-in-loop": {Kind: deterministic, Text: "Compile the pattern once outside the loop and reuse it.\n\nBefore:\nfor _, line := range lines {\n\tre := regexp.MustCompile(`\\d+`)\n\tif re.MatchString(line) { ... }\n}\n\nAfter:\nvar digits = regexp.MustCompile(`\\d+`) // compiled once\n\nfor _, line := range lines {\n\tif digits.MatchString(line) { ... }\n}"}, - "performance.go.defer-in-loop": {Kind: guided, Text: "Release per-iteration resources explicitly, or extract the loop body into a function so the defer runs each iteration.\n\nBefore:\nfor _, path := range paths {\n\tf, _ := os.Open(path)\n\tdefer f.Close() // all files stay open until the function returns\n}\n\nAfter:\nfor _, path := range paths {\n\tif err := processFile(path); err != nil { ... }\n}\n\nfunc processFile(path string) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close() // closes at the end of each iteration\n\t...\n}"}, - "performance.go.sleep-in-loop": {Kind: guided, Text: "Replace the polling sleep with a ticker, a channel signal, or backoff.\n\nBefore:\nfor {\n\tif ready() {\n\t\tbreak\n\t}\n\ttime.Sleep(100 * time.Millisecond)\n}\n\nAfter:\nticker := time.NewTicker(100 * time.Millisecond)\ndefer ticker.Stop()\nfor range ticker.C {\n\tif ready() {\n\t\tbreak\n\t}\n}\n// or better: have the producer signal readiness on a channel"}, - "performance.rust.sleep-in-loop": {Kind: guided, Text: "Replace the polling sleep with a proper synchronization primitive or bounded backoff.\n\nBefore:\nloop {\n if ready() { break; }\n std::thread::sleep(Duration::from_millis(100));\n}\n\nAfter:\nwhile !ready() {\n receiver.recv_timeout(Duration::from_millis(100)).ok();\n}\n// or use a Condvar / watch channel so readiness is signaled instead of polled"}, - "performance.cpp.sleep-in-loop": {Kind: guided, Text: "Replace the polling sleep with a condition variable, timer primitive, or bounded backoff helper.\n\nBefore:\nwhile (!ready()) {\n std::this_thread::sleep_for(std::chrono::milliseconds(100));\n}\n\nAfter:\nstd::unique_lock lock(mu);\ncv.wait_for(lock, std::chrono::milliseconds(100), [&] { return ready(); });\n// or use an event/timer abstraction instead of polling"}, - "performance.cpp.unbounded-concurrency": {Kind: guided, Text: "Bound loop-driven thread/task creation with a fixed worker pool, semaphore, or bounded executor.\n\nBefore:\nfor (const auto& job : jobs) {\n threads.emplace_back(run, job);\n}\n\nAfter:\nThreadPool pool(8);\nfor (const auto& job : jobs) {\n pool.submit(run, job);\n}\npool.wait();"}, - "performance.go.timer-leak-in-loop": {Kind: deterministic, Text: "Reuse one timer instead of allocating a new one per iteration with time.After.\n\nBefore:\nfor {\n\tselect {\n\tcase msg := <-inbox:\n\t\thandle(msg)\n\tcase <-time.After(time.Second): // leaks a timer per iteration\n\t\treturn\n\t}\n}\n\nAfter:\ntimer := time.NewTimer(time.Second)\ndefer timer.Stop()\nfor {\n\tselect {\n\tcase msg := <-inbox:\n\t\thandle(msg)\n\t\ttimer.Reset(time.Second)\n\tcase <-timer.C:\n\t\treturn\n\t}\n}"}, - "performance.unbounded-read": {Kind: guided, Text: "Bound the read or process the input as a stream.\n\nBefore:\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tbody, _ := io.ReadAll(r.Body) // one oversized request = whole payload in memory\n}\n\nAfter:\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB cap\n}\n// Python: read(65536) in a loop, or iterate the file object line by line"}, - "performance.go.hot-package": {Kind: guided, Text: "Reduce direct fan-in to the package so ordinary edits do not force broad recompiles.\n\nBefore:\ninternal/platform/config // imported directly by many packages\n\nAfter:\ninternal/platform/config/schema // stable leaf types\ninternal/platform/config/load // volatile implementation\n// move frequently changing code behind smaller leaf packages or interfaces"}, - "performance.go.rebuild-amplifier": {Kind: guided, Text: "Break the package apart so changes stop cascading through the transitive import graph.\n\nBefore:\ninternal/core // depends on many details and is imported nearly everywhere\n\nAfter:\ninternal/core/contracts // stable shared interfaces\ninternal/core/runtime // volatile implementation details\n// pull fast-changing code into narrower leaves and keep shared contracts small"}, - "performance.cpp.hot-header": {Kind: guided, Text: "Reduce direct include fan-in so ordinary header edits do not force broad recompilation.\n\nBefore:\n// common.hpp includes unrelated declarations and is included everywhere\n\nAfter:\n// split focused declarations into narrow headers and use forward declarations where possible"}, - "performance.cpp.rebuild-amplifier": {Kind: guided, Text: "Break the transitive include cascade around the header/module.\n\nBefore:\n// core.hpp includes volatile implementation headers and is imported broadly\n\nAfter:\n// keep stable declarations in core.hpp; move implementation dependencies into .cpp files or private modules"}, - "performance.string-concat-in-loop": {Kind: deterministic, Text: "Collect the parts and join once after the loop.\n\nBefore:\nout = \"\"\nfor row in rows:\n out += render(row)\n\nAfter:\nparts = [render(row) for row in rows]\nout = \"\".join(parts)\n// TS/JS: const parts: string[] = []; parts.push(render(row)); const out = parts.join(\"\")"}, - "performance.python.unbounded-concurrency": {Kind: guided, Text: "Bound loop-driven task creation with a semaphore or task group.\n\nBefore:\nfor url in urls:\n asyncio.create_task(fetch(url))\n\nAfter:\nsem = asyncio.Semaphore(8)\n\nasync def bounded_fetch(url):\n async with sem:\n return await fetch(url)\n\nasync with asyncio.TaskGroup() as group:\n for url in urls:\n group.create_task(bounded_fetch(url))"}, - "performance.typescript.await-in-loop": {Kind: guided, Text: "Run independent iterations concurrently with Promise.all.\n\nBefore:\nfor (const id of ids) {\n const user = await fetchUser(id); // serial round-trips\n users.push(user);\n}\n\nAfter:\nconst users = await Promise.all(ids.map((id) => fetchUser(id)));\n// keep the loop only when each iteration depends on the previous one"}, - "performance.javascript.await-in-loop": {Kind: guided, Text: "Run independent iterations concurrently with Promise.all.\n\nBefore:\nfor (const id of ids) {\n const user = await fetchUser(id); // serial round-trips\n users.push(user);\n}\n\nAfter:\nconst users = await Promise.all(ids.map((id) => fetchUser(id)));\n// keep the loop only when each iteration depends on the previous one"}, - "performance.typescript.timer-listener-leak": {Kind: guided, Text: "Store timer handles and clean up listeners on teardown.\n\nBefore:\nsetInterval(poll, 1000); // no clearInterval anywhere\nfor (const el of items) {\n el.addEventListener(\"click\", onClick);\n}\n\nAfter:\nconst handle = setInterval(poll, 1000);\nconst controller = new AbortController();\nfor (const el of items) {\n el.addEventListener(\"click\", onClick, { signal: controller.signal });\n}\n// on teardown:\nclearInterval(handle);\ncontroller.abort();"}, - "performance.javascript.timer-listener-leak": {Kind: guided, Text: "Store timer handles and clean up listeners on teardown.\n\nBefore:\nsetInterval(poll, 1000); // no clearInterval anywhere\nfor (const el of items) {\n el.addEventListener(\"click\", onClick);\n}\n\nAfter:\nconst handle = setInterval(poll, 1000);\nconst controller = new AbortController();\nfor (const el of items) {\n el.addEventListener(\"click\", onClick, { signal: controller.signal });\n}\n// on teardown:\nclearInterval(handle);\ncontroller.abort();"}, + "performance.sync-io-in-request-path": {Kind: guided, Text: "Load the data once at startup or cache it instead of reading the filesystem per request.\n\nBefore:\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tdata, _ := os.ReadFile(\"config.json\")\n\tw.Write(data)\n}\n\nAfter:\nvar config = mustLoadConfig(\"config.json\") // loaded once at startup\n\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tw.Write(config)\n}"}, + "performance.unbounded-goroutines-in-loop": {Kind: guided, Text: "Bound loop-driven concurrency with a worker pool, semaphore, or errgroup limit.\n\nBefore:\nfor _, job := range jobs {\n\tgo process(job)\n}\n\nAfter:\ngroup, ctx := errgroup.WithContext(ctx)\ngroup.SetLimit(8)\nfor _, job := range jobs {\n\tgroup.Go(func() error { return process(ctx, job) })\n}\nif err := group.Wait(); err != nil {\n\treturn err\n}"}, + "performance.n-plus-one-query": {Kind: guided, Text: "Batch the per-item lookups into one query issued before the loop.\n\nBefore:\nfor _, id := range ids {\n\tuser, _ := db.GetUser(ctx, id)\n\tusers = append(users, user)\n}\n\nAfter:\nusers, err := db.GetUsersByIDs(ctx, ids) // single batched query"}, + "performance.go.alloc-in-loop": {Kind: guided, Text: "Use strings.Builder for string accumulation and preallocate slice capacity before the loop.\n\nBefore:\nvar out string\nfor _, line := range lines {\n\tout += line + \"\\n\"\n}\n\nAfter:\nvar b strings.Builder\nfor _, line := range lines {\n\tb.WriteString(line)\n\tb.WriteByte('\\n')\n}\nout := b.String()"}, + "performance.rust.alloc-in-loop": {Kind: guided, Text: "Preallocate the String or collect parts and join once.\n\nBefore:\nlet mut out = String::new();\nfor row in rows {\n out.push_str(render(row).as_str());\n}\n\nAfter:\nlet mut out = String::with_capacity(rows.len() * 32);\nfor row in rows {\n out.push_str(render(row).as_str());\n}\n// or: let out = rows.iter().map(render).collect::>().join(\"\")"}, + "performance.cpp.alloc-in-loop": {Kind: guided, Text: "Reserve string capacity before the loop, or collect fragments and concatenate once after it.\n\nBefore:\nstd::string out;\nfor (const auto& row : rows) {\n out += render(row);\n}\n\nAfter:\nstd::string out;\nout.reserve(rows.size() * 32);\nfor (const auto& row : rows) {\n out += render(row);\n}\n// or collect into std::vector and join once"}, + "performance.typescript.sync-io-in-handler": {Kind: guided, Text: "Switch to the promise-based API inside request handlers so the event loop is not blocked.\n\nBefore:\napp.get(\"/report\", (req, res) => {\n const data = fs.readFileSync(\"report.json\", \"utf8\");\n res.send(data);\n});\n\nAfter:\napp.get(\"/report\", async (req, res) => {\n const data = await fs.promises.readFile(\"report.json\", \"utf8\");\n res.send(data);\n});"}, + "performance.javascript.sync-io-in-handler": {Kind: guided, Text: "Switch to the promise-based API inside request handlers so the event loop is not blocked.\n\nBefore:\napp.get(\"/report\", (req, res) => {\n res.send(fs.readFileSync(\"report.json\"));\n});\n\nAfter:\nconst fsp = require(\"fs/promises\");\napp.get(\"/report\", async (req, res) => {\n res.send(await fsp.readFile(\"report.json\"));\n});"}, + "performance.typescript.unbounded-concurrency": {Kind: guided, Text: "Process the work in bounded chunks or wrap calls with a concurrency limiter.\n\nBefore:\nfor (const url of urls) {\n promises.push(fetch(url));\n}\nawait Promise.all(promises);\n\nAfter:\nimport pLimit from \"p-limit\";\nconst limit = pLimit(8);\nawait Promise.all(urls.map((url) => limit(() => fetch(url))));"}, + "performance.javascript.unbounded-concurrency": {Kind: guided, Text: "Process the work in bounded chunks or wrap calls with a concurrency limiter.\n\nBefore:\nfor (const url of urls) {\n promises.push(fetch(url));\n}\nawait Promise.all(promises);\n\nAfter:\nfor (let i = 0; i < urls.length; i += 8) {\n await Promise.all(urls.slice(i, i + 8).map((url) => fetch(url)));\n}"}, + "performance.python.sync-io-in-async": {Kind: guided, Text: "Use async clients and asyncio.sleep inside async functions so the event loop is not blocked.\n\nBefore:\nasync def refresh():\n data = requests.get(url).json()\n time.sleep(1)\n\nAfter:\nasync def refresh():\n async with httpx.AsyncClient() as client:\n data = (await client.get(url)).json()\n await asyncio.sleep(1)"}, + "performance.regex-compile-in-loop": {Kind: deterministic, Text: "Compile the pattern once outside the loop and reuse it.\n\nBefore:\nfor _, line := range lines {\n\tre := regexp.MustCompile(`\\d+`)\n\tif re.MatchString(line) { ... }\n}\n\nAfter:\nvar digits = regexp.MustCompile(`\\d+`) // compiled once\n\nfor _, line := range lines {\n\tif digits.MatchString(line) { ... }\n}"}, + "performance.go.defer-in-loop": {Kind: guided, Text: "Release per-iteration resources explicitly, or extract the loop body into a function so the defer runs each iteration.\n\nBefore:\nfor _, path := range paths {\n\tf, _ := os.Open(path)\n\tdefer f.Close() // all files stay open until the function returns\n}\n\nAfter:\nfor _, path := range paths {\n\tif err := processFile(path); err != nil { ... }\n}\n\nfunc processFile(path string) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close() // closes at the end of each iteration\n\t...\n}"}, + "performance.go.sleep-in-loop": {Kind: guided, Text: "Replace the polling sleep with a ticker, a channel signal, or backoff.\n\nBefore:\nfor {\n\tif ready() {\n\t\tbreak\n\t}\n\ttime.Sleep(100 * time.Millisecond)\n}\n\nAfter:\nticker := time.NewTicker(100 * time.Millisecond)\ndefer ticker.Stop()\nfor range ticker.C {\n\tif ready() {\n\t\tbreak\n\t}\n}\n// or better: have the producer signal readiness on a channel"}, + "performance.rust.sleep-in-loop": {Kind: guided, Text: "Replace the polling sleep with a proper synchronization primitive or bounded backoff.\n\nBefore:\nloop {\n if ready() { break; }\n std::thread::sleep(Duration::from_millis(100));\n}\n\nAfter:\nwhile !ready() {\n receiver.recv_timeout(Duration::from_millis(100)).ok();\n}\n// or use a Condvar / watch channel so readiness is signaled instead of polled"}, + "performance.go.nested-loop-scan": {Kind: guided, Text: "Precompute a set for the inner collection and look the outer value up instead of rescanning.\n\nBefore:\nfor _, want := range wanted {\n\tfor _, have := range haves {\n\t\tif want == have {\n\t\t\tmatch(want)\n\t\t}\n\t}\n}\n\nAfter:\nhaveSet := make(map[string]struct{}, len(haves))\nfor _, have := range haves {\n\thaveSet[have] = struct{}{}\n}\nfor _, want := range wanted {\n\tif _, ok := haveSet[want]; ok {\n\t\tmatch(want)\n\t}\n}"}, + "performance.go.slice-membership-in-loop": {Kind: guided, Text: "Hoist the membership test out of the loop by building a set once.\n\nBefore:\nfor _, item := range items {\n\tif slices.Contains(blocked, item.ID) {\n\t\tcontinue\n\t}\n\thandle(item)\n}\n\nAfter:\nblockedSet := make(map[string]struct{}, len(blocked))\nfor _, id := range blocked {\n\tblockedSet[id] = struct{}{}\n}\nfor _, item := range items {\n\tif _, ok := blockedSet[item.ID]; ok {\n\t\tcontinue\n\t}\n\thandle(item)\n}"}, + "performance.go.lock-held-across-blocking-call": {Kind: guided, Text: "Copy the state the call needs, release the lock, then make the blocking call.\n\nBefore:\nmu.Lock()\ndefer mu.Unlock()\nresp, err := client.Do(req) // every caller serializes behind this\ncache[key] = resp\n\nAfter:\nmu.Lock()\nreq := buildRequest(state)\nmu.Unlock()\n\nresp, err := client.Do(req)\nif err != nil {\n\treturn err\n}\n\nmu.Lock()\ncache[key] = resp\nmu.Unlock()"}, + "performance.cpp.range-for-copy": {Kind: deterministic, Text: "Bind each element by const reference instead of by value.\n\nBefore:\nfor (auto [key, value] : lookup) {\n use(key, value);\n}\n\nAfter:\nfor (const auto& [key, value] : lookup) {\n use(key, value);\n}"}, + "performance.cpp.flush-in-loop": {Kind: deterministic, Text: "Write a newline inside the loop and flush once after it.\n\nBefore:\nfor (const auto& row : rows) {\n std::cout << render(row) << std::endl;\n}\n\nAfter:\nfor (const auto& row : rows) {\n std::cout << render(row) << '\\n';\n}\nstd::cout << std::flush;"}, + "performance.cpp.sleep-in-loop": {Kind: guided, Text: "Replace the polling sleep with a condition variable, timer primitive, or bounded backoff helper.\n\nBefore:\nwhile (!ready()) {\n std::this_thread::sleep_for(std::chrono::milliseconds(100));\n}\n\nAfter:\nstd::unique_lock lock(mu);\ncv.wait_for(lock, std::chrono::milliseconds(100), [&] { return ready(); });\n// or use an event/timer abstraction instead of polling"}, + "performance.cpp.unbounded-concurrency": {Kind: guided, Text: "Bound loop-driven thread/task creation with a fixed worker pool, semaphore, or bounded executor.\n\nBefore:\nfor (const auto& job : jobs) {\n threads.emplace_back(run, job);\n}\n\nAfter:\nThreadPool pool(8);\nfor (const auto& job : jobs) {\n pool.submit(run, job);\n}\npool.wait();"}, + "performance.go.timer-leak-in-loop": {Kind: deterministic, Text: "Reuse one timer instead of allocating a new one per iteration with time.After.\n\nBefore:\nfor {\n\tselect {\n\tcase msg := <-inbox:\n\t\thandle(msg)\n\tcase <-time.After(time.Second): // leaks a timer per iteration\n\t\treturn\n\t}\n}\n\nAfter:\ntimer := time.NewTimer(time.Second)\ndefer timer.Stop()\nfor {\n\tselect {\n\tcase msg := <-inbox:\n\t\thandle(msg)\n\t\ttimer.Reset(time.Second)\n\tcase <-timer.C:\n\t\treturn\n\t}\n}"}, + "performance.unbounded-read": {Kind: guided, Text: "Bound the read or process the input as a stream.\n\nBefore:\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tbody, _ := io.ReadAll(r.Body) // one oversized request = whole payload in memory\n}\n\nAfter:\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB cap\n}\n// Python: read(65536) in a loop, or iterate the file object line by line"}, + "performance.go.hot-package": {Kind: guided, Text: "Reduce direct fan-in to the package so ordinary edits do not force broad recompiles.\n\nBefore:\ninternal/platform/config // imported directly by many packages\n\nAfter:\ninternal/platform/config/schema // stable leaf types\ninternal/platform/config/load // volatile implementation\n// move frequently changing code behind smaller leaf packages or interfaces"}, + "performance.go.rebuild-amplifier": {Kind: guided, Text: "Break the package apart so changes stop cascading through the transitive import graph.\n\nBefore:\ninternal/core // depends on many details and is imported nearly everywhere\n\nAfter:\ninternal/core/contracts // stable shared interfaces\ninternal/core/runtime // volatile implementation details\n// pull fast-changing code into narrower leaves and keep shared contracts small"}, + "performance.cpp.hot-header": {Kind: guided, Text: "Reduce direct include fan-in so ordinary header edits do not force broad recompilation.\n\nBefore:\n// common.hpp includes unrelated declarations and is included everywhere\n\nAfter:\n// split focused declarations into narrow headers and use forward declarations where possible"}, + "performance.cpp.rebuild-amplifier": {Kind: guided, Text: "Break the transitive include cascade around the header/module.\n\nBefore:\n// core.hpp includes volatile implementation headers and is imported broadly\n\nAfter:\n// keep stable declarations in core.hpp; move implementation dependencies into .cpp files or private modules"}, + "performance.string-concat-in-loop": {Kind: deterministic, Text: "Collect the parts and join once after the loop.\n\nBefore:\nout = \"\"\nfor row in rows:\n out += render(row)\n\nAfter:\nparts = [render(row) for row in rows]\nout = \"\".join(parts)\n// TS/JS: const parts: string[] = []; parts.push(render(row)); const out = parts.join(\"\")"}, + "performance.python.unbounded-concurrency": {Kind: guided, Text: "Bound loop-driven task creation with a semaphore or task group.\n\nBefore:\nfor url in urls:\n asyncio.create_task(fetch(url))\n\nAfter:\nsem = asyncio.Semaphore(8)\n\nasync def bounded_fetch(url):\n async with sem:\n return await fetch(url)\n\nasync with asyncio.TaskGroup() as group:\n for url in urls:\n group.create_task(bounded_fetch(url))"}, + "performance.typescript.await-in-loop": {Kind: guided, Text: "Run independent iterations concurrently with Promise.all.\n\nBefore:\nfor (const id of ids) {\n const user = await fetchUser(id); // serial round-trips\n users.push(user);\n}\n\nAfter:\nconst users = await Promise.all(ids.map((id) => fetchUser(id)));\n// keep the loop only when each iteration depends on the previous one"}, + "performance.javascript.await-in-loop": {Kind: guided, Text: "Run independent iterations concurrently with Promise.all.\n\nBefore:\nfor (const id of ids) {\n const user = await fetchUser(id); // serial round-trips\n users.push(user);\n}\n\nAfter:\nconst users = await Promise.all(ids.map((id) => fetchUser(id)));\n// keep the loop only when each iteration depends on the previous one"}, + "performance.typescript.timer-listener-leak": {Kind: guided, Text: "Store timer handles and clean up listeners on teardown.\n\nBefore:\nsetInterval(poll, 1000); // no clearInterval anywhere\nfor (const el of items) {\n el.addEventListener(\"click\", onClick);\n}\n\nAfter:\nconst handle = setInterval(poll, 1000);\nconst controller = new AbortController();\nfor (const el of items) {\n el.addEventListener(\"click\", onClick, { signal: controller.signal });\n}\n// on teardown:\nclearInterval(handle);\ncontroller.abort();"}, + "performance.javascript.timer-listener-leak": {Kind: guided, Text: "Store timer handles and clean up listeners on teardown.\n\nBefore:\nsetInterval(poll, 1000); // no clearInterval anywhere\nfor (const el of items) {\n el.addEventListener(\"click\", onClick);\n}\n\nAfter:\nconst handle = setInterval(poll, 1000);\nconst controller = new AbortController();\nfor (const el of items) {\n el.addEventListener(\"click\", onClick, { signal: controller.signal });\n}\n// on teardown:\nclearInterval(handle);\ncontroller.abort();"}, } From 10bac0da161844bf9456ebe25503e5e31284f74e Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 22 Oct 2026 14:16:05 -0400 Subject: [PATCH 15/16] fix(rules): stop an escaped newline rendering as a line break in explain The flush-in-loop description embedded '\n' as a Go escape, so `codeguard explain` printed a real line break mid-sentence. Describe the character in words instead. Co-Authored-By: Claude Opus 5 (1M context) --- internal/codeguard/rules/catalog_performance.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/codeguard/rules/catalog_performance.go b/internal/codeguard/rules/catalog_performance.go index 9e93c984..8d09472e 100644 --- a/internal/codeguard/rules/catalog_performance.go +++ b/internal/codeguard/rules/catalog_performance.go @@ -205,7 +205,7 @@ var performanceCatalog = map[string]core.RuleMetadata{ ExecutionModel: core.RuleExecutionModelLanguageAgnostic, LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageCPP), Title: "C++ stream flush inside loop", - Description: "Warns when std::endl or std::flush is written inside a C++ loop, defeating stream buffering and often dominating hot-path latency (performance_rules.detect_hot_path_patterns). Ordinary newline writes such as '\n' are not flagged.", + Description: "Warns when std::endl or std::flush is written inside a C++ loop, defeating stream buffering and often dominating hot-path latency (performance_rules.detect_hot_path_patterns). Ordinary newline writes such as a plain newline character are not flagged.", HowToFix: "Write a newline and flush once after the loop, unless immediate delivery is required.", }, "performance.cpp.sleep-in-loop": { From 5f15b5fcac5e2b25a94709a549797df33acf1ef2 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Thu, 22 Oct 2026 14:39:39 -0400 Subject: [PATCH 16/16] perf(security): gate the per-line security regexes behind literal pre-checks The per-line detectors in checks/security are (?i)/\b alternations, the shape RE2 cannot accelerate: a leading \b or (?i) defeats its literal-prefix fast path, so the engine walks every line for every pattern of every scanned file. A CPU profile of a full scan across seven real repositories put appendOWASPExtraLineFindings, isInsecureDeserialization and foldConcatenatedLiterals at the top of regexp.MatchString time. Apply the technique the secret scanner already uses (builtinGatePasses): guard each regex with a cheap literal pre-check, since a line holding none of a pattern's required literals cannot match it. Covered: the OWASP-gap line rules (CORS, debug-enabled, bind-all, weak-hash, weak-cipher, insecure deserialization, Dockerfile root), the Go line-scan fallback (which runs on every line of every non-Go file), the Python/Rust/Java/C#/Ruby/C++/TypeScript per-language line rules, and the concatenated-literal fold in the secret scanner. Supporting changes: - sourceLine carries a line's raw and masked forms with a precomputed ASCII flag, so the non-ASCII scan the fold gates need is paid once per line rather than once per pattern. Lines holding a non-ASCII byte always pass a fold gate: Go's (?i) folds s->U+017F and k->U+212A, and admitting those lines costs a wasted regex run where modelling them wrongly would cost a finding. - appendOWASPExtraLineFindings strips a trailing comment only when some gate admits a pattern. The stripped line is a prefix of the raw line, so a gate that rejects the raw line rejects the stripped one too. - appendLanguageLineFindings returns nil instead of preallocating a three-element slice per line, which was ~769 MB of immediately-discarded allocations across the seven-repository scan. The gates must be supersets of the patterns they guard, or a rejected line silently never reaches the regex and the rule goes quiet. Two mechanisms enforce that: gatedPattern owns its *regexp.Regexp so it cannot be evaluated ungated, and newGatedPattern registers every instance, which TestSecurityGatedPatternsAreSupersets walks. That test derives sample lines from each pattern's own regexp/syntax tree -- every alternation branch, every member of a small character class, the zero/one repetition forms of optional elements -- and asserts the gate admits every sample the pattern matches, so a new pattern or branch is checked without anyone maintaining a literal list. The same generator now backs TestBuiltinGateCoversPatterns for the secret scanner's gate, which the architecture notes described but which did not exist. Findings are unchanged: full JSON finding sets (rule id, path, line, column, level, severity, confidence, fingerprint, message) over seven real repositories are byte-identical before and after, and the ground-truth corpus scoreboard holds at 71 TP / 0 FN / 1 FP. Co-Authored-By: Claude Opus 5 (1M context) --- .../security/security_additional_languages.go | 85 ++++-- .../checks/security/security_common.go | 76 +++-- .../codeguard/checks/security/security_cpp.go | 18 +- .../checks/security/security_line_gates.go | 153 ++++++++++ .../checks/security/security_owasp_extra.go | 73 +++-- .../security/security_owasp_extra_test.go | 272 +++++++++++++++++- .../security/security_secret_patterns_test.go | 24 ++ .../checks/security/security_secrets.go | 15 +- .../checks/security/security_typescript.go | 23 +- 9 files changed, 641 insertions(+), 98 deletions(-) create mode 100644 internal/codeguard/checks/security/security_line_gates.go diff --git a/internal/codeguard/checks/security/security_additional_languages.go b/internal/codeguard/checks/security/security_additional_languages.go index e1ec631a..499f570f 100644 --- a/internal/codeguard/checks/security/security_additional_languages.go +++ b/internal/codeguard/checks/security/security_additional_languages.go @@ -2,38 +2,60 @@ package security import ( "path/filepath" - "regexp" "strings" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" ) +// Every pattern below is a gatedPattern: a cheap literal pre-check runs before +// the regex, because these run on every line of a scanned file in the matching +// language. See security_line_gates.go for the superset invariant. var ( - rustShellPattern = regexp.MustCompile(`\b(?:std::process::)?Command::new\s*\(\s*"(?:sh|bash|zsh|fish|cmd|powershell|pwsh)"`) - rustShellCodePattern = regexp.MustCompile(`\b(?:std::process::)?Command::new\s*\(`) - rustTLSPattern = regexp.MustCompile(`\bdanger_accept_invalid_(?:certs|hostnames)\s*\(\s*true\s*\)`) - javaShellPattern = regexp.MustCompile(`\b(?:Runtime\.getRuntime\(\)\.exec|new\s+ProcessBuilder)\s*\(`) - javaTLSPattern = regexp.MustCompile(`\b(?:NoopHostnameVerifier\.INSTANCE|ALLOW_ALL_HOSTNAME_VERIFIER|TrustAllStrategy\.INSTANCE)\b|setHostnameVerifier\s*\(.*->\s*true`) - csharpShellPattern = regexp.MustCompile(`\b(?:Process\.Start|new\s+ProcessStartInfo)\s*\(`) - csharpTLSPattern = regexp.MustCompile(`\bDangerousAcceptAnyServerCertificateValidator\b|ServerCertificateCustomValidationCallback\s*=\s*[^;=]*=>\s*true`) - rubyShellPattern = regexp.MustCompile(`\b(?:system|exec|spawn)\s*\(|\bOpen3\.(?:capture2|capture2e|capture3|pipeline|pipeline_r|pipeline_rw|pipeline_start|popen2|popen2e|popen3)\s*\(`) - rubyTLSPattern = regexp.MustCompile(`\bVERIFY_NONE\b`) - rubyEvalPattern = regexp.MustCompile(`\b(?:eval|instance_eval|class_eval)\s*\(`) + rustShellPattern = newGatedPattern("rust.shell-execution.literal", + `\b(?:std::process::)?Command::new\s*\(\s*"(?:sh|bash|zsh|fish|cmd|powershell|pwsh)"`, + exactGate("Command::new")) + rustShellCodePattern = newGatedPattern("rust.shell-execution.call", + `\b(?:std::process::)?Command::new\s*\(`, + exactGate("Command::new")) + rustTLSPattern = newGatedPattern("rust.insecure-tls", + `\bdanger_accept_invalid_(?:certs|hostnames)\s*\(\s*true\s*\)`, + exactGate("danger_accept_invalid_")) + javaShellPattern = newGatedPattern("java.shell-execution", + `\b(?:Runtime\.getRuntime\(\)\.exec|new\s+ProcessBuilder)\s*\(`, + exactGate("Runtime.getRuntime", "ProcessBuilder")) + javaTLSPattern = newGatedPattern("java.insecure-tls", + `\b(?:NoopHostnameVerifier\.INSTANCE|ALLOW_ALL_HOSTNAME_VERIFIER|TrustAllStrategy\.INSTANCE)\b|setHostnameVerifier\s*\(.*->\s*true`, + exactGate("NoopHostnameVerifier", "ALLOW_ALL_HOSTNAME_VERIFIER", "TrustAllStrategy", "setHostnameVerifier")) + csharpShellPattern = newGatedPattern("csharp.shell-execution", + `\b(?:Process\.Start|new\s+ProcessStartInfo)\s*\(`, + exactGate("Process.Start", "ProcessStartInfo")) + csharpTLSPattern = newGatedPattern("csharp.insecure-tls", + `\bDangerousAcceptAnyServerCertificateValidator\b|ServerCertificateCustomValidationCallback\s*=\s*[^;=]*=>\s*true`, + exactGate("DangerousAcceptAnyServerCertificateValidator", "ServerCertificateCustomValidationCallback")) + rubyShellPattern = newGatedPattern("ruby.shell-execution", + `\b(?:system|exec|spawn)\s*\(|\bOpen3\.(?:capture2|capture2e|capture3|pipeline|pipeline_r|pipeline_rw|pipeline_start|popen2|popen2e|popen3)\s*\(`, + exactGate("system", "exec", "spawn", "Open3.")) + rubyTLSPattern = newGatedPattern("ruby.insecure-tls", + `\bVERIFY_NONE\b`, + exactGate("VERIFY_NONE")) + rubyEvalPattern = newGatedPattern("ruby.dynamic-code", + `\b(?:eval|instance_eval|class_eval)\s*\(`, + exactGate("eval")) ) -func appendAdditionalLanguageLineFindings(env support.Context, file string, lineNo int, raw string, masked string) []core.Finding { +func appendAdditionalLanguageLineFindings(env support.Context, file string, lineNo int, line sourceLine) []core.Finding { switch { case isRustFile(file): - return appendRustLineFindings(env, file, lineNo, raw, masked) + return appendRustLineFindings(env, file, lineNo, line) case isCPPFile(file): - return appendCPPLineFindings(env, file, lineNo, masked) + return appendCPPLineFindings(env, file, lineNo, line) case isJavaFile(file): - return appendJavaLineFindings(env, file, lineNo, masked) + return appendJavaLineFindings(env, file, lineNo, line) case isCSharpFile(file): - return appendCSharpLineFindings(env, file, lineNo, masked) + return appendCSharpLineFindings(env, file, lineNo, line) case isRubyFile(file): - return appendRubyLineFindings(env, file, lineNo, raw) + return appendRubyLineFindings(env, file, lineNo, line) default: return nil } @@ -42,46 +64,49 @@ func appendAdditionalLanguageLineFindings(env support.Context, file string, line // appendRustLineFindings matches the call structure on the masked line, then // reads the interpreter name from the raw line since string contents are // blanked by masking. -func appendRustLineFindings(env support.Context, file string, lineNo int, raw string, masked string) []core.Finding { +func appendRustLineFindings(env support.Context, file string, lineNo int, line sourceLine) []core.Finding { switch { - case rustTLSPattern.MatchString(masked): + case rustTLSPattern.matchString(line.masked, line.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.rust.insecure-tls", Level: "fail", Path: file, Line: lineNo, Column: 1, Message: "Rust TLS verification is disabled"})} - case rustShellCodePattern.MatchString(masked) && rustShellPattern.MatchString(raw): + case rustShellCodePattern.matchString(line.masked, line.ascii) && rustShellPattern.matchString(line.raw, line.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.rust.shell-execution", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "Rust shell execution primitive should be reviewed"})} default: return nil } } -func appendJavaLineFindings(env support.Context, file string, lineNo int, line string) []core.Finding { +func appendJavaLineFindings(env support.Context, file string, lineNo int, source sourceLine) []core.Finding { + line := source.masked switch { - case javaTLSPattern.MatchString(line): + case javaTLSPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.java.insecure-tls", Level: "fail", Path: file, Line: lineNo, Column: 1, Message: "Java TLS verification is disabled"})} - case javaShellPattern.MatchString(line): + case javaShellPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.java.shell-execution", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "Java shell execution primitive should be reviewed"})} default: return nil } } -func appendCSharpLineFindings(env support.Context, file string, lineNo int, line string) []core.Finding { +func appendCSharpLineFindings(env support.Context, file string, lineNo int, source sourceLine) []core.Finding { + line := source.masked switch { - case csharpTLSPattern.MatchString(line): + case csharpTLSPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.csharp.insecure-tls", Level: "fail", Path: file, Line: lineNo, Column: 1, Message: "C# TLS verification is disabled"})} - case csharpShellPattern.MatchString(line): + case csharpShellPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.csharp.shell-execution", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "C# shell execution primitive should be reviewed"})} default: return nil } } -func appendRubyLineFindings(env support.Context, file string, lineNo int, line string) []core.Finding { +func appendRubyLineFindings(env support.Context, file string, lineNo int, source sourceLine) []core.Finding { + line := source.raw switch { - case rubyTLSPattern.MatchString(line): + case rubyTLSPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.ruby.insecure-tls", Level: "fail", Path: file, Line: lineNo, Column: 1, Message: "Ruby TLS verification is disabled"})} - case rubyEvalPattern.MatchString(line): + case rubyEvalPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.ruby.dynamic-code", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "dynamic code execution should be reviewed"})} - case rubyShellPattern.MatchString(line): + case rubyShellPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.ruby.shell-execution", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "Ruby shell execution primitive should be reviewed"})} default: return nil diff --git a/internal/codeguard/checks/security/security_common.go b/internal/codeguard/checks/security/security_common.go index 67717be5..7fa3f227 100644 --- a/internal/codeguard/checks/security/security_common.go +++ b/internal/codeguard/checks/security/security_common.go @@ -2,7 +2,6 @@ package security import ( "path/filepath" - "regexp" "strings" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" @@ -12,18 +11,35 @@ import ( // Hardcoded secret/credential and private-key detection lives in // security_secrets.go, which runs as a dedicated repository-wide pass so it also // covers TypeScript/JavaScript targets. +// +// Every pattern below is a gatedPattern: a cheap literal pre-check runs before +// the regex, because these run on every line of every scanned file. See +// security_line_gates.go for the superset invariant the gates must hold to. var ( - pythonShellPattern = regexp.MustCompile(`\bsubprocess\.(?:run|Popen|call|check_call|check_output|getoutput|getstatusoutput)\s*\(.*shell\s*=\s*True`) - pythonSystemPattern = regexp.MustCompile(`\bos\.system\s*\(`) - pythonEvalPattern = regexp.MustCompile(`\b(?:eval|exec)\s*\(`) - pythonInsecureTLSPattern = regexp.MustCompile(`\bverify\s*=\s*False\b|\bssl\._create_unverified_context\s*\(`) + pythonShellPattern = newGatedPattern("python.shell-execution", + `\bsubprocess\.(?:run|Popen|call|check_call|check_output|getoutput|getstatusoutput)\s*\(.*shell\s*=\s*True`, + exactGate("subprocess.")) + pythonSystemPattern = newGatedPattern("python.os-system", + `\bos\.system\s*\(`, + exactGate("os.system")) + pythonEvalPattern = newGatedPattern("python.dynamic-code", + `\b(?:eval|exec)\s*\(`, + exactGate("eval", "exec")) + pythonInsecureTLSPattern = newGatedPattern("python.insecure-tls", + `\bverify\s*=\s*False\b|\bssl\._create_unverified_context\s*\(`, + exactGate("verify", "ssl._create_unverified_context")) // Line-scan forms of the Go-oriented base checks, matched against masked // source. They tolerate missing whitespace (InsecureSkipVerify:true) and // require a call shape for shell execution so import paths alone cannot - // fire. - goInsecureTLSLinePattern = regexp.MustCompile(`\bInsecureSkipVerify\s*[:=]\s*true\b`) - goShellCallLinePattern = regexp.MustCompile(`\b(?:exec\.Command(?:Context)?|syscall\.Exec)\s*\(`) + // fire. NOTE: these run on every line of every NON-Go file too (goParsed is + // false for them), which is why their gate matters as much as the Go one. + goInsecureTLSLinePattern = newGatedPattern("go.insecure-tls", + `\bInsecureSkipVerify\s*[:=]\s*true\b`, + exactGate("InsecureSkipVerify")) + goShellCallLinePattern = newGatedPattern("go.shell-execution", + `\b(?:exec\.Command(?:Context)?|syscall\.Exec)\s*\(`, + exactGate("exec.Command", "syscall.Exec")) ) func findingsForFile(env support.Context, file string, data []byte) []core.Finding { @@ -41,13 +57,17 @@ func findingsForFile(env support.Context, file string, data []byte) []core.Findi findings = append(findings, goFindings...) } - for idx, line := range lines { + for idx, raw := range lines { lineNo := idx + 1 + // One sourceLine per line: it carries the raw and masked forms together + // with the ASCII flag the literal gates read, so the scan for non-ASCII + // bytes is paid once per line rather than once per pattern. + line := newSourceLine(raw, maskedLines[idx]) if !goParsed { - findings = append(findings, appendCommonLineFindings(env, file, lineNo, maskedLines[idx])...) + findings = append(findings, appendCommonLineFindings(env, file, lineNo, line)...) } - findings = append(findings, appendLanguageLineFindings(env, file, lineNo, line, maskedLines[idx])...) - findings = append(findings, appendOWASPExtraLineFindings(env, file, lineNo, line, maskedLines[idx])...) + findings = append(findings, appendLanguageLineFindings(env, file, lineNo, line)...) + findings = append(findings, appendOWASPExtraLineFindings(env, file, lineNo, line)...) } if isCPPFile(file) { findings = append(findings, appendCPPFileFindings(env, file, strings.Join(maskedLines, "\n"))...) @@ -84,11 +104,12 @@ func maskedSourceForFile(file string, source string) string { // files go through the AST pass in security_go_ast.go); it always receives the // masked line, so comments and string literals cannot fire where a masker // exists for the language. -func appendCommonLineFindings(env support.Context, file string, lineNo int, line string) []core.Finding { +func appendCommonLineFindings(env support.Context, file string, lineNo int, source sourceLine) []core.Finding { + line := source.masked switch { - case goInsecureTLSLinePattern.MatchString(line): + case goInsecureTLSLinePattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.insecure-tls", Level: "fail", Path: file, Line: lineNo, Column: 1, Message: "InsecureSkipVerify is enabled"})} - case goShellCallLinePattern.MatchString(line): + case goShellCallLinePattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.shell-execution", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "shell execution primitive should be reviewed"})} default: return nil @@ -98,25 +119,30 @@ func appendCommonLineFindings(env support.Context, file string, lineNo int, line // appendLanguageLineFindings matches structural patterns against the masked // line so comments and string contents cannot trigger findings, while // patterns that must read literal values receive the raw line. -func appendLanguageLineFindings(env support.Context, file string, lineNo int, raw string, masked string) []core.Finding { - findings := make([]core.Finding, 0, 3) +// +// PERF: it returns a nil slice when nothing fires. The previous +// make([]core.Finding, 0, 3) allocated a three-element backing array for every +// line of every scanned file — ~769 MB of allocations across a seven-repository +// scan, almost all of it immediately discarded. +func appendLanguageLineFindings(env support.Context, file string, lineNo int, line sourceLine) []core.Finding { + var findings []core.Finding if isTypeScriptFile(file) { - findings = append(findings, appendTypeScriptLineFindings(env, file, lineNo, raw)...) + findings = append(findings, appendTypeScriptLineFindings(env, file, lineNo, line)...) } if isPythonFile(file) { - findings = append(findings, appendPythonLineFindings(env, file, lineNo, masked)...) + findings = append(findings, appendPythonLineFindings(env, file, lineNo, line)...) } - findings = append(findings, appendAdditionalLanguageLineFindings(env, file, lineNo, raw, masked)...) - return findings + return append(findings, appendAdditionalLanguageLineFindings(env, file, lineNo, line)...) } -func appendPythonLineFindings(env support.Context, file string, lineNo int, line string) []core.Finding { +func appendPythonLineFindings(env support.Context, file string, lineNo int, source sourceLine) []core.Finding { + line := source.masked switch { - case pythonInsecureTLSPattern.MatchString(line): + case pythonInsecureTLSPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.python.insecure-tls", Level: "fail", Path: file, Line: lineNo, Column: 1, Message: "Python TLS verification is disabled"})} - case pythonShellPattern.MatchString(line) || pythonSystemPattern.MatchString(line): + case pythonShellPattern.matchString(line, source.ascii) || pythonSystemPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.python.shell-execution", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "Python shell execution primitive should be reviewed"})} - case pythonEvalPattern.MatchString(line): + case pythonEvalPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.python.dynamic-code", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "dynamic code execution should be reviewed"})} default: return nil diff --git a/internal/codeguard/checks/security/security_cpp.go b/internal/codeguard/checks/security/security_cpp.go index 935b2364..58d928a1 100644 --- a/internal/codeguard/checks/security/security_cpp.go +++ b/internal/codeguard/checks/security/security_cpp.go @@ -9,18 +9,26 @@ import ( ) var ( - cppShellPattern = regexp.MustCompile(`\b(?:(?:std\s*::\s*)?system|popen|_popen)\s*\(`) + // Gated: these two run on every line of every C/C++ file. The whole-file + // TLS patterns below are not gated — they run once per file through + // FindAllStringIndex, so the per-line win does not apply to them. + cppShellPattern = newGatedPattern("cpp.shell-execution", + `\b(?:(?:std\s*::\s*)?system|popen|_popen)\s*\(`, + exactGate("system", "popen")) cppCurlTLSPattern = regexp.MustCompile(`\bcurl_easy_setopt\s*\([^;]*(?:CURLOPT_SSL_VERIFYPEER|CURLOPT_SSL_VERIFYHOST)\s*,\s*(?:0(?:[uUlL]*)?|false)\b`) cppOpenSSLTLSPattern = regexp.MustCompile(`\bSSL_CTX_set_verify\s*\([^;]*\bSSL_VERIFY_NONE\b`) cppVerifyNonePattern = regexp.MustCompile(`\b(?:set_verify_mode\s*\([^;]*\bverify_none\b|set_validate_certificates\s*\(\s*false\b)`) - cppUnsafeCAPIPattern = regexp.MustCompile(`\b(?:gets|strcpy|strcat|sprintf|vsprintf)\s*\(`) + cppUnsafeCAPIPattern = newGatedPattern("cpp.unsafe-c-api", + `\b(?:gets|strcpy|strcat|sprintf|vsprintf)\s*\(`, + exactGate("gets", "strcpy", "strcat", "sprintf")) ) -func appendCPPLineFindings(env support.Context, file string, lineNo int, line string) []core.Finding { +func appendCPPLineFindings(env support.Context, file string, lineNo int, source sourceLine) []core.Finding { + line := source.masked switch { - case cppShellPattern.MatchString(line): + case cppShellPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.cpp.shell-execution", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "C++ shell execution primitive should be reviewed"})} - case cppUnsafeCAPIPattern.MatchString(line): + case cppUnsafeCAPIPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: "security.cpp.unsafe-c-api", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "unbounded C string API should be replaced with a bounds-aware operation"})} default: return nil diff --git a/internal/codeguard/checks/security/security_line_gates.go b/internal/codeguard/checks/security/security_line_gates.go new file mode 100644 index 00000000..97957e6e --- /dev/null +++ b/internal/codeguard/checks/security/security_line_gates.go @@ -0,0 +1,153 @@ +package security + +import ( + "regexp" + "strings" +) + +// PERF: the detectors in this package that run once per source line are +// (?i)/\b alternations, which is exactly the shape RE2 cannot accelerate — a +// leading \b or (?i) defeats its literal-prefix fast path, so the engine walks +// the whole line for every pattern on every line of every scanned file. A CPU +// profile of a full scan across seven real repositories put +// appendOWASPExtraLineFindings, isInsecureDeserialization and +// foldConcatenatedLiterals at the top of regexp.MatchString time. +// +// The fix is the one the secret scanner already uses (builtinGatePasses in +// security_secret_patterns.go): guard every regex with a cheap literal +// pre-check. strings.Contains is roughly 85x faster than the equivalent +// alternation, and a line holding none of a pattern's required literals cannot +// match it. +// +// INVARIANT: a gate must be a SUPERSET of the pattern it guards. A line the +// gate rejects never reaches the regex, so a missing literal silently disables +// a security rule — a false negative, the worst failure mode for a scanner. +// Two things keep that honest: +// +// - gatedPattern owns its *regexp.Regexp, and matchString is the only way to +// evaluate it, so a pattern cannot be run ungated by accident. +// - newGatedPattern registers every instance in gatedPatterns, and +// TestSecurityGatedPatternsAreSupersets walks that registry, deriving +// concrete sample lines from each pattern's own regexp/syntax tree and +// asserting the gate admits every one the pattern matches. A new pattern, +// or a new alternation branch in an existing one, is checked automatically; +// nothing about the test is hand-maintained. + +// gatedPattern is a line regex plus the literal markers a line must contain for +// the regex to have any chance of matching. +// +// foldLiterals are compared ASCII-case-insensitively and must be given in +// lowercase; they are the markers for (?i) patterns. exactLiterals are compared +// byte-for-byte and are the markers for case-sensitive patterns. A line passes +// the gate when it contains any marker from either set. +type gatedPattern struct { + name string + re *regexp.Regexp + foldLiterals []string + exactLiterals []string +} + +// gatedPatterns holds every gated pattern in the package. newGatedPattern +// appends to it during package variable initialization, which is what makes +// the gate-coverage test exhaustive by construction rather than by review. +var gatedPatterns []*gatedPattern + +// patternGate describes the literal markers for one pattern. Build one with +// foldGate or exactGate. +type patternGate struct { + fold []string + exact []string +} + +// foldGate declares markers matched case-insensitively; literals must be +// lowercase ASCII. Use it for patterns carrying (?i). +func foldGate(literals ...string) patternGate { return patternGate{fold: literals} } + +// exactGate declares byte-exact markers. Use it for case-sensitive patterns. +func exactGate(literals ...string) patternGate { return patternGate{exact: literals} } + +func newGatedPattern(name string, expr string, gate patternGate) *gatedPattern { + pattern := &gatedPattern{ + name: name, + re: regexp.MustCompile(expr), + foldLiterals: gate.fold, + exactLiterals: gate.exact, + } + gatedPatterns = append(gatedPatterns, pattern) + return pattern +} + +// matchString reports whether line matches, running the regex only for lines +// the literal gate admits. +// +// ascii says whether line is known to hold no byte >= 0x80. Callers that test +// several patterns against the same line compute it once (see sourceLine); +// passing false is always safe because it only widens the gate. +func (p *gatedPattern) matchString(line string, ascii bool) bool { + return p.gatePasses(line, ascii) && p.re.MatchString(line) +} + +// gatePasses reports whether line could match. False positives cost a wasted +// regex run; false negatives would cost a finding, so every uncertainty here +// resolves to true. +func (p *gatedPattern) gatePasses(line string, ascii bool) bool { + for _, literal := range p.exactLiterals { + if strings.Contains(line, literal) { + return true + } + } + if len(p.foldLiterals) == 0 { + return false + } + // Go's (?i) uses Unicode simple folding, so `s` also matches U+017F and `k` + // also matches U+212A. Rather than model that, any line holding a non-ASCII + // byte is admitted: a wasted regex run costs time, a skipped one costs a + // security finding. Source lines are ASCII in practice, so the gate still + // does its job. This mirrors quality.containsAnyFold. + if !ascii { + return true + } + for _, literal := range p.foldLiterals { + if asciiContainsFold(line, literal) { + return true + } + } + return false +} + +// anyGatePasses reports whether any of patterns admits line. It lets a caller +// skip work that is only needed when some pattern could fire. +func anyGatePasses(line string, ascii bool, patterns ...*gatedPattern) bool { + for _, pattern := range patterns { + if pattern.gatePasses(line, ascii) { + return true + } + } + return false +} + +// sourceLine is one source line in both the forms the per-line detectors need, +// plus the ASCII flag the literal gates consume. +// +// Build it with newSourceLine: the flag is true only when BOTH forms hold +// nothing but ASCII, so every gate reading it stays a superset of its pattern +// even though masked and raw are scanned independently. +type sourceLine struct { + raw string + masked string + ascii bool +} + +func newSourceLine(raw string, masked string) sourceLine { + return sourceLine{raw: raw, masked: masked, ascii: asciiOnly(raw) && asciiOnly(masked)} +} + +// asciiOnly reports whether s holds no byte >= 0x80. +func asciiOnly(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] >= 0x80 { + return false + } + } + return true +} diff --git a/internal/codeguard/checks/security/security_owasp_extra.go b/internal/codeguard/checks/security/security_owasp_extra.go index ffea3d47..a252f52f 100644 --- a/internal/codeguard/checks/security/security_owasp_extra.go +++ b/internal/codeguard/checks/security/security_owasp_extra.go @@ -2,58 +2,82 @@ package security import ( "path/filepath" - "regexp" "strings" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" ) +// Every pattern below is a gatedPattern: a cheap literal pre-check runs before +// the regex, because these run on every line of every scanned file. See +// security_line_gates.go for the superset invariant the gates must hold to. var ( // String-literal based: matched on the raw line because the signal lives // inside string contents that source masking would blank. - corsWildcardPattern = regexp.MustCompile(`(?i)access-control-allow-origin["']?\s*[:,=]\s*["']?\*`) - bindAllPattern = regexp.MustCompile(`\b0\.0\.0\.0\b`) - dockerfileUserRoot = regexp.MustCompile(`(?i)^\s*USER\s+root\b`) + corsWildcardPattern = newGatedPattern("cors-wildcard", + `(?i)access-control-allow-origin["']?\s*[:,=]\s*["']?\*`, + foldGate("access-control-allow-origin")) + bindAllPattern = newGatedPattern("bind-all-interfaces", + `\b0\.0\.0\.0\b`, + exactGate("0.0.0.0")) + dockerfileUserRoot = newGatedPattern("dockerfile-root", + `(?i)^\s*USER\s+root\b`, + foldGate("root")) // Code/identifier based: matched on the raw line so string algorithm names // (e.g. getInstance("MD5")) and Go import paths (e.g. "crypto/md5"), which // masking would blank, are still visible. Known trade-off: comments that // mention these APIs can trigger the weak-hash/weak-cipher warns. - debugEnabledPattern = regexp.MustCompile(`\bdebug\s*=\s*True\b`) - weakHashPattern = regexp.MustCompile(`(?i)\bhashlib\.(?:md5|sha1)\s*\(|\b(?:md5|sha1)\.New\s*\(|crypto/(?:md5|sha1)|messagedigest\.getinstance\s*\(\s*"(?:md5|sha-?1)"|createhash\s*\(\s*['"](?:md5|sha1)['"]|\bDigest::(?:MD5|SHA1)\b|\b(?:MD5|SHA1)(?:CryptoServiceProvider|Managed)\b`) - weakCipherPattern = regexp.MustCompile(`(?i)crypto/(?:des|rc4)|cipher\.getinstance\s*\(\s*"(?:des|desede|rc4|[^"]*ecb[^"]*)"|createcipheriv\s*\(\s*['"](?:des|des-ede3|rc4|aes-\d+-ecb)|\bnew\s+(?:DESCryptoServiceProvider|RC2CryptoServiceProvider|TripleDESCryptoServiceProvider)\b|\bMODE_ECB\b`) - deserializePattern = regexp.MustCompile(`(?i)\b(?:pickle|cpickle)\.loads?\s*\(|\bmarshal\.loads\s*\(|\byaml\.load\s*\(|\.readObject\s*\(|\bnew\s+ObjectInputStream\b|\bXMLDecoder\b|\bMarshal\.load\s*\(|\bunserialize\s*\(`) + debugEnabledPattern = newGatedPattern("debug-enabled", + `\bdebug\s*=\s*True\b`, + exactGate("debug")) + weakHashPattern = newGatedPattern("weak-hash", + `(?i)\bhashlib\.(?:md5|sha1)\s*\(|\b(?:md5|sha1)\.New\s*\(|crypto/(?:md5|sha1)|messagedigest\.getinstance\s*\(\s*"(?:md5|sha-?1)"|createhash\s*\(\s*['"](?:md5|sha1)['"]|\bDigest::(?:MD5|SHA1)\b|\b(?:MD5|SHA1)(?:CryptoServiceProvider|Managed)\b`, + foldGate("md5", "sha1", "sha-1")) + weakCipherPattern = newGatedPattern("weak-cipher", + `(?i)crypto/(?:des|rc4)|cipher\.getinstance\s*\(\s*"(?:des|desede|rc4|[^"]*ecb[^"]*)"|createcipheriv\s*\(\s*['"](?:des|des-ede3|rc4|aes-\d+-ecb)|\bnew\s+(?:DESCryptoServiceProvider|RC2CryptoServiceProvider|TripleDESCryptoServiceProvider)\b|\bMODE_ECB\b`, + foldGate("crypto/des", "crypto/rc4", "cipher.getinstance", "createcipheriv", "cryptoserviceprovider", "mode_ecb")) + deserializePattern = newGatedPattern("insecure-deserialization", + `(?i)\b(?:pickle|cpickle)\.loads?\s*\(|\bmarshal\.loads\s*\(|\byaml\.load\s*\(|\.readObject\s*\(|\bnew\s+ObjectInputStream\b|\bXMLDecoder\b|\bMarshal\.load\s*\(|\bunserialize\s*\(`, + foldGate("pickle.load", "marshal.load", "yaml.load", ".readobject", "objectinputstream", "xmldecoder", "unserialize")) ) // appendOWASPExtraLineFindings runs the language-agnostic OWASP-gap heuristics // (A05 misconfiguration, A02 weak crypto, A08 insecure deserialization) for one -// source line. raw is the unmodified line; masked has comments and string -// contents blanked where a masker exists for the language. -func appendOWASPExtraLineFindings(env support.Context, file string, lineNo int, raw string, masked string) []core.Finding { - findings := make([]core.Finding, 0) - commentOnly := rawLineCommentOnly(raw, masked) - rawWithoutTrailingComment := stripMaskedTrailingComment(raw, masked) +// source line. +func appendOWASPExtraLineFindings(env support.Context, file string, lineNo int, line sourceLine) []core.Finding { + var findings []core.Finding + commentOnly := rawLineCommentOnly(line.raw, line.masked) - if !commentOnly && corsWildcardPattern.MatchString(rawWithoutTrailingComment) { + // The four raw-line rules read the line with any trailing comment removed. + // Stripping walks the whole line, so it is done only when some gate says a + // pattern could fire: the stripped line is a prefix of the raw line, so a + // gate that rejects the raw line necessarily rejects the stripped one too. + body := "" + bodyScanned := !commentOnly && anyGatePasses(line.raw, line.ascii, corsWildcardPattern, bindAllPattern, weakHashPattern, weakCipherPattern) + if bodyScanned { + body = stripMaskedTrailingComment(line.raw, line.masked) + } + + if bodyScanned && corsWildcardPattern.matchString(body, line.ascii) { findings = append(findings, env.NewFinding(support.FindingInput{RuleID: "security.cors-wildcard", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "wildcard CORS origin '*' allows any site to read responses"})) } - if !commentOnly && bindAllPattern.MatchString(rawWithoutTrailingComment) { + if bodyScanned && bindAllPattern.matchString(body, line.ascii) { findings = append(findings, env.NewFinding(support.FindingInput{RuleID: "security.bind-all-interfaces", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "service binds to 0.0.0.0 (all network interfaces)"})) } - if debugEnabledPattern.MatchString(masked) { + if debugEnabledPattern.matchString(line.masked, line.ascii) { findings = append(findings, env.NewFinding(support.FindingInput{RuleID: "security.debug-enabled", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "debug mode enabled; disable in production"})) } - if !commentOnly && weakHashPattern.MatchString(rawWithoutTrailingComment) { + if bodyScanned && weakHashPattern.matchString(body, line.ascii) { findings = append(findings, env.NewFinding(support.FindingInput{RuleID: "security.weak-hash", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "weak hash algorithm (MD5/SHA-1) should not be used for security"})) } - if !commentOnly && weakCipherPattern.MatchString(rawWithoutTrailingComment) { + if bodyScanned && weakCipherPattern.matchString(body, line.ascii) { findings = append(findings, env.NewFinding(support.FindingInput{RuleID: "security.weak-cipher", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "weak or insecure cipher (DES/RC4/ECB) should be replaced"})) } - if isInsecureDeserialization(raw) { + if isInsecureDeserialization(line.raw, line.ascii) { findings = append(findings, env.NewFinding(support.FindingInput{RuleID: "security.insecure-deserialization", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "insecure deserialization of potentially untrusted data"})) } - if isDockerfile(file) && dockerfileUserRoot.MatchString(raw) { + if isDockerfile(file) && dockerfileUserRoot.matchString(line.raw, line.ascii) { findings = append(findings, env.NewFinding(support.FindingInput{RuleID: "security.dockerfile-root", Level: "warn", Path: file, Line: lineNo, Column: 1, Message: "container runs as root; set a non-root USER"})) } return findings @@ -84,9 +108,10 @@ func stripMaskedTrailingComment(raw string, masked string) string { // isInsecureDeserialization matches dangerous deserialization calls while // excluding explicitly-safe YAML loaders (RE2 has no negative lookahead, so the -// safe case is filtered in code). -func isInsecureDeserialization(line string) bool { - if !deserializePattern.MatchString(line) { +// safe case is filtered in code). The ToLower below is reached only for lines +// the gate already admitted, so the allocation is rare. +func isInsecureDeserialization(line string, ascii bool) bool { + if !deserializePattern.matchString(line, ascii) { return false } lower := strings.ToLower(line) diff --git a/internal/codeguard/checks/security/security_owasp_extra_test.go b/internal/codeguard/checks/security/security_owasp_extra_test.go index c4784da9..83060822 100644 --- a/internal/codeguard/checks/security/security_owasp_extra_test.go +++ b/internal/codeguard/checks/security/security_owasp_extra_test.go @@ -1,6 +1,10 @@ package security import ( + "fmt" + "regexp" + "regexp/syntax" + "strings" "testing" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" @@ -20,7 +24,7 @@ func TestAppendOWASPExtraLineFindingsSuppressesCommentOnlyMatches(t *testing.T) {name: "weak cipher", raw: "/* cipher.getInstance(\"DES\") appears in prose */"}, } { t.Run(tc.name, func(t *testing.T) { - findings := appendOWASPExtraLineFindings(env, "fixture.go", 1, tc.raw, tc.masked) + findings := appendOWASPExtraLineFindings(env, "fixture.go", 1, newSourceLine(tc.raw, tc.masked)) if len(findings) != 0 { t.Fatalf("findings = %#v, want none", findings) } @@ -43,7 +47,7 @@ func TestAppendOWASPExtraLineFindingsKeepsRealMatches(t *testing.T) { {name: "weak cipher", file: "crypto.java", raw: `Cipher.getInstance("DES")`, masked: `Cipher.getInstance("DES")`, rule: "security.weak-cipher"}, } { t.Run(tc.name, func(t *testing.T) { - findings := appendOWASPExtraLineFindings(env, tc.file, 7, tc.raw, tc.masked) + findings := appendOWASPExtraLineFindings(env, tc.file, 7, newSourceLine(tc.raw, tc.masked)) if len(findings) != 1 { t.Fatalf("findings = %#v, want exactly one", findings) } @@ -66,7 +70,7 @@ func TestAppendOWASPExtraLineFindingsSuppressesTrailingCommentMatches(t *testing {name: "bind all", raw: `return nil // 0.0.0.0 example`}, } { t.Run(tc.name, func(t *testing.T) { - findings := appendOWASPExtraLineFindings(env, "fixture.go", 1, tc.raw, maskedSourceForFile("fixture.go", tc.raw)) + findings := appendOWASPExtraLineFindings(env, "fixture.go", 1, newSourceLine(tc.raw, maskedSourceForFile("fixture.go", tc.raw))) if len(findings) != 0 { t.Fatalf("findings = %#v, want none", findings) } @@ -106,3 +110,265 @@ func testSecurityContext() support.Context { } }} } + +// --- literal gate coverage ------------------------------------------------- +// +// These tests are the enforcement half of the superset invariant documented in +// security_line_gates.go: every gate must admit every line its pattern can +// match, because a line the gate rejects never reaches the regex and a missed +// match is a silent false negative in a security scanner. +// +// They are deliberately mechanical. Rather than list the literals a pattern +// requires (a list that drifts the moment someone adds an alternation branch), +// regexpSampleBranches reads the pattern's own regexp/syntax tree and +// enumerates concrete strings it accepts: every alternation branch, every +// member of a small character class (the parser factors `(?:ghp|gho|ghu)` into +// `gh[opu]`, so branches hide there), the zero- and one-repetition forms of +// every optional element, and the minimum plus one of every bounded one. +// Adding a pattern or a branch therefore extends the test automatically. +// +// The expansion is an under-approximation of the pattern's language, so it is +// evidence rather than proof — but it is evidence that regenerates itself, and +// it catches the realistic failure: a new branch whose marker nobody added to +// the gate. + +func TestSecurityGatedPatternsAreSupersets(t *testing.T) { + if len(gatedPatterns) == 0 { + t.Fatal("no gated patterns registered; newGatedPattern is the only way to build one") + } + seen := map[string]bool{} + for _, pattern := range gatedPatterns { + if seen[pattern.name] { + t.Fatalf("duplicate gated pattern name %q", pattern.name) + } + seen[pattern.name] = true + if len(pattern.foldLiterals) == 0 && len(pattern.exactLiterals) == 0 { + t.Fatalf("gated pattern %q declares no literals, so its gate rejects every line", pattern.name) + } + for _, literal := range pattern.foldLiterals { + if literal != strings.ToLower(literal) { + t.Fatalf("gated pattern %q fold literal %q must be lowercase", pattern.name, literal) + } + } + t.Run(pattern.name, func(t *testing.T) { + assertGateAdmitsPattern(t, pattern.re, pattern.name, func(line string) bool { + return pattern.gatePasses(line, asciiOnly(line)) + }) + }) + } +} + +// assertGateAdmitsPattern checks that gate admits every generated line re +// matches, and that every top-level alternation branch contributed at least one +// such line, so a branch the generator cannot reach is reported rather than +// silently vouched for. +func assertGateAdmitsPattern(t *testing.T, re *regexp.Regexp, name string, gate func(string) bool) { + t.Helper() + for idx, samples := range regexpSampleBranches(t, re.String()) { + matched := 0 + for _, sample := range samples { + // Both case folds are tried because a pattern carrying (?i) matches + // either, so a byte-exact gate literal on such a pattern would be a + // real bug. Variants the pattern does not match are skipped. + for _, variant := range []string{sample, strings.ToLower(sample), strings.ToUpper(sample)} { + if !re.MatchString(variant) { + continue + } + matched++ + if !gate(variant) { + t.Fatalf("%s: gate rejects %q, which the pattern matches\npattern: %s\nadd the missing literal to its gate", name, variant, re) + } + } + } + if matched == 0 { + t.Fatalf("%s: alternation branch %d produced no matching sample, so its gate is unverified\npattern: %s", name, idx, re) + } + } +} + +// maxRegexpSamples bounds the expansion. Exceeding it fails the test rather +// than silently sampling, so a pattern that outgrows the generator is noticed. +const maxRegexpSamples = 50000 + +// regexpSampleBranches returns, per top-level alternation branch of expr, the +// concrete strings that branch accepts. +func regexpSampleBranches(t *testing.T, expr string) [][]string { + t.Helper() + parsed, err := syntax.Parse(expr, syntax.Perl) + if err != nil { + t.Fatalf("parse %s: %v", expr, err) + } + branches := []*syntax.Regexp{parsed} + if parsed.Op == syntax.OpAlternate { + branches = parsed.Sub + } + out := make([][]string, 0, len(branches)) + for _, branch := range branches { + samples, err := expandRegexpSamples(branch) + if err != nil { + t.Fatalf("expand %s: %v", expr, err) + } + out = append(out, samples) + } + return out +} + +func expandRegexpSamples(re *syntax.Regexp) ([]string, error) { + switch re.Op { + case syntax.OpEmptyMatch, syntax.OpBeginLine, syntax.OpEndLine, + syntax.OpBeginText, syntax.OpEndText, syntax.OpWordBoundary, syntax.OpNoWordBoundary: + return []string{""}, nil + case syntax.OpNoMatch: + return nil, nil + case syntax.OpLiteral: + return []string{string(re.Rune)}, nil + case syntax.OpCharClass: + runes := classSampleRunes(re.Rune) + if len(runes) == 0 { + return nil, fmt.Errorf("character class with no runes") + } + samples := make([]string, 0, len(runes)) + for _, r := range runes { + samples = append(samples, string(r)) + } + return samples, nil + case syntax.OpAnyChar, syntax.OpAnyCharNotNL: + return []string{"x"}, nil + case syntax.OpCapture: + return expandRegexpSamples(re.Sub[0]) + case syntax.OpConcat: + return expandConcatSamples(re.Sub) + case syntax.OpAlternate: + var out []string + for _, sub := range re.Sub { + samples, err := expandRegexpSamples(sub) + if err != nil { + return nil, err + } + out = append(out, samples...) + } + return cappedSamples(out) + case syntax.OpStar, syntax.OpQuest: + return expandRepeatSamples(re.Sub[0], 0, 1) + case syntax.OpPlus: + return expandRepeatSamples(re.Sub[0], 1, 2) + case syntax.OpRepeat: + high := re.Min + 1 + if re.Max >= 0 && high > re.Max { + high = re.Max + } + return expandRepeatSamples(re.Sub[0], re.Min, high) + default: + return nil, fmt.Errorf("unsupported regexp op %v", re.Op) + } +} + +// expandRepeatSamples covers the counts that can change whether a gate literal +// stays contiguous: none, one, and one more than the minimum. Counts above one +// use a single representative expansion, since all they test is that a literal +// does not span the repeated element. +func expandRepeatSamples(sub *syntax.Regexp, low int, high int) ([]string, error) { + samples, err := expandRegexpSamples(sub) + if err != nil { + return nil, err + } + if len(samples) == 0 { + return []string{""}, nil + } + var out []string + for count := low; count <= high; count++ { + switch count { + case 0: + out = append(out, "") + case 1: + out = append(out, samples...) + default: + out = append(out, strings.Repeat(samples[0], count)) + } + } + return cappedSamples(out) +} + +func expandConcatSamples(subs []*syntax.Regexp) ([]string, error) { + out := []string{""} + for _, sub := range subs { + parts, err := expandRegexpSamples(sub) + if err != nil { + return nil, err + } + if len(parts) == 0 { + parts = []string{""} + } + next := make([]string, 0, len(out)*len(parts)) + for _, prefix := range out { + for _, part := range parts { + next = append(next, prefix+part) + } + } + if out, err = cappedSamples(next); err != nil { + return nil, err + } + } + return out, nil +} + +func cappedSamples(samples []string) ([]string, error) { + if len(samples) > maxRegexpSamples { + return nil, fmt.Errorf("expands to more than %d samples; split the pattern or extend the generator", maxRegexpSamples) + } + return samples, nil +} + +// classSampleRunes returns the runes to generate for a character class. Small +// identifier-shaped classes are expanded in full because the parser factors +// alternations such as (?:ghp|gho|ghu) into gh[opu], and each of those really +// is a separate branch needing its own gate literal. Everything else gets one +// representative rune, preferring a printable ASCII one. +func classSampleRunes(ranges []rune) []rune { + var all []rune + for i := 0; i+1 < len(ranges); i += 2 { + for r := ranges[i]; r <= ranges[i+1]; r++ { + all = append(all, r) + if len(all) > 8 { + return representativeRunes(ranges) + } + } + } + for _, r := range all { + if !isIdentifierRune(r) { + return representativeRunes(ranges) + } + } + if len(all) == 0 { + return nil + } + return all +} + +func isIdentifierRune(r rune) bool { + return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' +} + +// representativeRunes prefers a word character across the whole class before +// settling for any printable one: picking '-' out of [0-9A-Za-z_-] would end a +// generated sample on a non-word byte and silently defeat a trailing \b, which +// makes the sample unmatchable and the gate untested. +func representativeRunes(ranges []rune) []rune { + for _, preferred := range []rune{'a', 'A', '0', ' '} { + for i := 0; i+1 < len(ranges); i += 2 { + if preferred >= ranges[i] && preferred <= ranges[i+1] { + return []rune{preferred} + } + } + } + for i := 0; i+1 < len(ranges); i += 2 { + lo, hi := ranges[i], ranges[i+1] + if r := max(lo, '!'); hi >= '!' && lo <= '~' && r <= hi { + return []rune{r} + } + } + if len(ranges) >= 2 { + return []rune{ranges[0]} + } + return nil +} diff --git a/internal/codeguard/checks/security/security_secret_patterns_test.go b/internal/codeguard/checks/security/security_secret_patterns_test.go index 8978d45e..84d852f1 100644 --- a/internal/codeguard/checks/security/security_secret_patterns_test.go +++ b/internal/codeguard/checks/security/security_secret_patterns_test.go @@ -84,3 +84,27 @@ func requireOneMatch(t *testing.T, matches []Match, rule string, line int) { t.Fatalf("match = %#v, want rule %s at line %d", matches[0], rule, line) } } + +// TestBuiltinGateCoversPatterns is the secret scanner's half of the gate +// superset invariant (see security_line_gates.go): builtinGatePasses must admit +// every line any built-in pattern can match, or scanLine skips the pattern and +// the credential goes unreported. +// +// It reuses the regexp/syntax sample generator from the gated-pattern test, so +// adding a provider format — or a branch to an existing one — is checked +// automatically instead of relying on someone remembering to extend +// gateLiterals. Config-supplied custom_patterns have arbitrary markers and +// deliberately bypass the gate, so they are out of scope here. +func TestBuiltinGateCoversPatterns(t *testing.T) { + t.Run("private-key", func(t *testing.T) { + assertGateAdmitsPattern(t, privateKeyPattern, "privateKeyPattern", builtinGatePasses) + }) + t.Run("name-based", func(t *testing.T) { + assertGateAdmitsPattern(t, secretPattern, "secretPattern", builtinGatePasses) + }) + for _, pattern := range credentialPatterns { + t.Run(pattern.msg, func(t *testing.T) { + assertGateAdmitsPattern(t, pattern.re, pattern.msg, builtinGatePasses) + }) + } +} diff --git a/internal/codeguard/checks/security/security_secrets.go b/internal/codeguard/checks/security/security_secrets.go index 751227d1..ef40fbb3 100644 --- a/internal/codeguard/checks/security/security_secrets.go +++ b/internal/codeguard/checks/security/security_secrets.go @@ -1,7 +1,6 @@ package security import ( - "regexp" "strings" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" @@ -9,7 +8,13 @@ import ( runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" ) -var concatenatedLiteralPattern = regexp.MustCompile(`(["'])([^"']*)["']\s*\+\s*["']([^"']*)(["'])`) +// concatenatedLiteralPattern folds `"abc" + "def"` into `"abcdef"` so a secret +// split across literals is still detected. It is gated on the '+' the pattern +// requires: it runs on every scanned line, and profiling showed it as the third +// largest block of regexp time in the package. +var concatenatedLiteralPattern = newGatedPattern("concatenated-literal", + `(["'])([^"']*)["']\s*\+\s*["']([^"']*)(["'])`, + exactGate("+")) // Bounds that keep the scan cheap and resistant to pathological (and untrusted) // input such as minified bundles or deliberately oversized lines. codeguard runs @@ -50,8 +55,10 @@ func (s Scanner) ScanContent(content string) []Match { } func foldConcatenatedLiterals(line string) string { - for concatenatedLiteralPattern.MatchString(line) { - line = concatenatedLiteralPattern.ReplaceAllString(line, `$1$2$3$4`) + // The gate is exact-only, so the ascii argument is unused here; false is + // the conservative value (it can only widen a gate, never narrow one). + for concatenatedLiteralPattern.matchString(line, false) { + line = concatenatedLiteralPattern.re.ReplaceAllString(line, `$1$2$3$4`) } return line } diff --git a/internal/codeguard/checks/security/security_typescript.go b/internal/codeguard/checks/security/security_typescript.go index 854e165d..907ccb11 100644 --- a/internal/codeguard/checks/security/security_typescript.go +++ b/internal/codeguard/checks/security/security_typescript.go @@ -8,10 +8,18 @@ import ( ) var ( - typeScriptExecPattern = regexp.MustCompile(`\b(?:child_process\.)?(?:exec|execSync)\s*\(`) - typeScriptEvalPattern = regexp.MustCompile(`\beval\s*\(|\bnew\s+Function\s*\(`) - typeScriptInsecureTLSPattern = regexp.MustCompile(`\brejectUnauthorized\s*:\s*false\b`) - typeScriptNodeTLSPattern = regexp.MustCompile(`NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*["']?0["']?`) + typeScriptExecPattern = regexp.MustCompile(`\b(?:child_process\.)?(?:exec|execSync)\s*\(`) + typeScriptEvalPattern = regexp.MustCompile(`\beval\s*\(|\bnew\s+Function\s*\(`) + // Gated: these two run per line (via appendLanguageLineFindings). The other + // TypeScript patterns in this file are applied once per file through + // support.FindScriptCalls / ScriptRegexFindings, so a per-line gate does + // not apply to them. + typeScriptInsecureTLSPattern = newGatedPattern("typescript.insecure-tls", + `\brejectUnauthorized\s*:\s*false\b`, + exactGate("rejectUnauthorized")) + typeScriptNodeTLSPattern = newGatedPattern("typescript.node-tls", + `NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*["']?0["']?`, + exactGate("NODE_TLS_REJECT_UNAUTHORIZED")) typeScriptUnsafeHTMLPattern = regexp.MustCompile(`(?:\.\s*(?:innerHTML|outerHTML)\s*=|\.\s*insertAdjacentHTML\s*\(|\bdocument\.(?:write|writeln)\s*\()`) typeScriptStringTimerPattern = regexp.MustCompile(`\b(?:setTimeout|setInterval)\s*\(`) typeScriptPostMessagePattern = regexp.MustCompile(`\b(?:window\s*\.\s*)?postMessage\s*\(`) @@ -24,11 +32,12 @@ type typeScriptScanContext struct { code string } -func appendTypeScriptLineFindings(env support.Context, file string, lineNo int, line string) []core.Finding { +func appendTypeScriptLineFindings(env support.Context, file string, lineNo int, source sourceLine) []core.Finding { + line := source.raw switch { - case typeScriptInsecureTLSPattern.MatchString(line): + case typeScriptInsecureTLSPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: securityRuleID(file, "insecure-tls"), Level: "fail", Path: file, Line: lineNo, Column: 1, Message: support.ScriptLabelForPath(file) + " TLS verification is disabled"})} - case typeScriptNodeTLSPattern.MatchString(line): + case typeScriptNodeTLSPattern.matchString(line, source.ascii): return []core.Finding{env.NewFinding(support.FindingInput{RuleID: securityRuleID(file, "insecure-tls"), Level: "fail", Path: file, Line: lineNo, Column: 1, Message: "NODE_TLS_REJECT_UNAUTHORIZED disables TLS verification"})} default: return nil