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..d11b38f1 100644 --- a/.claude/knowledge/testing-patterns.md +++ b/.claude/knowledge/testing-patterns.md @@ -26,3 +26,11 @@ 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. +- **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. diff --git a/.codeguard/codeguard.yaml b/.codeguard/codeguard.yaml index 81b70317..e8eea0e0 100644 --- a/.codeguard/codeguard.yaml +++ b/.codeguard/codeguard.yaml @@ -54,6 +54,20 @@ 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 + # 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 @@ -75,10 +89,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/.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/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 4361a1a8..27052339 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. @@ -245,6 +257,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 @@ -325,7 +373,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 +391,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 | @@ -1370,7 +1423,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 +1453,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. | @@ -1481,6 +1534,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. @@ -1547,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, @@ -1564,6 +1624,49 @@ 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 `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 @@ -1753,6 +1856,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 @@ -1917,10 +2051,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/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/quality/export_test_helpers.go b/internal/codeguard/checks/quality/export_test_helpers.go new file mode 100644 index 00000000..270a937d --- /dev/null +++ b/internal/codeguard/checks/quality/export_test_helpers.go @@ -0,0 +1,9 @@ +package quality + +// SplitIdentifierWordsForTest exposes splitIdentifierWords to the external +// test packages under tests/. The repo keeps tests out of this package, so a +// differential test against the regex implementation this replaced needs a +// seam; splitIdentifierWords itself stays unexported. +func SplitIdentifierWordsForTest(name string) []string { + return splitIdentifierWords(name) +} 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_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_defensive.go b/internal/codeguard/checks/quality/quality_defensive.go index ebded550..9a687699 100644 --- a/internal/codeguard/checks/quality/quality_defensive.go +++ b/internal/codeguard/checks/quality/quality_defensive.go @@ -8,6 +8,17 @@ 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+$`) +) + const ( defensiveUnvalidatedBoundaryInputRuleID = "defensive.unvalidated-boundary-input" defensiveInvalidStateRepresentableRuleID = "defensive.invalid-state-representable" @@ -30,21 +41,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 +100,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 +116,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 +146,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 } @@ -244,7 +283,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 @@ -295,6 +334,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 +369,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, "}") && @@ -327,10 +384,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 +408,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 +454,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 +474,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 +641,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, `"'`))) { @@ -618,13 +675,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 +842,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_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_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_identifier_scan.go b/internal/codeguard/checks/quality/quality_identifier_scan.go new file mode 100644 index 00000000..06c7d42a --- /dev/null +++ b/internal/codeguard/checks/quality/quality_identifier_scan.go @@ -0,0 +1,208 @@ +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 + // Flush the final word after the loop rather than running the loop to + // i == len(trimmed) as a sentinel. The sentinel form was correct - the + // index was guarded by a short-circuiting i < len(trimmed) - but it reads + // as an off-byte-one against length and static analysers flag it as one, + // so the bound stays strict and the tail is explicit. + for i := 0; i < len(trimmed); i++ { + if !isIdentifierWordSeparator(trimmed[i]) { + continue + } + if i > start { + words = append(words, splitCamelWords(trimmed[start:i])...) + } + start = i + 1 + } + if start < len(trimmed) { + words = append(words, splitCamelWords(trimmed[start:])...) + } + 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..cff65008 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,24 @@ 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<[^>]*\[\])`) + 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]*\}`) ) func additionalPrecisionFunctionFindings(env support.Context, file string, fn precisionFunction) []core.Finding { @@ -159,13 +163,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 +523,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) @@ -904,6 +913,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 +938,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 + } + if collectionTypePattern.MatchString(trimmed) || strings.Contains(trimmed, "[") || strings.Contains(trimmed, "]") { + return false + } + if numericScalarTypePattern.MatchString(trimmed) || isBooleanNameCandidate(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")) { + 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 plural && scalar && !collection { + 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 { @@ -990,18 +1136,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 +1178,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 +1204,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 +1221,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 +1236,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 +1246,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 +1273,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_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/checks/quality/quality_smells.go b/internal/codeguard/checks/quality/quality_smells.go index e852ec94..3bda4486 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)) } @@ -641,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 } @@ -723,7 +742,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 +752,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/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_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_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_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_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 ec5b5693..ef40fbb3 100644 --- a/internal/codeguard/checks/security/security_secrets.go +++ b/internal/codeguard/checks/security/security_secrets.go @@ -1,14 +1,20 @@ package security import ( - "regexp" "strings" "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*["']([^"']*)(["'])`) +// 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 @@ -17,7 +23,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 @@ -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 } @@ -102,11 +109,16 @@ 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) 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 +128,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 +158,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/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 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/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/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..e7e21088 --- /dev/null +++ b/internal/codeguard/config/defaults_base_rules.go @@ -0,0 +1,114 @@ +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 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), + 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..64096b49 100644 --- a/internal/codeguard/config/defaults_rules.go +++ b/internal/codeguard/config/defaults_rules.go @@ -92,15 +92,21 @@ 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, &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 +127,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/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/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/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();"}, } diff --git a/internal/codeguard/rules/catalog_performance.go b/internal/codeguard/rules/catalog_performance.go index c2da2e4e..8d09472e 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 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": { ID: "performance.cpp.sleep-in-loop", Section: "Performance", 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/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/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/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/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/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.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..0f71ad65 100644 --- a/internal/codeguard/runner/support/utils_match.go +++ b/internal/codeguard/runner/support/utils_match.go @@ -26,21 +26,33 @@ 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 { 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 +60,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/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/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_identifier_split_test.go b/tests/checks/quality_identifier_split_test.go new file mode 100644 index 00000000..320616cf --- /dev/null +++ b/tests/checks/quality_identifier_split_test.go @@ -0,0 +1,102 @@ +package checks_test + +import ( + "regexp" + "strings" + "testing" + "testing/quick" + "unicode" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/quality" +) + +// referenceSeparator is the pattern splitIdentifierWords replaced when the +// naming detectors stopped recompiling regexes per call. +var referenceSeparator = regexp.MustCompile(`[_\-\s]+`) + +// referenceSplit is the original regex implementation, kept here as the +// oracle. The byte-scan rewrite must agree with it exactly. +func referenceSplit(name string) []string { + trimmed := strings.Trim(name, "_$") + if trimmed == "" { + return nil + } + var words []string + for _, part := range referenceSeparator.Split(trimmed, -1) { + if part == "" { + continue + } + words = append(words, referenceCamel(part)...) + } + return words +} + +func referenceCamel(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, cur := runes[idx-1], 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 + } + } + return append(out, string(runes[start:])) +} + +// TestSplitIdentifierWordsHandlesBoundaryInput pins the index arithmetic at +// every boundary the splitter has: an empty result after trimming, a string +// that is all separators, leading and trailing separators, and invalid UTF-8. +// A bot reported the previous sentinel loop as an off-by-one against length; +// it was not one, but these are the inputs that would have proved it either +// way, so they stay. +func TestSplitIdentifierWordsHandlesBoundaryInput(t *testing.T) { + for _, name := range []string{ + "", "_", "__", "$", "_$", "$_", "a", "_a", "a_", "_a_", + "---", " ", "\t\n\f\r ", "a-b", "a--b", "-a-", "a_b-c d", + "HTTPServer", "parseURLFrom", "x1", "_1", "日本語", "a日b_c", + "\xff\xfe", "a\xffb_c", + strings.Repeat("a_", 4096), strings.Repeat("_", 4096), + "A" + strings.Repeat("-", 1024) + "B", + } { + func() { + defer func() { + if recovered := recover(); recovered != nil { + t.Errorf("splitIdentifierWords panicked on %q: %v", name, recovered) + } + }() + if got, want := quality.SplitIdentifierWordsForTest(name), referenceSplit(name); !equalWords(got, want) { + t.Errorf("split %q = %q, want %q", name, got, want) + } + }() + } +} + +// TestSplitIdentifierWordsMatchesRegexOracle is the differential half: the +// byte scan must agree with the regex it replaced on arbitrary input, not +// only on the cases someone thought to write down. +func TestSplitIdentifierWordsMatchesRegexOracle(t *testing.T) { + agrees := func(name string) bool { + return equalWords(quality.SplitIdentifierWordsForTest(name), referenceSplit(name)) + } + if err := quick.Check(agrees, &quick.Config{MaxCount: 50000}); err != nil { + t.Errorf("byte scan disagrees with the regex oracle: %v", err) + } +} + +func equalWords(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} 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") +} 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/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) + } + } + }) + } +} 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() diff --git a/tests/cli/scan_zero_config_test.go b/tests/cli/scan_zero_config_test.go new file mode 100644 index 00000000..9f8aea2e --- /dev/null +++ b/tests/cli/scan_zero_config_test.go @@ -0,0 +1,206 @@ +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") + // A genuine, non-house-style defect, so the "still detects something" + // half of this pair cannot pass vacuously. Every other file here is + // deliberately clean apart from codeguard's own conventions, and those + // rules are correctly off under the conservative defaults - so without a + // real finding the fixture would report nothing and the assertion would + // stop meaning anything. + writeScanTestFile(t, filepath.Join(root, "internal", "calc", "digest.go"), + "package calc\n\nimport (\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n)\n\n// Digest hashes a value with MD5, which is a security.weak-hash finding.\nfunc Digest(value string) string {\n\tsum := md5.Sum([]byte(value))\n\treturn hex.EncodeToString(sum[:])\n}\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") + } +} 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" +} 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"; 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) + } +} 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 +} 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 +} 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) + } +}