From 34c7f87c8f6d0bf55917abcb3f5a05e81fcf3669 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Tue, 30 Jun 2026 15:03:59 -0400 Subject: [PATCH 1/6] fix(security): harden untrusted-input handling and wire lint cleanup Threat model: codeguard runs in CI on untrusted PRs, so repo config and scanned content are attacker-controllable. This wave closes a set of hardening gaps and clears the mechanical/security lint backlog. Hardening: - glob matcher no longer panics on malformed config patterns (regexp.Compile + treat-as-no-match) and memoizes compiled patterns via sync.Map (utils.go) - validate base_ref before git (reject leading '-', charset allowlist, --end-of-options) to block option injection (diff/changed_files/ diff_command/mcp_tools/ai-semantic) - git & govulncheck subprocesses use CommandContext with timeouts and bounded io.LimitReader output to prevent hangs and OOM - MCP HTTP server sets ReadHeaderTimeout (+Read/Write/Idle) against Slowloris - recover() around each check section so one panic can't abort the whole scan - fix close/cancel leaks (triage body, http_post close, mcp ctx cancel; httpretry now drains before close); GitHub comment client threads ctx and a client timeout - cap cache/config reads; tighten dir/file perms to 0750/0600 Tooling & quality: - wire golangci-lint (16-linter v2 config) into CI, non-blocking for now - %w error wrapping; sha1 -> sha256 for cache/identity keys; remove dead code, redundant conversions, and loop-var copies; prealloc where bounded - justified //nolint:gosec on intentional subprocess/file/jitter sites (production gosec findings now zero) Build, vet, gofmt clean; 430 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 4 + .golangci.yml | 38 ++++++- Makefile | 6 +- internal/cli/doctor_helpers.go | 2 +- internal/cli/info.go | 2 +- internal/cli/mcp_dispatch.go | 6 +- internal/cli/mcp_requests.go | 1 + internal/cli/mcp_run.go | 14 ++- internal/cli/mcp_tools.go | 7 ++ internal/codeguard/ai/fix/generator.go | 2 +- .../ai/fix/testplan_package_manager.go | 2 +- internal/codeguard/ai/httpretry/httpretry.go | 10 +- internal/codeguard/ai/nlrule/cache.go | 6 +- internal/codeguard/ai/nlrule/runtime.go | 2 +- internal/codeguard/ai/runtime/anthropic.go | 2 +- internal/codeguard/ai/runtime/http_post.go | 8 +- internal/codeguard/ai/runtime/provider.go | 2 +- internal/codeguard/ai/runtime/session.go | 4 +- internal/codeguard/ai/safehttp/safehttp.go | 2 +- internal/codeguard/ai/semantic/cache.go | 4 +- internal/codeguard/ai/semantic/diff.go | 52 ++++++++- internal/codeguard/ai/semantic/runtime.go | 2 +- internal/codeguard/ai/semantic/snapshots.go | 2 +- internal/codeguard/ai/triage/candidates.go | 6 +- internal/codeguard/ai/triage/http.go | 1 + internal/codeguard/ai/triage/provider.go | 4 +- internal/codeguard/cachefile/cachefile.go | 16 ++- .../quality/quality_ai_error_style_python.go | 2 +- .../checks/quality/quality_ai_naming_drift.go | 7 +- .../checks/quality/quality_ai_python_deps.go | 6 +- .../quality_ai_python_deps_pyproject.go | 2 +- .../checks/quality/quality_ai_resolution.go | 9 +- .../checks/quality/quality_ai_style_drift.go | 2 +- .../checks/quality/quality_ai_target_go.go | 4 +- .../quality/quality_ai_target_python.go | 2 +- .../quality/quality_ai_target_script.go | 4 +- .../checks/quality/quality_ai_test_idioms.go | 2 +- .../checks/quality/quality_coverage_go.go | 2 +- .../checks/quality/quality_coverage_lcov.go | 2 +- .../checks/security/security_typescript.go | 1 - internal/codeguard/checks/support/gomod.go | 2 +- .../supply_chain_licenses_resolution.go | 6 +- .../checks/support/supply_chain_lockfiles.go | 2 +- .../checks/support/typescript_semantic.go | 6 +- .../support/typescript_semantic_discovery.go | 2 +- internal/codeguard/config/example_ai.go | 2 +- internal/codeguard/config/io.go | 16 ++- internal/codeguard/history/history.go | 2 +- internal/codeguard/report/text_banner.go | 2 +- internal/codeguard/runner/checks/checks.go | 41 +++++-- .../runner/govulncheck/govulncheck.go | 41 ++++++- .../codeguard/runner/support/artifacts.go | 2 +- .../codeguard/runner/support/cache_helpers.go | 4 +- .../codeguard/runner/support/changed_files.go | 15 ++- internal/codeguard/runner/support/commands.go | 2 +- internal/codeguard/runner/support/context.go | 4 +- internal/codeguard/runner/support/diff.go | 107 +++++++++++++++++- .../codeguard/runner/support/diff_command.go | 34 ++++-- internal/codeguard/runner/support/findings.go | 6 +- internal/codeguard/runner/support/patch.go | 6 +- .../codeguard/runner/support/slop_history.go | 6 +- .../codeguard/runner/support/suppressions.go | 2 +- internal/codeguard/runner/support/utils.go | 34 +++++- internal/githubaction/comment_client.go | 64 ++++++++--- tests/checks/ci_additional_languages_test.go | 1 - tests/checks/parser_migration_test.go | 1 - .../quality_additional_languages_test.go | 1 - .../security_additional_languages_test.go | 1 - tests/checks/security_secrets_test.go | 1 - tests/support/clike_parser_test.go | 5 +- tests/support/python_parser_test.go | 2 +- 71 files changed, 525 insertions(+), 147 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a30b26..306123e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,10 @@ jobs: - name: Run go vet run: make lint + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + continue-on-error: true # TODO(harden): remove continue-on-error after the lint backlog is burned down + test: name: test needs: lint diff --git a/.golangci.yml b/.golangci.yml index ecd152e..286d740 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,37 @@ version: "2" - run: - timeout: 3m - + timeout: 5m linters: - default: standard + enable: + - errcheck + - govet + - staticcheck + - errorlint + - gocritic + - revive + - ineffassign + - unconvert + - prealloc + - copyloopvar + - unparam + - gosec + - bodyclose + - contextcheck + - misspell + - nakedret + settings: + govet: + enable: + - shadow + errcheck: + exclude-functions: + - fmt.Fprintf + - fmt.Fprintln + - fmt.Fprint +issues: + exclude-rules: + - path: tests/ + linters: + - errcheck + - gosec + - prealloc diff --git a/Makefile b/Makefile index 1182ab5..38da299 100644 --- a/Makefile +++ b/Makefile @@ -24,13 +24,14 @@ export GOMODCACHE .DEFAULT_GOAL := help -.PHONY: help fmt fmt-check lint test codeguard-ci check ci build release release-snapshot release-check deploy commit table table-diff table-interactive clean +.PHONY: help fmt fmt-check lint lint-strict test codeguard-ci check ci build release release-snapshot release-check deploy commit table table-diff table-interactive clean help: @printf "\ncodeguard make targets\n\n" @printf " make fmt Format Go files\n" @printf " make fmt-check Verify Go files are formatted\n" @printf " make lint Run go vet\n" + @printf " make lint-strict Run golangci-lint (not yet enforced in CI)\n" @printf " make test Run the Go test suite\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" @@ -61,6 +62,9 @@ fmt-check: lint: $(GO) vet ./... +lint-strict: + golangci-lint run + test: @set -o pipefail; $(GO) test ./... 2>&1 | grep -v '\[no test files\]' diff --git a/internal/cli/doctor_helpers.go b/internal/cli/doctor_helpers.go index 03cf8e5..5a23bfd 100644 --- a/internal/cli/doctor_helpers.go +++ b/internal/cli/doctor_helpers.go @@ -37,7 +37,7 @@ func targetDoctorChecks(targets []service.TargetConfig) []doctorCheck { } func repoDoctorCheck(target service.TargetConfig) doctorCheck { - if err := exec.Command("git", "-C", target.Path, "rev-parse", "--show-toplevel").Run(); err != nil { + if err := exec.Command("git", "-C", target.Path, "rev-parse", "--show-toplevel").Run(); err != nil { //nolint:gosec // fixed git subcommand; target.Path is a config scan target return warnDoctorCheck("repo:"+target.Name, fmt.Sprintf("%s is not a git worktree; diff scans will not work", target.Path)) } return passDoctorCheck("repo:"+target.Name, "git worktree detected") diff --git a/internal/cli/info.go b/internal/cli/info.go index 59d601b..5a6930e 100644 --- a/internal/cli/info.go +++ b/internal/cli/info.go @@ -32,7 +32,7 @@ func runRules(args []string, stdout io.Writer, stderr io.Writer) int { var line strings.Builder line.WriteString(rule.ID) line.WriteByte('\t') - line.WriteString(string(rule.DefaultLevel)) + line.WriteString(rule.DefaultLevel) line.WriteByte('\t') line.WriteString(string(rule.ExecutionModel)) line.WriteByte('\t') diff --git a/internal/cli/mcp_dispatch.go b/internal/cli/mcp_dispatch.go index 5ab6da3..d47009d 100644 --- a/internal/cli/mcp_dispatch.go +++ b/internal/cli/mcp_dispatch.go @@ -18,7 +18,7 @@ const mcpServerInstructions = "Use validate_patch before writing files to disk w func buildResultMessage(id json.RawMessage, result any) map[string]any { return map[string]any{ "jsonrpc": "2.0", - "id": json.RawMessage(id), + "id": id, "result": result, } } @@ -31,7 +31,7 @@ func buildErrorMessage(id *json.RawMessage, code int, message string) map[string "error": mcpError{Code: code, Message: message}, } if id != nil { - payload["id"] = json.RawMessage(*id) + payload["id"] = *id } else { payload["id"] = nil } @@ -44,7 +44,7 @@ func buildProgressMessage(token json.RawMessage, progress float64, total float64 "jsonrpc": "2.0", "method": "notifications/progress", "params": map[string]any{ - "progressToken": json.RawMessage(token), + "progressToken": token, "progress": progress, "total": total, "message": message, diff --git a/internal/cli/mcp_requests.go b/internal/cli/mcp_requests.go index 95f64bd..52f0d26 100644 --- a/internal/cli/mcp_requests.go +++ b/internal/cli/mcp_requests.go @@ -51,6 +51,7 @@ func (s *mcpServer) handleToolCall(req mcpRequest, stdout io.Writer) error { s.wg.Add(1) go func() { defer s.wg.Done() + defer cancel() defer s.finishRequest(key) if progressToken != nil { _ = s.responder.writeProgress(stdout, *progressToken, 0, 1, "Started") diff --git a/internal/cli/mcp_run.go b/internal/cli/mcp_run.go index c18855e..5be4ca5 100644 --- a/internal/cli/mcp_run.go +++ b/internal/cli/mcp_run.go @@ -76,7 +76,19 @@ func runServe(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer // in-flight requests gracefully. func serveMCPHTTP(addr string, path string, tools *mcpToolService, auth mcpAuthConfig, stderr io.Writer) error { handler := newMCPHTTPHandler(tools, auth, path) - srv := &http.Server{Addr: addr, Handler: handler} + // Set server timeouts to defend against Slowloris-style attacks, where a + // slow client trickles request bytes to exhaust connection slots. + // ReadHeaderTimeout is the key defense; the others bound overall request, + // response, and idle-connection lifetimes. WriteTimeout is generous because + // MCP tool responses can be large and stream over a longer scan. + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 5 * time.Minute, + IdleTimeout: 120 * time.Second, + } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/internal/cli/mcp_tools.go b/internal/cli/mcp_tools.go index 964f0f7..045b780 100644 --- a/internal/cli/mcp_tools.go +++ b/internal/cli/mcp_tools.go @@ -8,6 +8,7 @@ import ( "strings" "sync" + runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" service "github.com/devr-tools/codeguard/pkg/codeguard" ) @@ -46,6 +47,12 @@ func (s *mcpToolService) callScan(ctx context.Context, raw json.RawMessage) (map if baseRef == "" { baseRef = "main" } + // Validate the caller-supplied ref once at the trust boundary so a value + // beginning with "-" cannot be parsed by git as an option, and so only a + // conservative ref/SHA charset reaches the git invocations downstream. + if err := runnersupport.ValidateBaseRef(baseRef); err != nil { + return toolErrorResult(err.Error()), nil + } opts := service.ScanOptions{Mode: mode, BaseRef: baseRef} if emit := progressFrom(ctx); emit != nil { diff --git a/internal/codeguard/ai/fix/generator.go b/internal/codeguard/ai/fix/generator.go index 67da6f0..fce3eeb 100644 --- a/internal/codeguard/ai/fix/generator.go +++ b/internal/codeguard/ai/fix/generator.go @@ -74,7 +74,7 @@ func sourceExcerpt(cfg core.Config, finding core.Finding) string { if err != nil { continue } - data, err := os.ReadFile(fullPath) + data, err := os.ReadFile(fullPath) //nolint:gosec // path containment-checked by containedFindingPath against the target root if err != nil { continue } diff --git a/internal/codeguard/ai/fix/testplan_package_manager.go b/internal/codeguard/ai/fix/testplan_package_manager.go index bc7199c..ab099db 100644 --- a/internal/codeguard/ai/fix/testplan_package_manager.go +++ b/internal/codeguard/ai/fix/testplan_package_manager.go @@ -10,7 +10,7 @@ import ( ) func inferPackageManagerTestCommand(root string) (core.CommandCheckConfig, bool) { - data, err := os.ReadFile(filepath.Join(root, "package.json")) + data, err := os.ReadFile(filepath.Join(root, "package.json")) //nolint:gosec // fixed filename joined under the scan root if err != nil { return core.CommandCheckConfig{}, false } diff --git a/internal/codeguard/ai/httpretry/httpretry.go b/internal/codeguard/ai/httpretry/httpretry.go index 7589226..0ad3f6e 100644 --- a/internal/codeguard/ai/httpretry/httpretry.go +++ b/internal/codeguard/ai/httpretry/httpretry.go @@ -5,6 +5,7 @@ package httpretry import ( "context" + "io" "math/rand" "net/http" "os" @@ -21,6 +22,10 @@ const ( defaultBaseDelay = 250 * time.Millisecond defaultMaxDelay = 8 * time.Second maxRetryAfter = 30 * time.Second + + // drainLimit caps how much of a discarded response body we read before a + // retry so keep-alive can reuse the connection without unbounded reads. + drainLimit = 4 << 20 ) // Config controls retry behavior for one logical provider request. @@ -124,7 +129,7 @@ func backoffDelay(cfg Config, attempt int) time.Duration { // Equal jitter: keep half the delay deterministic and randomize the rest // so concurrent scans do not retry in lockstep. half := delay / 2 - return half + time.Duration(rand.Int63n(int64(half)+1)) + return half + time.Duration(rand.Int63n(int64(half)+1)) //nolint:gosec // retry jitter only, not security-sensitive } func parseRetryAfter(value string) (time.Duration, bool) { @@ -152,10 +157,13 @@ func capRetryAfter(delay time.Duration) time.Duration { return delay } +// drainAndClose reads and discards the remaining response body (up to a cap) +// before closing it so the underlying connection can be reused on retry. func drainAndClose(resp *http.Response) { if resp == nil || resp.Body == nil { return } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, drainLimit)) _ = resp.Body.Close() } diff --git a/internal/codeguard/ai/nlrule/cache.go b/internal/codeguard/ai/nlrule/cache.go index 73ec0f6..7f5943c 100644 --- a/internal/codeguard/ai/nlrule/cache.go +++ b/internal/codeguard/ai/nlrule/cache.go @@ -1,7 +1,7 @@ package nlrule import ( - "crypto/sha1" + "crypto/sha256" "encoding/hex" "path/filepath" "strings" @@ -21,7 +21,7 @@ type VerdictCache interface { // from the rule fingerprint, runtime fingerprint, file path, file content // hash, and prompt version, matching the semantic-cache keying pattern. func VerdictCacheKey(runtimeFingerprint string, rule core.CustomRuleConfig, path string, data []byte) string { - contentSum := sha1.Sum(data) + contentSum := sha256.Sum256(data) payload := strings.Join([]string{ promptVersion, ruleFingerprint(rule), @@ -29,7 +29,7 @@ func VerdictCacheKey(runtimeFingerprint string, rule core.CustomRuleConfig, path filepath.ToSlash(path), hex.EncodeToString(contentSum[:]), }, "|") - sum := sha1.Sum([]byte("nlrule-verdict-v1|" + payload)) + sum := sha256.Sum256([]byte("nlrule-verdict-v1|" + payload)) return hex.EncodeToString(sum[:]) } diff --git a/internal/codeguard/ai/nlrule/runtime.go b/internal/codeguard/ai/nlrule/runtime.go index a224a50..339a393 100644 --- a/internal/codeguard/ai/nlrule/runtime.go +++ b/internal/codeguard/ai/nlrule/runtime.go @@ -63,7 +63,7 @@ func (runtime commandRuntime) Evaluate(ctx context.Context, request EvaluationRe if err != nil { return EvaluationResponse{}, err } - cmd := exec.CommandContext(ctx, runtime.command) + cmd := exec.CommandContext(ctx, runtime.command) //nolint:gosec // command gated by trust.GuardConfigCommand above cmd.Stdin = bytes.NewReader(payload) var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/internal/codeguard/ai/runtime/anthropic.go b/internal/codeguard/ai/runtime/anthropic.go index fe707db..22e89b5 100644 --- a/internal/codeguard/ai/runtime/anthropic.go +++ b/internal/codeguard/ai/runtime/anthropic.go @@ -14,7 +14,7 @@ const ( anthropicDefaultBaseURL = "https://api.anthropic.com/v1" anthropicDefaultModel = "claude-sonnet-4-6" anthropicVersion = "2023-06-01" - anthropicAPIKeyEnv = "ANTHROPIC_API_KEY" + anthropicAPIKeyEnv = "ANTHROPIC_API_KEY" //nolint:gosec // env var name, not a credential anthropicMaxTokens = 4096 ) diff --git a/internal/codeguard/ai/runtime/http_post.go b/internal/codeguard/ai/runtime/http_post.go index 05b6ce3..075b75a 100644 --- a/internal/codeguard/ai/runtime/http_post.go +++ b/internal/codeguard/ai/runtime/http_post.go @@ -15,7 +15,7 @@ import ( // postProviderJSON marshals body, POSTs it to url with the provider-specific // headers, and returns the raw response payload after status validation. -func postProviderJSON(ctx context.Context, providerName string, url string, headers map[string]string, body any) ([]byte, error) { +func postProviderJSON(ctx context.Context, providerName string, url string, headers map[string]string, body any) (_ []byte, err error) { data, err := json.Marshal(body) if err != nil { return nil, err @@ -34,7 +34,11 @@ func postProviderJSON(ctx context.Context, providerName string, url string, head if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + if cerr := resp.Body.Close(); cerr != nil && err == nil { + err = cerr + } + }() respData, err := io.ReadAll(io.LimitReader(resp.Body, safehttp.MaxResponseBytes)) if err != nil { return nil, err diff --git a/internal/codeguard/ai/runtime/provider.go b/internal/codeguard/ai/runtime/provider.go index edfee4e..5bb8f43 100644 --- a/internal/codeguard/ai/runtime/provider.go +++ b/internal/codeguard/ai/runtime/provider.go @@ -115,7 +115,7 @@ type commandProvider struct { func (p commandProvider) Name() string { return "command" } func (p commandProvider) Evaluate(ctx context.Context, req Request) (Response, error) { - cmd := exec.CommandContext(ctx, p.command, p.args...) + cmd := exec.CommandContext(ctx, p.command, p.args...) //nolint:gosec // command gated by trust.GuardConfigCommand in BuildProvider data, err := json.Marshal(req) if err != nil { return Response{}, err diff --git a/internal/codeguard/ai/runtime/session.go b/internal/codeguard/ai/runtime/session.go index 409ef07..f74ef07 100644 --- a/internal/codeguard/ai/runtime/session.go +++ b/internal/codeguard/ai/runtime/session.go @@ -2,7 +2,7 @@ package runtime import ( "context" - "crypto/sha1" + "crypto/sha256" "encoding/hex" "strings" @@ -75,7 +75,7 @@ func (s *Session) EvaluateCached(ctx context.Context, req Request) (Response, st func CacheKey(req Request) (string, string) { content := strings.Join([]string{req.Kind, req.System, req.Prompt, req.InputJSON}, "|") - sum := sha1.Sum([]byte(content)) + sum := sha256.Sum256([]byte(content)) hash := hex.EncodeToString(sum[:]) return req.Kind + "|" + hash, hash } diff --git a/internal/codeguard/ai/safehttp/safehttp.go b/internal/codeguard/ai/safehttp/safehttp.go index 79c1f08..847d931 100644 --- a/internal/codeguard/ai/safehttp/safehttp.go +++ b/internal/codeguard/ai/safehttp/safehttp.go @@ -79,7 +79,7 @@ func ValidateProviderURL(rawURL string, trustedSource bool) error { } return fmt.Errorf( "AI provider base URL host %q is not on the allowlist (%s); it was set from repository "+ - "configuration. Set %s=1 or pass --allow-config-ai-endpoints to use a custom endpoint.", + "configuration; set %s=1 or pass --allow-config-ai-endpoints to use a custom endpoint", host, allowedHostList(), trust.AllowConfigAIEndpointsEnv) } diff --git a/internal/codeguard/ai/semantic/cache.go b/internal/codeguard/ai/semantic/cache.go index afb89ed..12c0b83 100644 --- a/internal/codeguard/ai/semantic/cache.go +++ b/internal/codeguard/ai/semantic/cache.go @@ -1,7 +1,7 @@ package semantic import ( - "crypto/sha1" + "crypto/sha256" "encoding/hex" "encoding/json" "path/filepath" @@ -52,7 +52,7 @@ func requestHash(req Request) string { if err != nil { return "" } - sum := sha1.Sum(append([]byte("semantic-request-v1|"), data...)) + sum := sha256.Sum256(append([]byte("semantic-request-v1|"), data...)) return hex.EncodeToString(sum[:]) } diff --git a/internal/codeguard/ai/semantic/diff.go b/internal/codeguard/ai/semantic/diff.go index 2a73084..d674fc4 100644 --- a/internal/codeguard/ai/semantic/diff.go +++ b/internal/codeguard/ai/semantic/diff.go @@ -1,12 +1,24 @@ package semantic import ( + "bytes" + "context" + "io" "os/exec" "strings" + "time" runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" ) +// gitCommandTimeout bounds a single git invocation, and maxGitOutputBytes caps +// how much diff output is buffered, so a hung process or a huge diff against a +// far-back base ref cannot hang the scan or exhaust memory. +const ( + gitCommandTimeout = 2 * time.Minute + maxGitOutputBytes = 64 << 20 // 64 MiB +) + func changedFilesFromDiff(diffText string) []string { return runnersupport.ChangedFilesFromUnifiedDiff(diffText) } @@ -15,15 +27,45 @@ func loadGitDiff(dir string, baseRef string) string { if strings.TrimSpace(baseRef) == "" { return "" } + if err := runnersupport.ValidateBaseRef(baseRef); err != nil { + return "" + } argsVariants := [][]string{ - {"-C", dir, "diff", "--unified=3", "--no-color", baseRef, "--"}, - {"-C", dir, "diff", "--unified=3", "--no-color", baseRef + "...HEAD", "--"}, + {"-C", dir, "diff", "--unified=3", "--no-color", "--end-of-options", baseRef, "--"}, + {"-C", dir, "diff", "--unified=3", "--no-color", "--end-of-options", baseRef + "...HEAD", "--"}, } for _, args := range argsVariants { - out, err := exec.Command("git", args...).Output() - if err == nil { - return string(out) + if out, ok := runGitDiffCapture(args...); ok { + return out } } return "" } + +// runGitDiffCapture runs git with a timeout and reads at most maxGitOutputBytes +// of stdout. It reports ok=false when git fails or the output cap is exceeded. +func runGitDiffCapture(args ...string) (string, bool) { + // TODO(harden): thread caller ctx once loadGitDiff accepts one. + ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "git", args...) //nolint:gosec // fixed git binary; args are tool-built (constants, validated baseRef, target paths) + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", false + } + if err := cmd.Start(); err != nil { + return "", false + } + var buf bytes.Buffer + n, _ := io.Copy(&buf, io.LimitReader(stdout, maxGitOutputBytes+1)) + if n > maxGitOutputBytes { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return "", false + } + if err := cmd.Wait(); err != nil { + return "", false + } + return buf.String(), true +} diff --git a/internal/codeguard/ai/semantic/runtime.go b/internal/codeguard/ai/semantic/runtime.go index a05c3da..ffdcfec 100644 --- a/internal/codeguard/ai/semantic/runtime.go +++ b/internal/codeguard/ai/semantic/runtime.go @@ -29,7 +29,7 @@ func runCommand(ctx context.Context, command string, req Request) (Response, err if err != nil { return Response{}, err } - cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) + cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) //nolint:gosec // command gated by trust.GuardConfigCommand above cmd.Stdin = bytes.NewReader(input) var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/internal/codeguard/ai/semantic/snapshots.go b/internal/codeguard/ai/semantic/snapshots.go index 2fd3e68..51b83d7 100644 --- a/internal/codeguard/ai/semantic/snapshots.go +++ b/internal/codeguard/ai/semantic/snapshots.go @@ -34,7 +34,7 @@ func collectSnapshots(root string, changedFiles []string) ([]FileSnapshot, []Fil func snapshotsForPaths(root string, paths []string, maxBytes int) []FileSnapshot { snapshots := make([]FileSnapshot, 0, len(paths)) for _, rel := range paths { - data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) //nolint:gosec // rel path joined under the scan-target root if err != nil { continue } diff --git a/internal/codeguard/ai/triage/candidates.go b/internal/codeguard/ai/triage/candidates.go index 7ac6e9d..59c527f 100644 --- a/internal/codeguard/ai/triage/candidates.go +++ b/internal/codeguard/ai/triage/candidates.go @@ -1,7 +1,7 @@ package triage import ( - "crypto/sha1" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -47,7 +47,7 @@ func contentHash(item candidate) string { "snippet": item.snippet, } data, _ := json.Marshal(payload) - sum := sha1.Sum(data) + sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) } @@ -85,7 +85,7 @@ func (resolver sourceResolver) snippetForFinding(finding core.Finding) string { if !ok { return "" } - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // path resolved from the scan-target file walk in newSourceResolver if err != nil { return "" } diff --git a/internal/codeguard/ai/triage/http.go b/internal/codeguard/ai/triage/http.go index 82e7e36..ac8e8db 100644 --- a/internal/codeguard/ai/triage/http.go +++ b/internal/codeguard/ai/triage/http.go @@ -31,6 +31,7 @@ func triageViaHTTP( if err != nil { return nil, err } + defer func() { _ = resp.Body.Close() }() return decode(resp) } diff --git a/internal/codeguard/ai/triage/provider.go b/internal/codeguard/ai/triage/provider.go index 1c1752d..4906c12 100644 --- a/internal/codeguard/ai/triage/provider.go +++ b/internal/codeguard/ai/triage/provider.go @@ -57,10 +57,10 @@ func incrementMockCountFile() { return } current := 0 - if data, err := os.ReadFile(path); err == nil { + if data, err := os.ReadFile(path); err == nil { //nolint:gosec // mock instrumentation; path from CODEGUARD_AI_TRIAGE_COUNT_FILE env if parsed, parseErr := strconv.Atoi(strings.TrimSpace(string(data))); parseErr == nil { current = parsed } } - _ = os.WriteFile(path, []byte(strconv.Itoa(current+1)), 0o644) + _ = os.WriteFile(path, []byte(strconv.Itoa(current+1)), 0o600) //nolint:gosec // mock instrumentation; path from CODEGUARD_AI_TRIAGE_COUNT_FILE env } diff --git a/internal/codeguard/cachefile/cachefile.go b/internal/codeguard/cachefile/cachefile.go index e12960a..89a7d0a 100644 --- a/internal/codeguard/cachefile/cachefile.go +++ b/internal/codeguard/cachefile/cachefile.go @@ -4,11 +4,16 @@ package cachefile import ( "encoding/json" + "io" "os" "path/filepath" "strings" ) +// maxCacheFileBytes caps how much of a cache file is read into memory, guarding +// against an oversized or malicious file exhausting memory. +const maxCacheFileBytes = 32 << 20 + // Load reads a JSON cache file into payload and reports whether payload was // populated from disk. A blank path, missing file, or malformed payload is // treated as a cache miss. @@ -16,7 +21,12 @@ func Load(path string, payload any) bool { if strings.TrimSpace(path) == "" { return false } - data, err := os.ReadFile(path) + f, err := os.Open(path) //nolint:gosec // config-supplied cache path; read is size-capped by LimitReader below + if err != nil { + return false + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, maxCacheFileBytes)) if err != nil { return false } @@ -30,10 +40,10 @@ func Write(path string, payload any) error { if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return err } - return os.WriteFile(path, append(data, '\n'), 0o644) + return os.WriteFile(path, append(data, '\n'), 0o600) } type entriesEnvelope[V any] struct { diff --git a/internal/codeguard/checks/quality/quality_ai_error_style_python.go b/internal/codeguard/checks/quality/quality_ai_error_style_python.go index 0934870..dc24ee2 100644 --- a/internal/codeguard/checks/quality/quality_ai_error_style_python.go +++ b/internal/codeguard/checks/quality/quality_ai_error_style_python.go @@ -29,7 +29,7 @@ func pythonErrorStyleCounts(source string) pythonErrorStyleSummary { func pythonRepoErrorStyle(root string, files []string) pythonErrorStyleSummary { total := pythonErrorStyleSummary{} for _, rel := range files { - data, err := os.ReadFile(filepath.Join(root, rel)) + data, err := os.ReadFile(filepath.Join(root, rel)) //nolint:gosec // file under the scan-target root if err != nil { continue } diff --git a/internal/codeguard/checks/quality/quality_ai_naming_drift.go b/internal/codeguard/checks/quality/quality_ai_naming_drift.go index 269cc65..5dae5ad 100644 --- a/internal/codeguard/checks/quality/quality_ai_naming_drift.go +++ b/internal/codeguard/checks/quality/quality_ai_naming_drift.go @@ -65,7 +65,7 @@ func namingCounts(source string, extract nameExtractor) map[string]int { func dominantNamingConvention(root string, files []string, extract nameExtractor) string { totals := map[string]int{} for _, rel := range files { - data, err := os.ReadFile(filepath.Join(root, rel)) + data, err := os.ReadFile(filepath.Join(root, rel)) //nolint:gosec // file under the scan-target root if err != nil { continue } @@ -127,8 +127,9 @@ func scriptDeclaredNames(source string) []nameAt { } func extractNames(source string, pattern *regexp.Regexp) []nameAt { - out := make([]nameAt, 0) - for _, match := range pattern.FindAllStringSubmatchIndex(source, -1) { + matches := pattern.FindAllStringSubmatchIndex(source, -1) + out := make([]nameAt, 0, len(matches)) + for _, match := range matches { out = append(out, nameAt{ name: source[match[2]:match[3]], line: 1 + strings.Count(source[:match[2]], "\n"), diff --git a/internal/codeguard/checks/quality/quality_ai_python_deps.go b/internal/codeguard/checks/quality/quality_ai_python_deps.go index 62d88cb..c087653 100644 --- a/internal/codeguard/checks/quality/quality_ai_python_deps.go +++ b/internal/codeguard/checks/quality/quality_ai_python_deps.go @@ -72,7 +72,7 @@ func readPythonRequirementsFiles(root string, catalog *pythonDependencyCatalog) if entry.IsDir() || !strings.HasPrefix(name, "requirements") || !strings.HasSuffix(name, ".txt") { continue } - data, err := os.ReadFile(filepath.Join(root, entry.Name())) + data, err := os.ReadFile(filepath.Join(root, entry.Name())) //nolint:gosec // dir entry under the scan-target root if err != nil { continue } @@ -84,7 +84,7 @@ func readPythonRequirementsFiles(root string, catalog *pythonDependencyCatalog) } func readPythonSetupPyDeps(root string, catalog *pythonDependencyCatalog) { - data, err := os.ReadFile(filepath.Join(root, "setup.py")) + data, err := os.ReadFile(filepath.Join(root, "setup.py")) //nolint:gosec // fixed filename under the scan-target root if err != nil { return } @@ -97,7 +97,7 @@ func readPythonSetupPyDeps(root string, catalog *pythonDependencyCatalog) { } func readPythonSetupCfgDeps(root string, catalog *pythonDependencyCatalog) { - data, err := os.ReadFile(filepath.Join(root, "setup.cfg")) + data, err := os.ReadFile(filepath.Join(root, "setup.cfg")) //nolint:gosec // fixed filename under the scan-target root if err != nil { return } diff --git a/internal/codeguard/checks/quality/quality_ai_python_deps_pyproject.go b/internal/codeguard/checks/quality/quality_ai_python_deps_pyproject.go index 02ad1b2..8e9401b 100644 --- a/internal/codeguard/checks/quality/quality_ai_python_deps_pyproject.go +++ b/internal/codeguard/checks/quality/quality_ai_python_deps_pyproject.go @@ -9,7 +9,7 @@ import ( // pyproject.toml dependency extraction for the Python dependency catalog. func readPythonPyprojectDeps(root string, catalog *pythonDependencyCatalog) { - data, err := os.ReadFile(filepath.Join(root, "pyproject.toml")) + data, err := os.ReadFile(filepath.Join(root, "pyproject.toml")) //nolint:gosec // fixed filename under the scan-target root if err != nil { return } diff --git a/internal/codeguard/checks/quality/quality_ai_resolution.go b/internal/codeguard/checks/quality/quality_ai_resolution.go index ed143a6..c6ac34c 100644 --- a/internal/codeguard/checks/quality/quality_ai_resolution.go +++ b/internal/codeguard/checks/quality/quality_ai_resolution.go @@ -1,12 +1,14 @@ package quality import ( + "context" "encoding/json" "os" "os/exec" "path/filepath" "slices" "strings" + "time" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" @@ -39,7 +41,7 @@ type packageManifest struct { } func readPackageManifest(root string) (packageManifest, bool) { - data, err := os.ReadFile(filepath.Join(root, "package.json")) + data, err := os.ReadFile(filepath.Join(root, "package.json")) //nolint:gosec // fixed filename under the scan-target root if err != nil { return packageManifest{}, false } @@ -86,7 +88,10 @@ func readWorkspacePackageNames(root string, excludes []string) map[string]struct } func readGitHeadMessage(dir string) string { - cmd := exec.Command("git", "-C", dir, "log", "-1", "--format=%B") + // TODO(harden): thread caller ctx once readGitHeadMessage accepts one. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "-C", dir, "log", "-1", "--format=%B") //nolint:gosec // fixed git subcommand; dir is a config-supplied scan target path out, err := cmd.Output() if err != nil { return "" diff --git a/internal/codeguard/checks/quality/quality_ai_style_drift.go b/internal/codeguard/checks/quality/quality_ai_style_drift.go index 485609b..5cfda9f 100644 --- a/internal/codeguard/checks/quality/quality_ai_style_drift.go +++ b/internal/codeguard/checks/quality/quality_ai_style_drift.go @@ -89,7 +89,7 @@ func scriptErrorStyleDriftFinding(env support.Context, file string, source strin func dominantStyle(root string, files []string, counter func(string) map[string]int) string { totals := map[string]int{} for _, rel := range files { - data, err := os.ReadFile(filepath.Join(root, rel)) + data, err := os.ReadFile(filepath.Join(root, rel)) //nolint:gosec // file under the scan-target root if err != nil { continue } diff --git a/internal/codeguard/checks/quality/quality_ai_target_go.go b/internal/codeguard/checks/quality/quality_ai_target_go.go index fcd309f..fbae078 100644 --- a/internal/codeguard/checks/quality/quality_ai_target_go.go +++ b/internal/codeguard/checks/quality/quality_ai_target_go.go @@ -59,7 +59,7 @@ type goFileScanInput struct { func goFileAIQualityFindings(env support.Context, root string, rel string, input goFileScanInput) ([]core.Finding, *goParsedFile) { abs := filepath.Join(root, rel) - data, err := os.ReadFile(abs) + data, err := os.ReadFile(abs) //nolint:gosec // path resolved under the scan-target root if err != nil { return nil, nil } @@ -92,7 +92,7 @@ func goFileAIQualityFindings(env support.Context, root string, rel string, input } func readGoModuleMetadata(root string) goModuleMetadata { - data, err := os.ReadFile(filepath.Join(root, "go.mod")) + data, err := os.ReadFile(filepath.Join(root, "go.mod")) //nolint:gosec // fixed filename under the scan-target root if err != nil { return goModuleMetadata{} } diff --git a/internal/codeguard/checks/quality/quality_ai_target_python.go b/internal/codeguard/checks/quality/quality_ai_target_python.go index 066d526..e58dd47 100644 --- a/internal/codeguard/checks/quality/quality_ai_target_python.go +++ b/internal/codeguard/checks/quality/quality_ai_target_python.go @@ -46,7 +46,7 @@ type pythonFileScanInput struct { } func pythonFileAIQualityFindings(env support.Context, root string, rel string, input pythonFileScanInput) []core.Finding { - data, err := os.ReadFile(filepath.Join(root, rel)) + data, err := os.ReadFile(filepath.Join(root, rel)) //nolint:gosec // file under the scan-target root if err != nil { return nil } diff --git a/internal/codeguard/checks/quality/quality_ai_target_script.go b/internal/codeguard/checks/quality/quality_ai_target_script.go index 2eb83e3..5ac1b41 100644 --- a/internal/codeguard/checks/quality/quality_ai_target_script.go +++ b/internal/codeguard/checks/quality/quality_ai_target_script.go @@ -56,7 +56,7 @@ type scriptFileScanInput struct { func scriptFileAIQualityFindings(env support.Context, root string, rel string, input scriptFileScanInput) []core.Finding { abs := filepath.Join(root, rel) - data, err := os.ReadFile(abs) + data, err := os.ReadFile(abs) //nolint:gosec // path resolved under the scan-target root if err != nil { return nil } @@ -136,7 +136,7 @@ func resolveRelativeScriptImport(root string, dir string, specifier string) bool filepath.Join(base, "index.js"), filepath.Join(base, "index.jsx"), } for _, candidate := range candidates { - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { //nolint:gosec // stat-only existence check; candidate is joined under the scan root return true } } diff --git a/internal/codeguard/checks/quality/quality_ai_test_idioms.go b/internal/codeguard/checks/quality/quality_ai_test_idioms.go index 4de81cf..03b466a 100644 --- a/internal/codeguard/checks/quality/quality_ai_test_idioms.go +++ b/internal/codeguard/checks/quality/quality_ai_test_idioms.go @@ -60,7 +60,7 @@ func readFrameworkFile(root string, rel string, include func(string) bool, detec if !include(rel) { return "", false } - data, err := os.ReadFile(filepath.Join(root, rel)) + data, err := os.ReadFile(filepath.Join(root, rel)) //nolint:gosec // file under the scan-target root if err != nil { return "", false } diff --git a/internal/codeguard/checks/quality/quality_coverage_go.go b/internal/codeguard/checks/quality/quality_coverage_go.go index 6081bbf..11818f2 100644 --- a/internal/codeguard/checks/quality/quality_coverage_go.go +++ b/internal/codeguard/checks/quality/quality_coverage_go.go @@ -43,7 +43,7 @@ func goCoverageProfile(ctx context.Context, env support.Context, target core.Tar return nil, fmt.Errorf("go test: %w", err) } - data, err := os.ReadFile(profilePath) + data, err := os.ReadFile(profilePath) //nolint:gosec // config-supplied coverage profile path if err != nil { return nil, err } diff --git a/internal/codeguard/checks/quality/quality_coverage_lcov.go b/internal/codeguard/checks/quality/quality_coverage_lcov.go index a72ade3..662fb13 100644 --- a/internal/codeguard/checks/quality/quality_coverage_lcov.go +++ b/internal/codeguard/checks/quality/quality_coverage_lcov.go @@ -34,7 +34,7 @@ func commandCoverageProfile(ctx context.Context, env support.Context, target cor if !filepath.IsAbs(reportPath) { reportPath = filepath.Join(target.Path, reportPath) } - data, err := os.ReadFile(reportPath) + data, err := os.ReadFile(reportPath) //nolint:gosec // config-supplied coverage report path if err != nil { return nil, fmt.Errorf("coverage report %q: %w", command.ReportPath, err) } diff --git a/internal/codeguard/checks/security/security_typescript.go b/internal/codeguard/checks/security/security_typescript.go index 4994029..35046af 100644 --- a/internal/codeguard/checks/security/security_typescript.go +++ b/internal/codeguard/checks/security/security_typescript.go @@ -9,7 +9,6 @@ import ( var ( typeScriptExecPattern = regexp.MustCompile(`\b(?:child_process\.)?(?:exec|execSync)\s*\(`) - typeScriptSpawnShellPattern = regexp.MustCompile(`\b(?:child_process\.)?(?:spawn|spawnSync)\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["']?`) diff --git a/internal/codeguard/checks/support/gomod.go b/internal/codeguard/checks/support/gomod.go index 606df61..ec667d4 100644 --- a/internal/codeguard/checks/support/gomod.go +++ b/internal/codeguard/checks/support/gomod.go @@ -9,7 +9,7 @@ import ( // GoModulePath reads the module path declared in dir/go.mod, or returns "" // when the file is missing or has no module directive. func GoModulePath(dir string) string { - data, err := os.ReadFile(filepath.Join(dir, "go.mod")) + data, err := os.ReadFile(filepath.Join(dir, "go.mod")) //nolint:gosec // fixed filename joined under the scan target dir if err != nil { return "" } diff --git a/internal/codeguard/checks/support/supply_chain_licenses_resolution.go b/internal/codeguard/checks/support/supply_chain_licenses_resolution.go index 2a4602a..83a52c1 100644 --- a/internal/codeguard/checks/support/supply_chain_licenses_resolution.go +++ b/internal/codeguard/checks/support/supply_chain_licenses_resolution.go @@ -14,7 +14,7 @@ import ( func resolveNodeDependencyLicense(root string, manifest core.SupplyChainManifest, dep core.SupplyChainDependency) (string, string) { for _, searchRoot := range manifestSearchRoots(root, manifest.Path) { manifestPath := filepath.Join(searchRoot, filepath.FromSlash(path.Join("node_modules", dep.Name, "package.json"))) - data, err := os.ReadFile(manifestPath) + data, err := os.ReadFile(manifestPath) //nolint:gosec // dependency manifest under the scan-target search root if err != nil { continue } @@ -38,7 +38,7 @@ func resolveNodeDependencyLicense(root string, manifest core.SupplyChainManifest func resolveCargoDependencyLicense(root string, manifest core.SupplyChainManifest, dep core.SupplyChainDependency) (string, string) { for _, searchRoot := range manifestSearchRoots(root, manifest.Path) { candidate := filepath.Join(searchRoot, "vendor", filepath.FromSlash(dep.Name), "Cargo.toml") - data, err := os.ReadFile(candidate) + data, err := os.ReadFile(candidate) //nolint:gosec // dependency manifest under the scan-target search root if err != nil { continue } @@ -54,7 +54,7 @@ func resolvePythonDependencyLicense(root string, manifest core.SupplyChainManife for _, pattern := range pythonMetadataPatterns(searchRoot, dep.Name) { matches, _ := filepath.Glob(pattern) for _, match := range matches { - data, err := os.ReadFile(match) + data, err := os.ReadFile(match) //nolint:gosec // dependency metadata file matched under the scan-target search root if err != nil { continue } diff --git a/internal/codeguard/checks/support/supply_chain_lockfiles.go b/internal/codeguard/checks/support/supply_chain_lockfiles.go index 642df5d..05ec3dc 100644 --- a/internal/codeguard/checks/support/supply_chain_lockfiles.go +++ b/internal/codeguard/checks/support/supply_chain_lockfiles.go @@ -21,7 +21,7 @@ func SupplyChainLockfileIssues(root string, manifest core.SupplyChainManifest) [ var parsedAny bool var firstIssues []string for _, lockfile := range manifest.Lockfiles { - data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(lockfile))) + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(lockfile))) //nolint:gosec // lockfile path joined under the scan-target root if err != nil { continue } diff --git a/internal/codeguard/checks/support/typescript_semantic.go b/internal/codeguard/checks/support/typescript_semantic.go index e6e6f0a..cabef2f 100644 --- a/internal/codeguard/checks/support/typescript_semantic.go +++ b/internal/codeguard/checks/support/typescript_semantic.go @@ -2,7 +2,7 @@ package support import ( "context" - "crypto/sha1" + "crypto/sha256" _ "embed" "encoding/hex" "encoding/json" @@ -61,7 +61,7 @@ func typeScriptSemanticCacheKey(input typeScriptSemanticInput) (string, error) { if err != nil { return "", err } - sum := sha1.Sum(data) + sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]), nil } @@ -70,7 +70,7 @@ func runTypeScriptSemanticRunner(ctx context.Context, input typeScriptSemanticIn if err != nil { return TypeScriptSemanticResults{}, err } - cmd := exec.CommandContext(ctx, "node", "-e", typeScriptSemanticRunner) + cmd := exec.CommandContext(ctx, "node", "-e", typeScriptSemanticRunner) //nolint:gosec // fixed node binary running an embedded constant script; input passed via stdin cmd.Stdin = strings.NewReader(string(payload)) output, err := cmd.CombinedOutput() if err != nil { diff --git a/internal/codeguard/checks/support/typescript_semantic_discovery.go b/internal/codeguard/checks/support/typescript_semantic_discovery.go index df17333..fc81aeb 100644 --- a/internal/codeguard/checks/support/typescript_semantic_discovery.go +++ b/internal/codeguard/checks/support/typescript_semantic_discovery.go @@ -56,6 +56,6 @@ func isTypeScriptLibPath(path string) bool { if strings.TrimSpace(path) == "" { return false } - info, err := os.Stat(path) + info, err := os.Stat(path) //nolint:gosec // stat-only existence check during source discovery return err == nil && !info.IsDir() } diff --git a/internal/codeguard/config/example_ai.go b/internal/codeguard/config/example_ai.go index d4c5ed0..bfb46b1 100644 --- a/internal/codeguard/config/example_ai.go +++ b/internal/codeguard/config/example_ai.go @@ -14,7 +14,7 @@ func exampleAIConfig() core.AIConfig { } func exampleAIProviderConfig() core.AIProviderConfig { - return core.AIProviderConfig{ + return core.AIProviderConfig{ //nolint:gosec // APIKeyEnv is an env var name, not a credential Type: "openai", Model: "gpt-5", BaseURL: "https://api.openai.com/v1", diff --git a/internal/codeguard/config/io.go b/internal/codeguard/config/io.go index 32b9f50..89142cb 100644 --- a/internal/codeguard/config/io.go +++ b/internal/codeguard/config/io.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -10,6 +11,10 @@ import ( "github.com/devr-tools/codeguard/internal/codeguard/core" ) +// maxConfigFileBytes caps how much of a config file is read into memory, +// guarding against an oversized file exhausting memory. +const maxConfigFileBytes = 32 << 20 + var ( defaultConfigNames = []string{"codeguard.yaml", "codeguard.yml", "codeguard.json"} directoryConfigNames = []string{"codeguard.yaml", "codeguard.yml", "codeguard.json", "config.yaml", "config.yml", "config.json"} @@ -22,7 +27,12 @@ func LoadFile(path string) (core.Config, error) { return core.Config{}, err } - data, err := os.ReadFile(resolvedPath) + f, err := os.Open(resolvedPath) //nolint:gosec // operator-supplied config path; read is size-capped by LimitReader below + if err != nil { + return core.Config{}, err + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, maxConfigFileBytes)) if err != nil { return core.Config{}, err } @@ -114,10 +124,10 @@ func WriteFile(path string, cfg core.Config) error { if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil && !errors.Is(err, os.ErrExist) { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil && !errors.Is(err, os.ErrExist) { return err } - return os.WriteFile(path, append(data, '\n'), 0o644) + return os.WriteFile(path, append(data, '\n'), 0o600) } func resolveConfigPath(path string) (string, error) { diff --git a/internal/codeguard/history/history.go b/internal/codeguard/history/history.go index be149ba..0a19d3e 100644 --- a/internal/codeguard/history/history.go +++ b/internal/codeguard/history/history.go @@ -63,7 +63,7 @@ func Scan(ctx context.Context, opts Options) (Report, error) { args = append(args, fmt.Sprintf("-n%d", opts.MaxCommits)) } - cmd := exec.CommandContext(ctx, "git", args...) + cmd := exec.CommandContext(ctx, "git", args...) //nolint:gosec // fixed git log subcommand; args are tool-controlled constants plus the scan repo path stdout, err := cmd.StdoutPipe() if err != nil { return Report{}, err diff --git a/internal/codeguard/report/text_banner.go b/internal/codeguard/report/text_banner.go index fcf588c..425ddc9 100644 --- a/internal/codeguard/report/text_banner.go +++ b/internal/codeguard/report/text_banner.go @@ -45,7 +45,7 @@ func loadBannerAsset() ([]byte, error) { } var firstErr error for _, path := range paths { - logo, err := os.ReadFile(path) + logo, err := os.ReadFile(path) //nolint:gosec // fixed banner asset path, not user input if err == nil { return logo, nil } diff --git a/internal/codeguard/runner/checks/checks.go b/internal/codeguard/runner/checks/checks.go index 4620d75..3796de8 100644 --- a/internal/codeguard/runner/checks/checks.go +++ b/internal/codeguard/runner/checks/checks.go @@ -2,6 +2,7 @@ package checks import ( "context" + "fmt" ciCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/ci" contractsCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/contracts" @@ -21,32 +22,56 @@ func Build(ctx context.Context, sc runnersupport.Context) []core.SectionResult { sections := make([]core.SectionResult, 0, 7) checkEnv := buildCheckContext(sc) if sc.Cfg.Checks.Quality { - sections = append(sections, qualityCheck.Run(ctx, checkEnv)) + sections = append(sections, safeRun("quality", "Quality", func() core.SectionResult { return qualityCheck.Run(ctx, checkEnv) })) } if sc.Cfg.Checks.Design { - sections = append(sections, designCheck.Run(ctx, checkEnv)) + sections = append(sections, safeRun("design", "Design", func() core.SectionResult { return designCheck.Run(ctx, checkEnv) })) } if sc.Cfg.Checks.Security { - sections = append(sections, securityCheck.Run(ctx, checkEnv)) + sections = append(sections, safeRun("security", "Security", func() core.SectionResult { return securityCheck.Run(ctx, checkEnv) })) } if sc.Cfg.Checks.Prompts { - sections = append(sections, promptsCheck.Run(ctx, checkEnv)) + sections = append(sections, safeRun("prompts", "Prompts", func() core.SectionResult { return promptsCheck.Run(ctx, checkEnv) })) } if sc.Cfg.Checks.CI { - sections = append(sections, ciCheck.Run(ctx, checkEnv)) + sections = append(sections, safeRun("ci", "CI", func() core.SectionResult { return ciCheck.Run(ctx, checkEnv) })) } if sc.Cfg.Checks.SupplyChain { - sections = append(sections, supplyChainCheck.Run(ctx, checkEnv)) + sections = append(sections, safeRun("supply-chain", "Supply Chain", func() core.SectionResult { return supplyChainCheck.Run(ctx, checkEnv) })) } if contractsEnabled(sc) { - sections = append(sections, contractsCheck.Run(ctx, checkEnv)) + sections = append(sections, safeRun("contracts", "Contracts", func() core.SectionResult { return contractsCheck.Run(ctx, checkEnv) })) } if len(sc.CustomRules) > 0 { - sections = append(sections, customrunner.RunSection(ctx, sc)) + sections = append(sections, safeRun("custom", "Custom Rules", func() core.SectionResult { return customrunner.RunSection(ctx, sc) })) } return sections } +// safeRun executes one check section, recovering from any panic so that a single +// failing check (e.g. an index-out-of-range while parsing an untrusted file) +// degrades to a diagnostic warning for that section instead of aborting the +// entire scan. On panic it returns a section bearing a single warning finding +// and the remaining sections still run. +func safeRun(id string, name string, fn func() core.SectionResult) (result core.SectionResult) { + defer func() { + if r := recover(); r != nil { + result = core.SectionResult{ + ID: id, + Name: name, + Status: core.StatusWarn, + Findings: []core.Finding{{ + RuleID: "checks.section.panic", + Level: "warning", + Section: id, + Message: fmt.Sprintf("%s check did not complete: internal error (%v)", name, r), + }}, + } + } + }() + return fn() +} + // contractsEnabled resolves the contracts toggle: an explicit config value // wins, otherwise the family is enabled only for diff scans. func contractsEnabled(sc runnersupport.Context) bool { diff --git a/internal/codeguard/runner/govulncheck/govulncheck.go b/internal/codeguard/runner/govulncheck/govulncheck.go index f970042..8b68646 100644 --- a/internal/codeguard/runner/govulncheck/govulncheck.go +++ b/internal/codeguard/runner/govulncheck/govulncheck.go @@ -1,6 +1,7 @@ package govulncheck import ( + "bytes" "context" "fmt" "os/exec" @@ -10,14 +11,50 @@ import ( runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" ) +// limitedWriter writes to w until remaining bytes are exhausted, then silently +// drops the rest while recording that truncation occurred. It lets a single +// writer back both cmd.Stdout and cmd.Stderr under a shared byte budget. +type limitedWriter struct { + w *bytes.Buffer + remaining int + truncated bool +} + +func (l *limitedWriter) Write(p []byte) (int, error) { + if l.remaining <= 0 { + l.truncated = true + return len(p), nil + } + if len(p) > l.remaining { + l.w.Write(p[:l.remaining]) + l.remaining = 0 + l.truncated = true + return len(p), nil + } + n, err := l.w.Write(p) + l.remaining -= n + return n, err +} + +// maxOutputBytes caps how much govulncheck output is buffered so a runaway or +// malicious tool cannot exhaust memory. +const maxOutputBytes = 64 << 20 // 64 MiB + func Run(ctx context.Context, dir string, cmdName string, sc runnersupport.Context) ([]core.Finding, error) { if strings.TrimSpace(cmdName) == "" { cmdName = "govulncheck" } cmd := exec.CommandContext(ctx, cmdName, "./...") cmd.Dir = dir - output, err := cmd.CombinedOutput() - text := string(output) + var buf bytes.Buffer + limited := &limitedWriter{w: &buf, remaining: maxOutputBytes} + cmd.Stdout = limited + cmd.Stderr = limited + err := cmd.Run() + if limited.truncated { + return nil, fmt.Errorf("govulncheck output exceeded %d bytes", maxOutputBytes) + } + text := buf.String() parsed := parseOutput(text, sc) if len(parsed) > 0 { return parsed, nil diff --git a/internal/codeguard/runner/support/artifacts.go b/internal/codeguard/runner/support/artifacts.go index 670a48c..94b6a12 100644 --- a/internal/codeguard/runner/support/artifacts.go +++ b/internal/codeguard/runner/support/artifacts.go @@ -53,7 +53,7 @@ func (store *ArtifactStore) List() []core.Artifact { func VisitTargetFiles(sc Context, target core.TargetConfig, include func(string) bool, visit func(rel string, data []byte)) { files, _ := WalkFiles(target.Path, sc.Cfg.Exclude, include) for _, file := range files { - data, err := os.ReadFile(filepath.Join(target.Path, file)) + data, err := os.ReadFile(filepath.Join(target.Path, file)) //nolint:gosec // file enumerated by WalkFiles under target.Path if err != nil { continue } diff --git a/internal/codeguard/runner/support/cache_helpers.go b/internal/codeguard/runner/support/cache_helpers.go index 949d69f..6f4c17a 100644 --- a/internal/codeguard/runner/support/cache_helpers.go +++ b/internal/codeguard/runner/support/cache_helpers.go @@ -1,7 +1,7 @@ package support import ( - "crypto/sha1" + "crypto/sha256" "encoding/hex" "encoding/json" "path/filepath" @@ -15,7 +15,7 @@ func cacheKey(sectionID string, targetPath string, rel string) string { } func hashBytes(data []byte) string { - sum := sha1.Sum(data) + sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) } diff --git a/internal/codeguard/runner/support/changed_files.go b/internal/codeguard/runner/support/changed_files.go index 4757b4e..5d1a4d4 100644 --- a/internal/codeguard/runner/support/changed_files.go +++ b/internal/codeguard/runner/support/changed_files.go @@ -2,7 +2,6 @@ package support import ( "fmt" - "os/exec" "path/filepath" "strings" @@ -16,16 +15,18 @@ func ListChangedFiles(sc Context, target core.TargetConfig) ([]core.ChangedFile, if sc.Opts.Mode != core.ScanModeDiff { return nil, nil } + if err := ValidateBaseRef(sc.Opts.BaseRef); err != nil { + return nil, err + } var lastErr error for _, ref := range []string{sc.Opts.BaseRef, sc.Opts.BaseRef + "...HEAD"} { - cmd := exec.Command("git", "-C", target.Path, "diff", "--name-status", "--no-renames", "--no-color", ref, "--") - output, err := cmd.Output() + output, err := runGitCapture("-C", target.Path, "diff", "--name-status", "--no-renames", "--no-color", "--end-of-options", ref, "--") if err == nil { return parseNameStatus(string(output)), nil } lastErr = err } - return nil, fmt.Errorf("diff mode requires git diff --name-status against %q: %v", sc.Opts.BaseRef, lastErr) + return nil, fmt.Errorf("diff mode requires git diff --name-status against %q: %w", sc.Opts.BaseRef, lastErr) } func parseNameStatus(output string) []core.ChangedFile { @@ -46,6 +47,8 @@ func parseNameStatus(output string) []core.ChangedFile { // ReadBaseFile returns the contents of a target-relative file at the diff // base ref. func ReadBaseFile(sc Context, target core.TargetConfig, rel string) ([]byte, error) { - cmd := exec.Command("git", "-C", target.Path, "show", sc.Opts.BaseRef+":./"+filepath.ToSlash(rel)) - return cmd.Output() + if err := ValidateBaseRef(sc.Opts.BaseRef); err != nil { + return nil, err + } + return runGitCapture("-C", target.Path, "show", "--end-of-options", sc.Opts.BaseRef+":./"+filepath.ToSlash(rel)) } diff --git a/internal/codeguard/runner/support/commands.go b/internal/codeguard/runner/support/commands.go index 393d4bf..86ed07c 100644 --- a/internal/codeguard/runner/support/commands.go +++ b/internal/codeguard/runner/support/commands.go @@ -56,7 +56,7 @@ func runCommandCheck(ctx context.Context, dir string, check core.CommandCheckCon if strings.Contains(command, string(filepath.Separator)) && !filepath.IsAbs(command) { command = filepath.Join(dir, command) } - cmd := exec.CommandContext(ctx, command, check.Args...) + cmd := exec.CommandContext(ctx, command, check.Args...) //nolint:gosec // command gated by trust.GuardConfigCommand above cmd.Dir = dir if len(env) > 0 { cmd.Env = env diff --git a/internal/codeguard/runner/support/context.go b/internal/codeguard/runner/support/context.go index e388503..1c35d0a 100644 --- a/internal/codeguard/runner/support/context.go +++ b/internal/codeguard/runner/support/context.go @@ -130,7 +130,7 @@ func WriteBaselineFile(path string, entries []core.BaselineEntry) error { if err != nil { return err } - return os.WriteFile(path, append(data, '\n'), 0o644) + return os.WriteFile(path, append(data, '\n'), 0o600) } func BaselineEntriesFromReport(report core.Report) []core.BaselineEntry { @@ -155,7 +155,7 @@ func BaselineEntriesFromReport(report core.Report) []core.BaselineEntry { } func loadBaselineFile(path string) (map[string]core.BaselineEntry, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // operator-supplied baseline file path from config if err != nil { return nil, err } diff --git a/internal/codeguard/runner/support/diff.go b/internal/codeguard/runner/support/diff.go index ab454bb..d18aae3 100644 --- a/internal/codeguard/runner/support/diff.go +++ b/internal/codeguard/runner/support/diff.go @@ -1,14 +1,109 @@ package support import ( + "bytes" + "context" "fmt" + "io" "os/exec" "strconv" "strings" + "time" "github.com/devr-tools/codeguard/internal/codeguard/core" ) +// gitCommandTimeout bounds how long a single git invocation may run before it +// is cancelled. It guards against a hung or pathological git process when no +// caller context is available to thread through. +const gitCommandTimeout = 2 * time.Minute + +// maxGitOutputBytes caps how much stdout codeguard will buffer from a git +// subprocess. A diff against a far-back base ref can be hundreds of MB; reading +// it unbounded risks exhausting memory, so output past this cap is an error. +const maxGitOutputBytes = 64 << 20 // 64 MiB + +// errGitOutputTooLarge is returned when a git subprocess produces more output +// than maxGitOutputBytes. +var errGitOutputTooLarge = fmt.Errorf("git output exceeded %d bytes", maxGitOutputBytes) + +// validBaseRef reports whether ref is a safe value to pass to git as a +// revision/ref:path argument. It rejects refs beginning with "-" (which git +// would otherwise parse as an option even after "--") and restricts the value +// to a conservative ref/SHA charset. The literal "stdin" sentinel (used when a +// diff is supplied directly rather than read from git) is always allowed. +func validBaseRef(ref string) bool { + if ref == "stdin" { + return true + } + if ref == "" || strings.HasPrefix(ref, "-") { + return false + } + for _, r := range ref { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9': + continue + } + switch r { + case '.', '_', '/', '-', '~', '^', '@', '{', '}', ':': + continue + default: + return false + } + } + return true +} + +// ValidateBaseRef validates a base ref at the trust boundary, returning a clear +// error when the ref could be misinterpreted by git as an option or contains +// unexpected characters. +func ValidateBaseRef(ref string) error { + if !validBaseRef(ref) { + return fmt.Errorf("invalid base ref %q", ref) + } + return nil +} + +// runGitCapture runs git with the given args, enforcing gitCommandTimeout and +// capturing at most maxGitOutputBytes of stdout. stderr is captured separately +// so it can be surfaced in errors without counting against the output cap. +func runGitCapture(args ...string) ([]byte, error) { + // TODO(harden): thread caller ctx once the diff helpers accept one. + ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "git", args...) //nolint:gosec // fixed git binary; args are tool-built (constants, validated baseRef, target paths) + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + return nil, err + } + + var buf bytes.Buffer + n, copyErr := io.Copy(&buf, io.LimitReader(stdout, maxGitOutputBytes+1)) + if n > maxGitOutputBytes { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return nil, errGitOutputTooLarge + } + if waitErr := cmd.Wait(); waitErr != nil { + if stderr.Len() > 0 { + return buf.Bytes(), fmt.Errorf("%w: %s", waitErr, strings.TrimSpace(stderr.String())) + } + return buf.Bytes(), waitErr + } + if copyErr != nil { + return buf.Bytes(), copyErr + } + return buf.Bytes(), nil +} + type LineRanges struct { allChanged bool ranges [][2]int @@ -38,20 +133,22 @@ func LoadDiffScope(targets []core.TargetConfig, baseRef string) (map[string]Line } func gitChangedLines(dir string, baseRef string) (map[string]LineRanges, error) { + if err := ValidateBaseRef(baseRef); err != nil { + return nil, err + } argsVariants := [][]string{ - {"-C", dir, "diff", "--unified=0", "--no-color", baseRef, "--"}, - {"-C", dir, "diff", "--unified=0", "--no-color", baseRef + "...HEAD", "--"}, + {"-C", dir, "diff", "--unified=0", "--no-color", "--end-of-options", baseRef, "--"}, + {"-C", dir, "diff", "--unified=0", "--no-color", "--end-of-options", baseRef + "...HEAD", "--"}, } var output []byte var err error for _, args := range argsVariants { - cmd := exec.Command("git", args...) - output, err = cmd.CombinedOutput() + output, err = runGitCapture(args...) if err == nil { return parseUnifiedDiff(string(output)), nil } } - return nil, fmt.Errorf("diff mode requires git diff against %q: %v", baseRef, err) + return nil, fmt.Errorf("diff mode requires git diff against %q: %w", baseRef, err) } func parseUnifiedDiff(diff string) map[string]LineRanges { diff --git a/internal/codeguard/runner/support/diff_command.go b/internal/codeguard/runner/support/diff_command.go index 43902c5..3cb0154 100644 --- a/internal/codeguard/runner/support/diff_command.go +++ b/internal/codeguard/runner/support/diff_command.go @@ -1,6 +1,7 @@ package support import ( + "context" "fmt" "io" "os" @@ -15,6 +16,10 @@ type diffCommandEnv struct { } func prepareDiffCommandEnv(dir string, baseRef string) (diffCommandEnv, func(), error) { + if err := ValidateBaseRef(baseRef); err != nil { + return diffCommandEnv{}, func() {}, err + } + repoRoot, err := gitRepoRoot(dir) if err != nil { return diffCommandEnv{}, func() {}, err @@ -42,7 +47,10 @@ func prepareDiffCommandEnv(dir string, baseRef string) (diffCommandEnv, func(), headRoot := filepath.Join(tempRoot, "head") baseWorktree := filepath.Join(tempRoot, "base-worktree") cleanup := func() { - _ = exec.Command("git", "-C", repoRoot, "worktree", "remove", "--force", baseWorktree).Run() + // TODO(harden): thread caller ctx once prepareDiffCommandEnv accepts one. + ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout) + defer cancel() + _ = exec.CommandContext(ctx, "git", "-C", repoRoot, "worktree", "remove", "--force", baseWorktree).Run() //nolint:gosec // fixed git subcommand; paths are tool-generated temp dirs _ = os.RemoveAll(tempRoot) } @@ -51,7 +59,10 @@ func prepareDiffCommandEnv(dir string, baseRef string) (diffCommandEnv, func(), return diffCommandEnv{}, func() {}, fmt.Errorf("copy head target: %w", err) } - cmd := exec.Command("git", "-C", repoRoot, "worktree", "add", "--detach", baseWorktree, baseRef) + // TODO(harden): thread caller ctx once prepareDiffCommandEnv accepts one. + addCtx, addCancel := context.WithTimeout(context.Background(), gitCommandTimeout) + defer addCancel() + cmd := exec.CommandContext(addCtx, "git", "-C", repoRoot, "worktree", "add", "--detach", "--end-of-options", baseWorktree, baseRef) //nolint:gosec // baseRef validated by ValidateBaseRef at function entry; --end-of-options blocks flag injection if output, err := cmd.CombinedOutput(); err != nil { cleanup() return diffCommandEnv{}, func() {}, fmt.Errorf("prepare base worktree for %q: %w: %s", baseRef, err, strings.TrimSpace(string(output))) @@ -59,7 +70,7 @@ func prepareDiffCommandEnv(dir string, baseRef string) (diffCommandEnv, func(), baseDir := filepath.Join(baseWorktree, relativeTarget) if info, err := os.Stat(baseDir); err != nil || !info.IsDir() { - if err := os.MkdirAll(baseDir, 0o755); err != nil { + if err := os.MkdirAll(baseDir, 0o750); err != nil { cleanup() return diffCommandEnv{}, func() {}, fmt.Errorf("prepare base target dir: %w", err) } @@ -87,7 +98,10 @@ func canonicalPath(path string) (string, error) { } func gitRepoRoot(dir string) (string, error) { - cmd := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel") + // TODO(harden): thread caller ctx once gitRepoRoot accepts one. + ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "--show-toplevel") //nolint:gosec // fixed git subcommand; dir is a config scan target path output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("resolve git repo root for %q: %w: %s", dir, err, strings.TrimSpace(string(output))) @@ -122,17 +136,21 @@ func copyDir(srcDir string, dstDir string) error { } func copyFile(srcPath string, dstPath string, mode os.FileMode) (err error) { - src, err := os.Open(srcPath) + src, err := os.Open(srcPath) //nolint:gosec // tool-generated source path during diff worktree copy if err != nil { return err } - defer src.Close() + defer func() { + if closeErr := src.Close(); err == nil && closeErr != nil { + err = closeErr + } + }() - if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(dstPath), 0o750); err != nil { return err } - dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) //nolint:gosec // tool-generated destination path during diff worktree copy if err != nil { return err } diff --git a/internal/codeguard/runner/support/findings.go b/internal/codeguard/runner/support/findings.go index 5db0a7d..c440cb0 100644 --- a/internal/codeguard/runner/support/findings.go +++ b/internal/codeguard/runner/support/findings.go @@ -1,7 +1,7 @@ package support import ( - "crypto/sha1" + "crypto/sha256" "encoding/hex" "os" "path/filepath" @@ -32,7 +32,7 @@ func ScanTargetFiles(sc Context, target core.TargetConfig, sectionID string, inc files, _ := WalkFiles(target.Path, sc.Cfg.Exclude, include) findings := make([]core.Finding, 0) for _, file := range files { - data, err := os.ReadFile(filepath.Join(target.Path, file)) + data, err := os.ReadFile(filepath.Join(target.Path, file)) //nolint:gosec // file enumerated by WalkFiles under target.Path if err != nil { continue } @@ -74,7 +74,7 @@ func NewFinding(sc Context, input FindingInput) core.Finding { input.Level = meta.DefaultLevel } input.Level = NormalizedSeverity(input.Level) - sum := sha1.Sum([]byte(strings.Join([]string{input.RuleID, normalizedPath, strconv.Itoa(input.Line), input.Message}, "|"))) + sum := sha256.Sum256([]byte(strings.Join([]string{input.RuleID, normalizedPath, strconv.Itoa(input.Line), input.Message}, "|"))) return core.Finding{ RuleID: input.RuleID, Level: input.Level, diff --git a/internal/codeguard/runner/support/patch.go b/internal/codeguard/runner/support/patch.go index 67d4b2f..8b51bdb 100644 --- a/internal/codeguard/runner/support/patch.go +++ b/internal/codeguard/runner/support/patch.go @@ -1,6 +1,7 @@ package support import ( + "context" "fmt" "os" "os/exec" @@ -82,7 +83,10 @@ func ApplyUnifiedDiff(cfg core.Config, diffText string) error { } func applyUnifiedDiff(dir string, diffText string) error { - cmd := exec.Command("git", "apply", "--recount", "--whitespace=nowarn") + // TODO(harden): thread caller ctx once applyUnifiedDiff accepts one. + ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "apply", "--recount", "--whitespace=nowarn") cmd.Dir = dir cmd.Stdin = strings.NewReader(diffText) output, err := cmd.CombinedOutput() diff --git a/internal/codeguard/runner/support/slop_history.go b/internal/codeguard/runner/support/slop_history.go index d5f24fb..455748e 100644 --- a/internal/codeguard/runner/support/slop_history.go +++ b/internal/codeguard/runner/support/slop_history.go @@ -39,7 +39,7 @@ func LoadSlopHistory(path string) map[string][]core.SlopHistoryEntry { if strings.TrimSpace(path) == "" { return map[string][]core.SlopHistoryEntry{} } - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // config-supplied slop-history cache path if err != nil { return map[string][]core.SlopHistoryEntry{} } @@ -82,8 +82,8 @@ func saveSlopHistory(path string, entries map[string][]core.SlopHistoryEntry) { if err != nil { return } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return } - _ = os.WriteFile(path, append(data, '\n'), 0o644) + _ = os.WriteFile(path, append(data, '\n'), 0o600) } diff --git a/internal/codeguard/runner/support/suppressions.go b/internal/codeguard/runner/support/suppressions.go index 79a7f70..556df94 100644 --- a/internal/codeguard/runner/support/suppressions.go +++ b/internal/codeguard/runner/support/suppressions.go @@ -94,7 +94,7 @@ func suppressionExpired(expires string, today time.Time) bool { } func parseInlineSuppressions(path string) ([]inlineSuppression, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // path resolved via findingFullPath against the scan context if err != nil { return nil, err } diff --git a/internal/codeguard/runner/support/utils.go b/internal/codeguard/runner/support/utils.go index 0f70493..92d15d9 100644 --- a/internal/codeguard/runner/support/utils.go +++ b/internal/codeguard/runner/support/utils.go @@ -6,10 +6,17 @@ import ( "path/filepath" "regexp" "strings" + "sync" "github.com/devr-tools/codeguard/internal/codeguard/core" ) +// patternCache memoizes compiled glob patterns keyed by the raw glob string. +// MatchPattern is the hottest compile site in the codebase (per file × per +// pattern during the walk), so compiling once and reusing avoids recompiling +// the same regex repeatedly. A nil value records a glob that failed to compile. +var patternCache sync.Map // map[string]*regexp.Regexp + func SummarizeSections(sections []core.SectionResult) core.ReportSummary { var summary core.ReportSummary for _, section := range sections { @@ -96,7 +103,25 @@ func MatchPattern(pattern string, value string) bool { if pattern == "" { return false } + re, ok := compilePattern(pattern) + if !ok { + // An untrusted glob (e.g. from repo config) that translates to an + // invalid regex matches nothing rather than panicking the scan. + return false + } + return re.MatchString(value) +} + +// compilePattern translates a (trimmed, slash-normalized) glob into an anchored +// regex, compiling it at most once per distinct glob. The second return value is +// false when the glob does not yield a valid regex. +func compilePattern(pattern string) (*regexp.Regexp, bool) { + if cached, ok := patternCache.Load(pattern); ok { + re, _ := cached.(*regexp.Regexp) + return re, re != nil + } replacer := strings.NewReplacer( + `\`, `\\`, `.`, `\.`, `+`, `\+`, `(`, `\(`, @@ -113,8 +138,13 @@ func MatchPattern(pattern string, value string) bool { expr = strings.ReplaceAll(expr, "*", `[^/]*`) expr = strings.ReplaceAll(expr, "§§DOUBLESTAR§§", `.*`) expr = strings.ReplaceAll(expr, "?", `[^/]`) - re := regexp.MustCompile("^" + expr + "$") - return re.MatchString(value) + re, err := regexp.Compile("^" + expr + "$") + if err != nil { + patternCache.Store(pattern, (*regexp.Regexp)(nil)) + return nil, false + } + patternCache.Store(pattern, re) + return re, true } func CountLines(data []byte) int { diff --git a/internal/githubaction/comment_client.go b/internal/githubaction/comment_client.go index a342084..8d01ef8 100644 --- a/internal/githubaction/comment_client.go +++ b/internal/githubaction/comment_client.go @@ -2,16 +2,22 @@ package githubaction import ( "bytes" + "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" + "time" ) const MaxCommentBodyBytes = 65000 +// defaultClientTimeout bounds GitHub API requests when the caller-supplied +// client has no timeout of its own. +const defaultClientTimeout = 30 * time.Second + type CommentPublisher struct { BaseURL string Token string @@ -19,27 +25,30 @@ type CommentPublisher struct { } func (p CommentPublisher) Publish(repository string, prNumber int, body string, mode string) error { + // TODO(harden): thread caller ctx through Publish once the cmd entrypoint + // is updated to supply one. + ctx := context.Background() switch strings.TrimSpace(mode) { case "", "sticky": - return p.publishSticky(repository, prNumber, body) + return p.publishSticky(ctx, repository, prNumber, body) case "new": - return p.createComment(repository, prNumber, body) + return p.createComment(ctx, repository, prNumber, body) default: return fmt.Errorf("unsupported mode %q", mode) } } -func (p CommentPublisher) publishSticky(repository string, prNumber int, body string) error { - comments, err := p.listComments(repository, prNumber) +func (p CommentPublisher) publishSticky(ctx context.Context, repository string, prNumber int, body string) error { + comments, err := p.listComments(ctx, repository, prNumber) if err != nil { return err } for _, comment := range comments { if strings.Contains(comment.Body, StickyMarkerPrefix) { - return p.updateComment(repository, comment.ID, body) + return p.updateComment(ctx, repository, comment.ID, body) } } - return p.createComment(repository, prNumber, body) + return p.createComment(ctx, repository, prNumber, body) } // escapeRepository percent-encodes each segment of an "owner/repo" identifier @@ -52,8 +61,8 @@ func escapeRepository(repository string) string { return strings.Join(parts, "/") } -func (p CommentPublisher) listComments(repository string, prNumber int) ([]issueComment, error) { - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/repos/%s/issues/%d/comments?per_page=100", p.BaseURL, escapeRepository(repository), prNumber), nil) +func (p CommentPublisher) listComments(ctx context.Context, repository string, prNumber int) ([]issueComment, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/repos/%s/issues/%d/comments?per_page=100", p.BaseURL, escapeRepository(repository), prNumber), nil) if err != nil { return nil, err } @@ -64,20 +73,20 @@ func (p CommentPublisher) listComments(repository string, prNumber int) ([]issue return comments, nil } -func (p CommentPublisher) createComment(repository string, prNumber int, body string) error { - return p.sendCommentRequest(http.MethodPost, fmt.Sprintf("%s/repos/%s/issues/%d/comments", p.BaseURL, escapeRepository(repository), prNumber), body, http.StatusCreated) +func (p CommentPublisher) createComment(ctx context.Context, repository string, prNumber int, body string) error { + return p.sendCommentRequest(ctx, http.MethodPost, fmt.Sprintf("%s/repos/%s/issues/%d/comments", p.BaseURL, escapeRepository(repository), prNumber), body, http.StatusCreated) } -func (p CommentPublisher) updateComment(repository string, commentID int64, body string) error { - return p.sendCommentRequest(http.MethodPatch, fmt.Sprintf("%s/repos/%s/issues/comments/%d", p.BaseURL, escapeRepository(repository), commentID), body, http.StatusOK) +func (p CommentPublisher) updateComment(ctx context.Context, repository string, commentID int64, body string) error { + return p.sendCommentRequest(ctx, http.MethodPatch, fmt.Sprintf("%s/repos/%s/issues/comments/%d", p.BaseURL, escapeRepository(repository), commentID), body, http.StatusOK) } -func (p CommentPublisher) sendCommentRequest(method string, url string, body string, wantStatus int) error { +func (p CommentPublisher) sendCommentRequest(ctx context.Context, method string, url string, body string, wantStatus int) error { payload, err := json.Marshal(issueCommentRequest{Body: TruncateCommentBody(body)}) if err != nil { return err } - req, err := http.NewRequest(method, url, bytes.NewReader(payload)) + req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(payload)) if err != nil { return err } @@ -85,16 +94,37 @@ func (p CommentPublisher) sendCommentRequest(method string, url string, body str return p.doJSON(req, wantStatus, nil) } -func (p CommentPublisher) doJSON(req *http.Request, wantStatus int, out any) error { +// httpClient returns the caller-supplied client, falling back to one with a +// sane timeout so a hung GitHub endpoint cannot block indefinitely. +func (p CommentPublisher) httpClient() *http.Client { + if p.Client == nil { + return &http.Client{Timeout: defaultClientTimeout} + } + if p.Client.Timeout <= 0 { + clone := *p.Client + clone.Timeout = defaultClientTimeout + return &clone + } + return p.Client +} + +func (p CommentPublisher) doJSON(req *http.Request, wantStatus int, out any) (err error) { req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("Authorization", "Bearer "+p.Token) req.Header.Set("User-Agent", "codeguard-action") - resp, err := p.Client.Do(req) + // The request host is the constant GitHub API base (api.github.com or the + // GHES equivalent passed via BaseURL), not attacker-controlled, so the URL + // taint flagged by gosec G704 is not exploitable here. + resp, err := p.httpClient().Do(req) //nolint:gosec // host is the constant GitHub API base if err != nil { return err } - defer resp.Body.Close() + defer func() { + if cerr := resp.Body.Close(); cerr != nil && err == nil { + err = cerr + } + }() body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) if err != nil { diff --git a/tests/checks/ci_additional_languages_test.go b/tests/checks/ci_additional_languages_test.go index 9e0a173..2728b49 100644 --- a/tests/checks/ci_additional_languages_test.go +++ b/tests/checks/ci_additional_languages_test.go @@ -12,7 +12,6 @@ func TestCICheckHandlesAdditionalLanguageTestPaths(t *testing.T) { t.Parallel() for _, tc := range ciAdditionalLanguageCases() { - tc := tc t.Run(tc.name+"-fail", func(t *testing.T) { report := runCITestPathCase(t, tc, false) assertSectionStatus(t, report, "CI/CD", "fail") diff --git a/tests/checks/parser_migration_test.go b/tests/checks/parser_migration_test.go index fd72d0f..09c4543 100644 --- a/tests/checks/parser_migration_test.go +++ b/tests/checks/parser_migration_test.go @@ -150,7 +150,6 @@ func TestSecurityIgnoresPrimitivesInCommentsAndStrings(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, filepath.FromSlash(tc.path)), tc.source) diff --git a/tests/checks/quality_additional_languages_test.go b/tests/checks/quality_additional_languages_test.go index 395d244..5879057 100644 --- a/tests/checks/quality_additional_languages_test.go +++ b/tests/checks/quality_additional_languages_test.go @@ -12,7 +12,6 @@ func TestQualityCheckWarnsForAdditionalLanguageMaintainability(t *testing.T) { t.Parallel() for _, tc := range additionalLanguageMaintainabilityCases() { - tc := tc t.Run(tc.name, func(t *testing.T) { report := runAdditionalLanguageQualityCase(t, tc) assertSectionStatus(t, report, "Code Quality", "warn") diff --git a/tests/checks/security_additional_languages_test.go b/tests/checks/security_additional_languages_test.go index 0c3f1d5..0cb398d 100644 --- a/tests/checks/security_additional_languages_test.go +++ b/tests/checks/security_additional_languages_test.go @@ -54,7 +54,6 @@ func TestSecurityCheckFindsAdditionalLanguagePatterns(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, filepath.FromSlash(tc.path)), tc.source) diff --git a/tests/checks/security_secrets_test.go b/tests/checks/security_secrets_test.go index ae7a755..62f5d58 100644 --- a/tests/checks/security_secrets_test.go +++ b/tests/checks/security_secrets_test.go @@ -60,7 +60,6 @@ func TestSecurityDetectsKnownCredentialFormats(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() dir := t.TempDir() diff --git a/tests/support/clike_parser_test.go b/tests/support/clike_parser_test.go index 7662a8c..b314ad6 100644 --- a/tests/support/clike_parser_test.go +++ b/tests/support/clike_parser_test.go @@ -191,8 +191,9 @@ func TestParseJavaStructure(t *testing.T) { } func functionNames(file *support.ParsedFile) []string { - names := make([]string, 0) - for _, fn := range file.AllFunctions() { + allFns := file.AllFunctions() + names := make([]string, 0, len(allFns)) + for _, fn := range allFns { names = append(names, fn.Name) } return names diff --git a/tests/support/python_parser_test.go b/tests/support/python_parser_test.go index 481ffe8..d5e7200 100644 --- a/tests/support/python_parser_test.go +++ b/tests/support/python_parser_test.go @@ -36,7 +36,7 @@ func TestParsePythonStructure(t *testing.T) { file := support.ParsePython(trickyPython) if len(file.Functions) != 2 { - names := make([]string, 0) + names := make([]string, 0, len(file.Functions)) for _, fn := range file.Functions { names = append(names, fn.Name) } From 7a0bd52d2c31975f34028312b8f202d2255b568f Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Tue, 30 Jun 2026 15:19:11 -0400 Subject: [PATCH 2/6] refactor(checks,cli): data-driven section registry, shared finding builder, CLI cleanup Behavior-preserving refactors (output byte-for-byte identical, 430 tests pass): - runner/checks: replace the hardcoded section-dispatch if-ladder with a data-driven sectionRegistry ([]sectionDef) iterated by Build; all 8 sections covered, panic-recovery (safeRun) and ordering preserved (new registry.go) - checks/quality: add warnFinding() builder and migrate 21 hand-written FindingInput struct literals onto it, incl. the three per-language performance scanners and the three near-identical AI-quality emitters - cli: extract parseFlags helper and name exit-code constants (exitOK/exitError) in new exit.go; migrate the five commands.go handlers Note: the per-language performance scanners were found to use genuinely different strategies (Go AST / Python indent / TS brace-depth), so no shared line-scanner was forced. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/commands.go | 66 +++++------ internal/cli/exit.go | 26 +++++ .../checks/quality/finding_builders.go | 21 ++++ .../codeguard/checks/quality/quality_ai.go | 60 ++-------- .../checks/quality/quality_ai_dead_code.go | 20 +--- .../quality/quality_ai_dead_code_python.go | 10 +- .../quality/quality_ai_dead_code_script.go | 20 +--- .../quality/quality_ai_error_style_python.go | 10 +- .../checks/quality/quality_ai_naming_drift.go | 10 +- .../checks/quality/quality_ai_style_drift.go | 10 +- .../checks/quality/quality_ai_target_go.go | 30 +---- .../quality/quality_ai_target_python.go | 10 +- .../quality/quality_ai_target_script.go | 30 +---- .../checks/quality/quality_ai_test_idioms.go | 10 +- .../codeguard/checks/quality/quality_clone.go | 18 +-- .../codeguard/checks/quality/quality_go.go | 20 +--- .../checks/quality/quality_metrics.go | 30 +---- .../checks/quality/quality_performance_go.go | 20 +--- .../quality/quality_performance_go_alloc.go | 9 +- .../quality/quality_performance_go_loops.go | 10 +- .../quality/quality_performance_python.go | 9 +- .../quality/quality_performance_typescript.go | 9 +- .../quality/quality_typescript_helpers.go | 9 +- internal/codeguard/runner/checks/checks.go | 40 ++----- internal/codeguard/runner/checks/registry.go | 103 ++++++++++++++++++ 25 files changed, 257 insertions(+), 353 deletions(-) create mode 100644 internal/cli/exit.go create mode 100644 internal/codeguard/checks/quality/finding_builders.go create mode 100644 internal/codeguard/runner/checks/registry.go diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 0d44c65..ef2bc1d 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -17,28 +17,28 @@ func runInit(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) output := fs.String("output", service.DefaultConfigPath(), "output config path") interactive := fs.Bool("interactive", false, "prompt for config values in the terminal") profile := fs.String("profile", "", "optional policy profile: startup, strict, enterprise, ai-safe") - if err := fs.Parse(args); err != nil { - return 1 + if ok, code := parseFlags(fs, args, stderr); !ok { + return code } cfg, err := exampleConfigForProfile(strings.TrimSpace(*profile)) if err != nil { _, _ = fmt.Fprintf(stderr, "init profile: %v\n", err) - return 1 + return exitError } if *interactive { if err := promptInitValues(bufio.NewReader(stdin), stdout, output, &cfg.Name); err != nil { _, _ = fmt.Fprintf(stderr, "interactive init: %v\n", err) - return 1 + return exitError } } if err := service.WriteConfigFile(*output, cfg); err != nil { _, _ = fmt.Fprintf(stderr, "write config: %v\n", err) - return 1 + return exitError } _, _ = fmt.Fprintf(stdout, "wrote %s\n", *output) - return 0 + return exitOK } func runValidate(args []string, stdout io.Writer, stderr io.Writer) int { @@ -46,22 +46,22 @@ func runValidate(args []string, stdout io.Writer, stderr io.Writer) int { fs.SetOutput(stderr) configPath := fs.String("config", service.DefaultConfigPath(), "config file or directory path") profile := fs.String("profile", "", "optional policy profile override") - if err := fs.Parse(args); err != nil { - return 1 + if ok, code := parseFlags(fs, args, stderr); !ok { + return code } cfg, err := loadConfigWithProfile(*configPath, *profile) if err != nil { _, _ = fmt.Fprintf(stderr, "load config: %v\n", err) - return 1 + return exitError } if err := service.ValidateConfig(cfg); err != nil { _, _ = fmt.Fprintf(stderr, "invalid config: %v\n", err) - return 1 + return exitError } _, _ = fmt.Fprintln(stdout, "config valid") - return 0 + return exitOK } func runScan(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { @@ -72,26 +72,26 @@ func runScan(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) format := fs.String("format", "", "optional output format override: text, json, sarif, github") enableAI := fs.Bool("ai", false, "enable optional AI-assisted analysis") interactive := fs.Bool("interactive", false, "prompt for scan inputs in the terminal") - if err := fs.Parse(args); err != nil { - return 1 + if ok, code := parseFlags(fs, args, stderr); !ok { + return code } flags.applyTrustPolicy() if err := promptScanInputs(*interactive, stdin, stdout, &inputs); err != nil { _, _ = fmt.Fprintf(stderr, "interactive scan: %v\n", err) - return 1 + return exitError } scanMode, err := parseScanMode(*inputs.mode) if err != nil { _, _ = fmt.Fprintln(stderr, err) - return 1 + return exitError } cfg, err := loadConfigWithProfile(*inputs.configPath, *flags.profile) if err != nil { _, _ = fmt.Fprintf(stderr, "load config: %v\n", err) - return 1 + return exitError } if trimmedFormat := strings.TrimSpace(*format); trimmedFormat != "" { cfg.Output.Format = trimmedFormat @@ -99,9 +99,9 @@ func runScan(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) if err := executeScan(stdout, cfg, scanMode, strings.TrimSpace(*inputs.baseRef), *enableAI); err != nil { _, _ = fmt.Fprintf(stderr, "scan failed: %v\n", err) - return 1 + return exitError } - return 0 + return exitOK } func runValidatePatch(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { @@ -111,24 +111,24 @@ func runValidatePatch(args []string, stdin io.Reader, stdout io.Writer, stderr i format := fs.String("format", "", "optional output format override: text, json, sarif, github") enableAI := fs.Bool("ai", false, "enable optional AI-assisted analysis") profile := fs.String("profile", "", "optional policy profile override") - if err := fs.Parse(args); err != nil { - return 1 + if ok, code := parseFlags(fs, args, stderr); !ok { + return code } diffText, err := io.ReadAll(stdin) if err != nil { _, _ = fmt.Fprintf(stderr, "read patch stdin: %v\n", err) - return 1 + return exitError } if strings.TrimSpace(string(diffText)) == "" { _, _ = fmt.Fprintln(stderr, "validate-patch requires a unified diff on stdin") - return 1 + return exitError } cfg, err := loadConfigWithProfile(*configPath, *profile) if err != nil { _, _ = fmt.Fprintf(stderr, "load config: %v\n", err) - return 1 + return exitError } if trimmedFormat := strings.TrimSpace(*format); trimmedFormat != "" { cfg.Output.Format = trimmedFormat @@ -142,16 +142,16 @@ func runValidatePatch(args []string, stdin io.Reader, stdout io.Writer, stderr i }) if err != nil { _, _ = fmt.Fprintf(stderr, "patch validation failed: %v\n", err) - return 1 + return exitError } if err := service.WriteReport(stdout, report, cfg.Output.Format); err != nil { _, _ = fmt.Fprintf(stderr, "write report: %v\n", err) - return 1 + return exitError } if report.Summary.FailedSections > 0 { - return 1 + return exitError } - return 0 + return exitOK } func runBaseline(args []string, stdout io.Writer, stderr io.Writer) int { @@ -159,15 +159,15 @@ func runBaseline(args []string, stdout io.Writer, stderr io.Writer) int { fs.SetOutput(stderr) flags := registerScanRunFlags(fs) outputPath := fs.String("output", "codeguard-baseline.json", "baseline output path") - if err := fs.Parse(args); err != nil { - return 1 + if ok, code := parseFlags(fs, args, stderr); !ok { + return code } flags.applyTrustPolicy() cfg, err := loadConfigWithProfile(*flags.configPath, *flags.profile) if err != nil { _, _ = fmt.Fprintf(stderr, "load config: %v\n", err) - return 1 + return exitError } cfg.Baseline.Path = "" @@ -177,14 +177,14 @@ func runBaseline(args []string, stdout io.Writer, stderr io.Writer) int { }) if err != nil { _, _ = fmt.Fprintf(stderr, "baseline scan failed: %v\n", err) - return 1 + return exitError } if err := service.WriteBaselineFile(*outputPath, service.BaselineEntriesFromReport(report)); err != nil { _, _ = fmt.Fprintf(stderr, "write baseline: %v\n", err) - return 1 + return exitError } _, _ = fmt.Fprintf(stdout, "wrote %s\n", *outputPath) - return 0 + return exitOK } func promptInitValues(reader *bufio.Reader, stdout io.Writer, output *string, configName *string) error { diff --git a/internal/cli/exit.go b/internal/cli/exit.go new file mode 100644 index 0000000..24271a6 --- /dev/null +++ b/internal/cli/exit.go @@ -0,0 +1,26 @@ +package cli + +import ( + "flag" + "io" +) + +// Process exit codes returned by command handlers. +const ( + // exitOK indicates the command completed successfully. + exitOK = 0 + // exitError indicates the command failed, or a scan/patch reported one or + // more failed sections. + exitError = 1 +) + +// parseFlags parses args into fs, returning ok=false together with the exit +// code a handler should return when parsing fails. Flag errors are reported by +// the FlagSet's own output (set to stderr by callers), so no additional message +// is written here. +func parseFlags(fs *flag.FlagSet, args []string, _ io.Writer) (ok bool, code int) { + if err := fs.Parse(args); err != nil { + return false, exitError + } + return true, exitOK +} diff --git a/internal/codeguard/checks/quality/finding_builders.go b/internal/codeguard/checks/quality/finding_builders.go new file mode 100644 index 0000000..99d2965 --- /dev/null +++ b/internal/codeguard/checks/quality/finding_builders.go @@ -0,0 +1,21 @@ +package quality + +import ( + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// warnFinding builds a warn-level finding. The quality checks construct the +// same support.FindingInput literal (Level "warn", a path, line, column and +// message) at dozens of call sites; this helper collapses that boilerplate +// while preserving the exact field values, so findings output is unchanged. +func warnFinding(env support.Context, ruleID string, file string, line int, column int, message string) core.Finding { + return env.NewFinding(support.FindingInput{ + RuleID: ruleID, + Level: "warn", + Path: file, + Line: line, + Column: column, + Message: message, + }) +} diff --git a/internal/codeguard/checks/quality/quality_ai.go b/internal/codeguard/checks/quality/quality_ai.go index 7710fa4..6602294 100644 --- a/internal/codeguard/checks/quality/quality_ai.go +++ b/internal/codeguard/checks/quality/quality_ai.go @@ -24,14 +24,8 @@ func goAIQualityFindings(env support.Context, file string, fset *token.FileSet, } if rhsIdent, ok := assign.Rhs[idx].(*ast.Ident); ok && rhsIdent.Name == "err" { pos := fset.Position(lhs.Pos()) - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.swallowed-error", - Level: "warn", - Path: file, - Line: pos.Line, - Column: pos.Column, - Message: "error is assigned to the blank identifier and effectively ignored", - })) + findings = append(findings, warnFinding(env, "quality.ai.swallowed-error", file, pos.Line, pos.Column, + "error is assigned to the blank identifier and effectively ignored")) } } return true @@ -44,14 +38,8 @@ func goAIQualityFindings(env support.Context, file string, fset *token.FileSet, continue } pos := fset.Position(comment.Pos()) - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.narrative-comment", - Level: "warn", - Path: file, - Line: pos.Line, - Column: pos.Column, - Message: "comment narrates the code instead of explaining intent or constraints", - })) + findings = append(findings, warnFinding(env, "quality.ai.narrative-comment", file, pos.Line, pos.Column, + "comment narrates the code instead of explaining intent or constraints")) } } return findings @@ -61,28 +49,16 @@ func pythonAIQualityFindings(env support.Context, file string, data []byte) []co source := strings.ReplaceAll(string(data), "\r\n", "\n") findings := make([]core.Finding, 0) for _, line := range regexLineMatches(aiPythonPassExceptPattern, source) { - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.swallowed-error", - Level: "warn", - Path: file, - Line: line, - Column: 1, - Message: "except block swallows the error without handling or re-raising it", - })) + findings = append(findings, warnFinding(env, "quality.ai.swallowed-error", file, line, 1, + "except block swallows the error without handling or re-raising it")) } for idx, line := range strings.Split(source, "\n") { text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#")) if !isNarrativeComment(text) { continue } - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.narrative-comment", - Level: "warn", - Path: file, - Line: idx + 1, - Column: 1, - Message: "comment narrates the code instead of explaining intent or constraints", - })) + findings = append(findings, warnFinding(env, "quality.ai.narrative-comment", file, idx+1, 1, + "comment narrates the code instead of explaining intent or constraints")) } return findings } @@ -90,28 +66,16 @@ func pythonAIQualityFindings(env support.Context, file string, data []byte) []co func typeScriptAIQualityFindings(ctx typeScriptScanContext) []core.Finding { findings := make([]core.Finding, 0) for _, line := range regexLineMatches(aiEmptyCatchPattern, ctx.source) { - findings = append(findings, ctx.env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.swallowed-error", - Level: "warn", - Path: ctx.file, - Line: line, - Column: 1, - Message: support.ScriptLabelForPath(ctx.file) + " catch block swallows the error without handling it", - })) + findings = append(findings, warnFinding(ctx.env, "quality.ai.swallowed-error", ctx.file, line, 1, + support.ScriptLabelForPath(ctx.file)+" catch block swallows the error without handling it")) } for idx, line := range strings.Split(ctx.source, "\n") { text, ok := extractScriptCommentText(line) if !ok || !isNarrativeComment(text) { continue } - findings = append(findings, ctx.env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.narrative-comment", - Level: "warn", - Path: ctx.file, - Line: idx + 1, - Column: 1, - Message: support.ScriptLabelForPath(ctx.file) + " comment narrates the code instead of explaining intent or constraints", - })) + findings = append(findings, warnFinding(ctx.env, "quality.ai.narrative-comment", ctx.file, idx+1, 1, + support.ScriptLabelForPath(ctx.file)+" comment narrates the code instead of explaining intent or constraints")) } return findings } diff --git a/internal/codeguard/checks/quality/quality_ai_dead_code.go b/internal/codeguard/checks/quality/quality_ai_dead_code.go index 6b73c2f..f98a71c 100644 --- a/internal/codeguard/checks/quality/quality_ai_dead_code.go +++ b/internal/codeguard/checks/quality/quality_ai_dead_code.go @@ -16,14 +16,8 @@ func goUnreachableCodeFindings(env support.Context, file string, fset *token.Fil findings := make([]core.Finding, 0) flag := func(stmt ast.Stmt) { pos := fset.Position(stmt.Pos()) - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.dead-code", - Level: "warn", - Path: file, - Line: pos.Line, - Column: pos.Column, - Message: "statement is unreachable because the previous statement unconditionally exits the block", - })) + findings = append(findings, warnFinding(env, "quality.ai.dead-code", file, pos.Line, pos.Column, + "statement is unreachable because the previous statement unconditionally exits the block")) } ast.Inspect(parsed, func(node ast.Node) bool { switch block := node.(type) { @@ -104,14 +98,8 @@ func goUnusedPrivateFunctionFindings(env support.Context, packageFiles []goParse if _, ok := used[site.name]; ok { continue } - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.dead-code", - Level: "warn", - Path: site.rel, - Line: site.pos.Line, - Column: site.pos.Column, - Message: fmt.Sprintf("private function %q is declared but never referenced within its package", site.name), - })) + findings = append(findings, warnFinding(env, "quality.ai.dead-code", site.rel, site.pos.Line, site.pos.Column, + fmt.Sprintf("private function %q is declared but never referenced within its package", site.name))) } return findings } diff --git a/internal/codeguard/checks/quality/quality_ai_dead_code_python.go b/internal/codeguard/checks/quality/quality_ai_dead_code_python.go index 5a051e5..91533e6 100644 --- a/internal/codeguard/checks/quality/quality_ai_dead_code_python.go +++ b/internal/codeguard/checks/quality/quality_ai_dead_code_python.go @@ -89,14 +89,8 @@ func pythonUnusedPrivateFunctionFindings(env support.Context, file string, sourc continue } line := 1 + strings.Count(source[:match[2]], "\n") - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.dead-code", - Level: "warn", - Path: file, - Line: line, - Column: 1, - Message: fmt.Sprintf("private function %q is declared but never referenced in this file", name), - })) + findings = append(findings, warnFinding(env, "quality.ai.dead-code", file, line, 1, + fmt.Sprintf("private function %q is declared but never referenced in this file", name))) } return findings } 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 bb432c3..e8504cb 100644 --- a/internal/codeguard/checks/quality/quality_ai_dead_code_script.go +++ b/internal/codeguard/checks/quality/quality_ai_dead_code_script.go @@ -20,14 +20,8 @@ var ( // unreachableStatementFinding builds the shared dead-code finding emitted when // a statement follows an unconditional block terminator. func unreachableStatementFinding(env support.Context, file string, line int) core.Finding { - return env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.dead-code", - Level: "warn", - Path: file, - Line: line, - Column: 1, - Message: "statement is unreachable because the previous statement unconditionally exits the block", - }) + return warnFinding(env, "quality.ai.dead-code", file, line, 1, + "statement is unreachable because the previous statement unconditionally exits the block") } func scriptUnreachableFindings(env support.Context, file string, source string) []core.Finding { @@ -75,14 +69,8 @@ func scriptUnusedFunctionFindings(env support.Context, file string, source strin continue } line := 1 + strings.Count(sanitized[:match[2]], "\n") - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.dead-code", - Level: "warn", - Path: file, - Line: line, - Column: 1, - Message: fmt.Sprintf("file-local function %q is declared but never referenced in this file", name), - })) + findings = append(findings, warnFinding(env, "quality.ai.dead-code", file, line, 1, + fmt.Sprintf("file-local function %q is declared but never referenced in this file", name))) } return findings } diff --git a/internal/codeguard/checks/quality/quality_ai_error_style_python.go b/internal/codeguard/checks/quality/quality_ai_error_style_python.go index dc24ee2..c3d7f1a 100644 --- a/internal/codeguard/checks/quality/quality_ai_error_style_python.go +++ b/internal/codeguard/checks/quality/quality_ai_error_style_python.go @@ -52,14 +52,8 @@ func pythonErrorStyleDriftFindings(env support.Context, file string, source stri } findings := make([]core.Finding, 0, counts.bareExcepts) for _, line := range regexLineMatches(pythonBareExceptPattern, source) { - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.error-style-drift", - Level: "warn", - Path: file, - Line: line, - Column: 1, - Message: "bare except clause diverges from the repository's typed exception handling style", - })) + findings = append(findings, warnFinding(env, "quality.ai.error-style-drift", file, line, 1, + "bare except clause diverges from the repository's typed exception handling style")) } return findings } diff --git a/internal/codeguard/checks/quality/quality_ai_naming_drift.go b/internal/codeguard/checks/quality/quality_ai_naming_drift.go index 5dae5ad..8b2d703 100644 --- a/internal/codeguard/checks/quality/quality_ai_naming_drift.go +++ b/internal/codeguard/checks/quality/quality_ai_naming_drift.go @@ -104,14 +104,8 @@ func namingDriftFinding(env support.Context, file string, source string, dominan if divergent < 2 || divergent <= matching { return nil } - return []core.Finding{env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.naming-drift", - Level: "warn", - Path: file, - Line: firstDivergent.line, - Column: 1, - Message: fmt.Sprintf("identifier %q diverges from the repository's dominant %s naming convention", firstDivergent.name, dominant), - })} + return []core.Finding{warnFinding(env, "quality.ai.naming-drift", file, firstDivergent.line, 1, + fmt.Sprintf("identifier %q diverges from the repository's dominant %s naming convention", firstDivergent.name, dominant))} } func goDeclaredNames(source string) []nameAt { diff --git a/internal/codeguard/checks/quality/quality_ai_style_drift.go b/internal/codeguard/checks/quality/quality_ai_style_drift.go index 5cfda9f..7b0a06c 100644 --- a/internal/codeguard/checks/quality/quality_ai_style_drift.go +++ b/internal/codeguard/checks/quality/quality_ai_style_drift.go @@ -115,12 +115,6 @@ func errorStyleDriftFinding(env support.Context, file string, dominant string, c if counts[fileDominant] < 2 || counts[dominant] > 0 { return nil } - return []core.Finding{env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.error-style-drift", - Level: "warn", - Path: file, - Line: 1, - Column: 1, - Message: fmt.Sprintf("%s style %q diverges from the repository's dominant style %q", label, fileDominant, dominant), - })} + return []core.Finding{warnFinding(env, "quality.ai.error-style-drift", file, 1, 1, + fmt.Sprintf("%s style %q diverges from the repository's dominant style %q", label, fileDominant, dominant))} } diff --git a/internal/codeguard/checks/quality/quality_ai_target_go.go b/internal/codeguard/checks/quality/quality_ai_target_go.go index fbae078..e8a3f1d 100644 --- a/internal/codeguard/checks/quality/quality_ai_target_go.go +++ b/internal/codeguard/checks/quality/quality_ai_target_go.go @@ -128,14 +128,8 @@ func goHallucinatedImportFindings(env support.Context, file string, fset *token. continue } pos := fset.Position(imp.Pos()) - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.hallucinated-import", - Level: "warn", - Path: file, - Line: pos.Line, - Column: pos.Column, - Message: fmt.Sprintf("import %q does not resolve against go.mod or the local module path", importPath), - })) + findings = append(findings, warnFinding(env, "quality.ai.hallucinated-import", file, pos.Line, pos.Column, + fmt.Sprintf("import %q does not resolve against go.mod or the local module path", importPath))) } return findings } @@ -170,14 +164,8 @@ func goDeadCodeFindings(env support.Context, file string, fset *token.FileSet, p return true } pos := fset.Position(ifStmt.Pos()) - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.dead-code", - Level: "warn", - Path: file, - Line: pos.Line, - Column: pos.Column, - Message: "constant false branch leaves unreachable placeholder logic in the code path", - })) + findings = append(findings, warnFinding(env, "quality.ai.dead-code", file, pos.Line, pos.Column, + "constant false branch leaves unreachable placeholder logic in the code path")) return true }) return findings @@ -191,12 +179,6 @@ func goOverMockedTestFinding(env support.Context, file string, source string) [] if mockCount < 4 || assertCount > 1 { return nil } - return []core.Finding{env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.over-mocked-test", - Level: "warn", - Path: file, - Line: firstLineContaining(source, mockMarkers), - Column: 1, - Message: "test is dominated by mock setup and expectations with very little direct behavior assertion", - })} + return []core.Finding{warnFinding(env, "quality.ai.over-mocked-test", file, firstLineContaining(source, mockMarkers), 1, + "test is dominated by mock setup and expectations with very little direct behavior assertion")} } diff --git a/internal/codeguard/checks/quality/quality_ai_target_python.go b/internal/codeguard/checks/quality/quality_ai_target_python.go index e58dd47..0b3787b 100644 --- a/internal/codeguard/checks/quality/quality_ai_target_python.go +++ b/internal/codeguard/checks/quality/quality_ai_target_python.go @@ -97,14 +97,8 @@ func pythonImportFindings(env support.Context, root string, file string, source if pythonImportResolvable(root, file, module, input.catalog, input.localModules) { continue } - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.hallucinated-import", - Level: "warn", - Path: file, - Line: idx + 1, - Column: 1, - Message: fmt.Sprintf("import %q does not resolve against the standard library, declared dependencies, or local modules", module), - })) + findings = append(findings, warnFinding(env, "quality.ai.hallucinated-import", file, idx+1, 1, + fmt.Sprintf("import %q does not resolve against the standard library, declared dependencies, or local modules", module))) } } return findings diff --git a/internal/codeguard/checks/quality/quality_ai_target_script.go b/internal/codeguard/checks/quality/quality_ai_target_script.go index 5ac1b41..cc8f019 100644 --- a/internal/codeguard/checks/quality/quality_ai_target_script.go +++ b/internal/codeguard/checks/quality/quality_ai_target_script.go @@ -93,14 +93,8 @@ func scriptImportFindings(env support.Context, root string, file string, source continue } line := 1 + strings.Count(source[:match[0]], "\n") - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.hallucinated-import", - Level: "warn", - Path: file, - Line: line, - Column: 1, - Message: fmt.Sprintf("import %q does not resolve against package manifests, workspace packages, or local files", specifier), - })) + findings = append(findings, warnFinding(env, "quality.ai.hallucinated-import", file, line, 1, + fmt.Sprintf("import %q does not resolve against package manifests, workspace packages, or local files", specifier))) } return findings } @@ -147,14 +141,8 @@ func scriptDeadCodeFindings(env support.Context, file string, source string) []c lines := regexLineMatches(scriptDeadBranchPattern, source) findings := make([]core.Finding, 0, len(lines)) for _, line := range lines { - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.dead-code", - Level: "warn", - Path: file, - Line: line, - Column: 1, - Message: "constant-condition branch leaves unreachable placeholder logic in the code path", - })) + findings = append(findings, warnFinding(env, "quality.ai.dead-code", file, line, 1, + "constant-condition branch leaves unreachable placeholder logic in the code path")) } return findings } @@ -167,12 +155,6 @@ func scriptOverMockedTestFinding(env support.Context, file string, source string if mockCount < 2 || assertCount > 1 { return nil } - return []core.Finding{env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.over-mocked-test", - Level: "warn", - Path: file, - Line: firstLineContaining(source, mockMarkers), - Column: 1, - Message: "test relies mostly on mocked collaborators with very little direct behavior assertion", - })} + return []core.Finding{warnFinding(env, "quality.ai.over-mocked-test", file, firstLineContaining(source, mockMarkers), 1, + "test relies mostly on mocked collaborators with very little direct behavior assertion")} } diff --git a/internal/codeguard/checks/quality/quality_ai_test_idioms.go b/internal/codeguard/checks/quality/quality_ai_test_idioms.go index 03b466a..2af94b9 100644 --- a/internal/codeguard/checks/quality/quality_ai_test_idioms.go +++ b/internal/codeguard/checks/quality/quality_ai_test_idioms.go @@ -83,12 +83,6 @@ func idiomDriftFinding(env support.Context, file string, dominant string, actual if dominant == "" || actual == "" || actual == dominant { return nil } - return []core.Finding{env.NewFinding(support.FindingInput{ - RuleID: "quality.ai.local-idiom-drift", - Level: "warn", - Path: file, - Line: 1, - Column: 1, - Message: fmt.Sprintf("test uses %s while the repository primarily uses %s", actual, dominant), - })} + return []core.Finding{warnFinding(env, "quality.ai.local-idiom-drift", file, 1, 1, + fmt.Sprintf("test uses %s while the repository primarily uses %s", actual, dominant))} } diff --git a/internal/codeguard/checks/quality/quality_clone.go b/internal/codeguard/checks/quality/quality_clone.go index c5aa522..c68cdc5 100644 --- a/internal/codeguard/checks/quality/quality_clone.go +++ b/internal/codeguard/checks/quality/quality_clone.go @@ -34,14 +34,7 @@ func cloneFindingsForTarget(env support.Context, target core.TargetConfig) []cor rightLine, threshold, ) - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.duplicate-code", - Level: "warn", - Path: left.Path, - Line: leftLine, - Column: 1, - Message: message, - })) + findings = append(findings, warnFinding(env, "quality.duplicate-code", left.Path, leftLine, 1, message)) message = fmt.Sprintf( "duplicate normalized token sequence of %d tokens also found in %s:%d (threshold %d)", candidate.Length, @@ -49,14 +42,7 @@ func cloneFindingsForTarget(env support.Context, target core.TargetConfig) []cor leftLine, threshold, ) - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.duplicate-code", - Level: "warn", - Path: right.Path, - Line: rightLine, - Column: 1, - Message: message, - })) + findings = append(findings, warnFinding(env, "quality.duplicate-code", right.Path, rightLine, 1, message)) } return findings } diff --git a/internal/codeguard/checks/quality/quality_go.go b/internal/codeguard/checks/quality/quality_go.go index 7b4c6f8..2fa6672 100644 --- a/internal/codeguard/checks/quality/quality_go.go +++ b/internal/codeguard/checks/quality/quality_go.go @@ -53,14 +53,8 @@ func goFindingsForFile(env support.Context, file string, data []byte) []core.Fin return append(fileLengthFindingWithSignals(env, file, data, findings), findings...) } if len(parsed.Decls) > env.Config.Checks.DesignRules.MaxDeclsPerFile { - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "design.max-decls-per-file", - Level: "warn", - Path: file, - Line: 1, - Column: 1, - Message: fmt.Sprintf("file has %d declarations; max is %d", len(parsed.Decls), env.Config.Checks.DesignRules.MaxDeclsPerFile), - })) + findings = append(findings, warnFinding(env, "design.max-decls-per-file", file, 1, 1, + fmt.Sprintf("file has %d declarations; max is %d", len(parsed.Decls), env.Config.Checks.DesignRules.MaxDeclsPerFile))) } findings = append(findings, importFindings(env, file, fset, parsed)...) findings = append(findings, goFunctionFindings(env, file, fset, parsed)...) @@ -75,14 +69,8 @@ func importFindings(env support.Context, file string, fset *token.FileSet, parse pathValue := strings.Trim(imp.Path.Value, `"`) if strings.Contains(pathValue, "/internal/") && allowsInternalImport(env, file) { pos := fset.Position(imp.Pos()) - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.dependency-direction", - Level: "warn", - Path: file, - Line: pos.Line, - Column: pos.Column, - Message: "non-CLI package imports internal implementation detail", - })) + findings = append(findings, warnFinding(env, "quality.dependency-direction", file, pos.Line, pos.Column, + "non-CLI package imports internal implementation detail")) } } return findings diff --git a/internal/codeguard/checks/quality/quality_metrics.go b/internal/codeguard/checks/quality/quality_metrics.go index cf5396b..c5eec88 100644 --- a/internal/codeguard/checks/quality/quality_metrics.go +++ b/internal/codeguard/checks/quality/quality_metrics.go @@ -66,34 +66,16 @@ func fileHasComplexityFinding(findings []core.Finding, file string) bool { func maintainabilityFindings(env support.Context, file string, fn functionMetrics) []core.Finding { findings := make([]core.Finding, 0, 3) if fn.Length > env.Config.Checks.QualityRules.MaxFunctionLines { - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.max-function-lines", - Level: "warn", - Path: file, - Line: fn.StartLine, - Column: 1, - Message: fmt.Sprintf("function %s has %d lines; max is %d", fn.Name, fn.Length, env.Config.Checks.QualityRules.MaxFunctionLines), - })) + findings = append(findings, warnFinding(env, "quality.max-function-lines", file, fn.StartLine, 1, + fmt.Sprintf("function %s has %d lines; max is %d", fn.Name, fn.Length, env.Config.Checks.QualityRules.MaxFunctionLines))) } if fn.Params > env.Config.Checks.QualityRules.MaxParameters { - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.max-parameters", - Level: "warn", - Path: file, - Line: fn.StartLine, - Column: 1, - Message: fmt.Sprintf("function %s has %d parameters; max is %d", fn.Name, fn.Params, env.Config.Checks.QualityRules.MaxParameters), - })) + findings = append(findings, warnFinding(env, "quality.max-parameters", file, fn.StartLine, 1, + fmt.Sprintf("function %s has %d parameters; max is %d", fn.Name, fn.Params, env.Config.Checks.QualityRules.MaxParameters))) } if fn.Complexity > env.Config.Checks.QualityRules.MaxCyclomaticComplexity { - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.cyclomatic-complexity", - Level: "warn", - Path: file, - Line: fn.StartLine, - Column: 1, - Message: fmt.Sprintf("function %s has cyclomatic complexity %d; max is %d", fn.Name, fn.Complexity, env.Config.Checks.QualityRules.MaxCyclomaticComplexity), - })) + findings = append(findings, warnFinding(env, "quality.cyclomatic-complexity", file, fn.StartLine, 1, + fmt.Sprintf("function %s has cyclomatic complexity %d; max is %d", fn.Name, fn.Complexity, env.Config.Checks.QualityRules.MaxCyclomaticComplexity))) } return findings } diff --git a/internal/codeguard/checks/quality/quality_performance_go.go b/internal/codeguard/checks/quality/quality_performance_go.go index a8b8828..d9d9d20 100644 --- a/internal/codeguard/checks/quality/quality_performance_go.go +++ b/internal/codeguard/checks/quality/quality_performance_go.go @@ -50,14 +50,8 @@ func goCorePerformanceFindings(env support.Context, file string, fset *token.Fil case *ast.GoStmt: if hasLoopAncestor(stack[:len(stack)-1]) { pos := fset.Position(node.Go) - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.unbounded-goroutines-in-loop", - Level: "warn", - Path: file, - Line: pos.Line, - Column: pos.Column, - Message: "goroutine launched inside a loop should be bounded or queued explicitly", - })) + findings = append(findings, warnFinding(env, "quality.unbounded-goroutines-in-loop", file, pos.Line, pos.Column, + "goroutine launched inside a loop should be bounded or queued explicitly")) } case *ast.CallExpr: fn := enclosingFunc(stack[:len(stack)-1]) @@ -65,14 +59,8 @@ func goCorePerformanceFindings(env support.Context, file string, fset *token.Fil return true } pos := fset.Position(node.Pos()) - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.sync-io-in-request-path", - Level: "warn", - Path: file, - Line: pos.Line, - Column: pos.Column, - Message: "synchronous file I/O in an HTTP request path can add tail latency", - })) + findings = append(findings, warnFinding(env, "quality.sync-io-in-request-path", file, pos.Line, pos.Column, + "synchronous file I/O in an HTTP request path can add tail latency")) } return true }) diff --git a/internal/codeguard/checks/quality/quality_performance_go_alloc.go b/internal/codeguard/checks/quality/quality_performance_go_alloc.go index dedecc3..9258e90 100644 --- a/internal/codeguard/checks/quality/quality_performance_go_alloc.go +++ b/internal/codeguard/checks/quality/quality_performance_go_alloc.go @@ -86,14 +86,7 @@ func (scan goAllocLoopScan) assignFindings(assign *ast.AssignStmt, knowableBound func (scan goAllocLoopScan) finding(assign *ast.AssignStmt, message string) core.Finding { pos := scan.fset.Position(assign.Pos()) - return scan.env.NewFinding(support.FindingInput{ - RuleID: "quality.go.alloc-in-loop", - Level: "warn", - Path: scan.file, - Line: pos.Line, - Column: pos.Column, - Message: message, - }) + return warnFinding(scan.env, "quality.go.alloc-in-loop", scan.file, pos.Line, pos.Column, message) } func goStringGrowthMessage(assign *ast.AssignStmt) string { diff --git a/internal/codeguard/checks/quality/quality_performance_go_loops.go b/internal/codeguard/checks/quality/quality_performance_go_loops.go index 5c5510a..b6936df 100644 --- a/internal/codeguard/checks/quality/quality_performance_go_loops.go +++ b/internal/codeguard/checks/quality/quality_performance_go_loops.go @@ -69,14 +69,8 @@ func goNPlusOneFindings(env support.Context, file string, fset *token.FileSet, p return true } seen[line] = struct{}{} - findings = append(findings, env.NewFinding(support.FindingInput{ - RuleID: "quality.n-plus-one-query", - Level: "warn", - Path: file, - Line: line, - Column: fset.Position(call.Pos()).Column, - Message: fmt.Sprintf("query call %s inside a loop suggests an N+1 query pattern; batch the query or hoist it out of the loop", sel.Sel.Name), - })) + findings = append(findings, warnFinding(env, "quality.n-plus-one-query", file, line, fset.Position(call.Pos()).Column, + fmt.Sprintf("query call %s inside a loop suggests an N+1 query pattern; batch the query or hoist it out of the loop", sel.Sel.Name))) return true }) return true diff --git a/internal/codeguard/checks/quality/quality_performance_python.go b/internal/codeguard/checks/quality/quality_performance_python.go index d5e065b..cd04ec8 100644 --- a/internal/codeguard/checks/quality/quality_performance_python.go +++ b/internal/codeguard/checks/quality/quality_performance_python.go @@ -61,14 +61,7 @@ func (s *pythonPerformanceScan) checkLine(lineNo int, line string, inLoop bool, } func (s *pythonPerformanceScan) addFinding(ruleID string, lineNo int, message string) { - s.findings = append(s.findings, s.env.NewFinding(support.FindingInput{ - RuleID: ruleID, - Level: "warn", - Path: s.file, - Line: lineNo, - Column: 1, - Message: message, - })) + s.findings = append(s.findings, warnFinding(s.env, ruleID, s.file, lineNo, 1, message)) } func popIndentRegions(regions []int, indent int) []int { diff --git a/internal/codeguard/checks/quality/quality_performance_typescript.go b/internal/codeguard/checks/quality/quality_performance_typescript.go index 6c1386b..47ebad9 100644 --- a/internal/codeguard/checks/quality/quality_performance_typescript.go +++ b/internal/codeguard/checks/quality/quality_performance_typescript.go @@ -89,12 +89,5 @@ func (s *tsPerformanceScan) checkLine(lineNo int, line string, inLoop bool, inHa } func (s *tsPerformanceScan) addFinding(tsRuleID string, jsRuleID string, lineNo int, message string) { - s.findings = append(s.findings, s.env.NewFinding(support.FindingInput{ - RuleID: support.RuleIDForScript(s.file, tsRuleID, jsRuleID), - Level: "warn", - Path: s.file, - Line: lineNo, - Column: 1, - Message: message, - })) + s.findings = append(s.findings, warnFinding(s.env, support.RuleIDForScript(s.file, tsRuleID, jsRuleID), s.file, lineNo, 1, message)) } diff --git a/internal/codeguard/checks/quality/quality_typescript_helpers.go b/internal/codeguard/checks/quality/quality_typescript_helpers.go index a61eae7..e78f00e 100644 --- a/internal/codeguard/checks/quality/quality_typescript_helpers.go +++ b/internal/codeguard/checks/quality/quality_typescript_helpers.go @@ -32,14 +32,7 @@ func qualityRuleID(path string, suffix string) string { } func newTypeScriptQualityFinding(ctx typeScriptScanContext, ruleID string, line int, message string) core.Finding { - return ctx.env.NewFinding(support.FindingInput{ - RuleID: ruleID, - Level: "warn", - Path: ctx.file, - Line: line, - Column: 1, - Message: support.ScriptLabelForPath(ctx.file) + " " + message, - }) + return warnFinding(ctx.env, ruleID, ctx.file, line, 1, support.ScriptLabelForPath(ctx.file)+" "+message) } func isTypeScriptLikeFile(rel string) bool { diff --git a/internal/codeguard/runner/checks/checks.go b/internal/codeguard/runner/checks/checks.go index 3796de8..c61bb86 100644 --- a/internal/codeguard/runner/checks/checks.go +++ b/internal/codeguard/runner/checks/checks.go @@ -4,46 +4,22 @@ import ( "context" "fmt" - ciCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/ci" - contractsCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/contracts" - designCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/design" - promptsCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/prompts" - qualityCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/quality" - securityCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/security" - supplyChainCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/supplychain" checkSupport "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" - customrunner "github.com/devr-tools/codeguard/internal/codeguard/runner/custom" govulncheckrunner "github.com/devr-tools/codeguard/internal/codeguard/runner/govulncheck" runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" ) func Build(ctx context.Context, sc runnersupport.Context) []core.SectionResult { - sections := make([]core.SectionResult, 0, 7) + sections := make([]core.SectionResult, 0, len(sectionRegistry)) checkEnv := buildCheckContext(sc) - if sc.Cfg.Checks.Quality { - sections = append(sections, safeRun("quality", "Quality", func() core.SectionResult { return qualityCheck.Run(ctx, checkEnv) })) - } - if sc.Cfg.Checks.Design { - sections = append(sections, safeRun("design", "Design", func() core.SectionResult { return designCheck.Run(ctx, checkEnv) })) - } - if sc.Cfg.Checks.Security { - sections = append(sections, safeRun("security", "Security", func() core.SectionResult { return securityCheck.Run(ctx, checkEnv) })) - } - if sc.Cfg.Checks.Prompts { - sections = append(sections, safeRun("prompts", "Prompts", func() core.SectionResult { return promptsCheck.Run(ctx, checkEnv) })) - } - if sc.Cfg.Checks.CI { - sections = append(sections, safeRun("ci", "CI", func() core.SectionResult { return ciCheck.Run(ctx, checkEnv) })) - } - if sc.Cfg.Checks.SupplyChain { - sections = append(sections, safeRun("supply-chain", "Supply Chain", func() core.SectionResult { return supplyChainCheck.Run(ctx, checkEnv) })) - } - if contractsEnabled(sc) { - sections = append(sections, safeRun("contracts", "Contracts", func() core.SectionResult { return contractsCheck.Run(ctx, checkEnv) })) - } - if len(sc.CustomRules) > 0 { - sections = append(sections, safeRun("custom", "Custom Rules", func() core.SectionResult { return customrunner.RunSection(ctx, sc) })) + for _, def := range sectionRegistry { + if !def.enabled(sc) { + continue + } + sections = append(sections, safeRun(def.id, def.name, func() core.SectionResult { + return def.run(ctx, sc, checkEnv) + })) } return sections } diff --git a/internal/codeguard/runner/checks/registry.go b/internal/codeguard/runner/checks/registry.go new file mode 100644 index 0000000..8ab88e3 --- /dev/null +++ b/internal/codeguard/runner/checks/registry.go @@ -0,0 +1,103 @@ +package checks + +import ( + "context" + + ciCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/ci" + contractsCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/contracts" + designCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/design" + promptsCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/prompts" + qualityCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/quality" + securityCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/security" + supplyChainCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/supplychain" + checkSupport "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" + customrunner "github.com/devr-tools/codeguard/internal/codeguard/runner/custom" + runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" +) + +// sectionDef describes one check section in a data-driven form, replacing the +// hand-maintained if-ladder that previously lived in Build. Each entry carries +// its stable id/display name, the predicate that decides whether the section +// runs for a given scan, and the closure that actually executes it. +// +// run receives both the runner-level Context (sc) and the per-check Context +// (checkEnv) so that sections built on either entry point fit the same shape: +// most sections call .Run(ctx, checkEnv); the custom-rules section calls +// customrunner.RunSection(ctx, sc). +type sectionDef struct { + id string + name string + enabled func(sc runnersupport.Context) bool + run func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult +} + +// sectionRegistry lists every section in the exact order they appear in the +// scan result. Build iterates this slice and, for each enabled section, calls +// through the safeRun panic-recovery wrapper. +var sectionRegistry = []sectionDef{ + { + id: "quality", + name: "Quality", + enabled: func(sc runnersupport.Context) bool { return sc.Cfg.Checks.Quality }, + run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + return qualityCheck.Run(ctx, checkEnv) + }, + }, + { + id: "design", + name: "Design", + enabled: func(sc runnersupport.Context) bool { return sc.Cfg.Checks.Design }, + run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + return designCheck.Run(ctx, checkEnv) + }, + }, + { + id: "security", + name: "Security", + enabled: func(sc runnersupport.Context) bool { return sc.Cfg.Checks.Security }, + run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + return securityCheck.Run(ctx, checkEnv) + }, + }, + { + id: "prompts", + name: "Prompts", + enabled: func(sc runnersupport.Context) bool { return sc.Cfg.Checks.Prompts }, + run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + return promptsCheck.Run(ctx, checkEnv) + }, + }, + { + id: "ci", + name: "CI", + enabled: func(sc runnersupport.Context) bool { return sc.Cfg.Checks.CI }, + run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + return ciCheck.Run(ctx, checkEnv) + }, + }, + { + id: "supply-chain", + name: "Supply Chain", + enabled: func(sc runnersupport.Context) bool { return sc.Cfg.Checks.SupplyChain }, + run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + return supplyChainCheck.Run(ctx, checkEnv) + }, + }, + { + id: "contracts", + name: "Contracts", + enabled: contractsEnabled, + run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + return contractsCheck.Run(ctx, checkEnv) + }, + }, + { + id: "custom", + name: "Custom Rules", + enabled: func(sc runnersupport.Context) bool { return len(sc.CustomRules) > 0 }, + run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + return customrunner.RunSection(ctx, sc) + }, + }, +} From c15349769a3823c7a056a4b5492f6c0a6a74dc00 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Tue, 30 Jun 2026 16:12:18 -0400 Subject: [PATCH 3/6] test(support): regression tests for base_ref validation and glob matching Lock in the H1/H2 hardening: - ValidateBaseRef rejects option-injection refs (leading '-') and out-of-charset values; accepts legitimate refs and the stdin sentinel - MatchPattern returns false (never panics) on malformed globs, matches valid glob semantics, and is idempotent across the sync.Map cache 476 tests pass (was 430). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../support/runner_support_hardening_test.go | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/support/runner_support_hardening_test.go diff --git a/tests/support/runner_support_hardening_test.go b/tests/support/runner_support_hardening_test.go new file mode 100644 index 0000000..1b04380 --- /dev/null +++ b/tests/support/runner_support_hardening_test.go @@ -0,0 +1,148 @@ +package support_test + +import ( + "testing" + + runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" +) + +// TestValidateBaseRef locks in the option-injection guard added to +// runner/support: refs that git could misparse as an option, or that contain +// characters outside the conservative ref/SHA charset, must be rejected, while +// legitimate refs (and the "stdin" sentinel) must pass. +func TestValidateBaseRef(t *testing.T) { + cases := []struct { + name string + ref string + wantErr bool + }{ + // Rejections: leading '-' would be parsed by git as an option. + {"option_upload_pack", "--upload-pack=evil", true}, + {"short_option", "-x", true}, + {"single_dash", "-", true}, + // Rejections: characters outside the allowed charset. + {"empty", "", true}, + {"space", "main branch", true}, + {"semicolon", "main;rm -rf", true}, + {"dollar", "main$(whoami)", true}, + {"backtick", "main`id`", true}, + {"newline", "main\nHEAD", true}, + {"star", "main*", true}, + {"backslash", `main\HEAD`, true}, + {"question", "main?", true}, + {"hash", "main#1", true}, + + // Acceptances: legitimate refs. + {"branch", "main", false}, + {"remote_branch", "origin/main", false}, + {"ancestor", "HEAD~3", false}, + {"tag", "v1.2.3", false}, + {"sha40", "0123456789abcdef0123456789abcdef01234567", false}, + {"feature_branch", "feature/foo-bar", false}, + {"caret_parent", "HEAD^", false}, + {"reflog_at", "main@{1}", false}, + {"ref_colon_path", "HEAD:path/to/file", false}, + {"stdin_sentinel", "stdin", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := runnersupport.ValidateBaseRef(tc.ref) + if tc.wantErr && err == nil { + t.Fatalf("ValidateBaseRef(%q) = nil, want error", tc.ref) + } + if !tc.wantErr && err != nil { + t.Fatalf("ValidateBaseRef(%q) = %v, want nil", tc.ref, err) + } + }) + } +} + +// safeMatchPattern calls MatchPattern, converting any panic into a test +// failure. The hardening under test guarantees an un-compilable glob returns +// false rather than panicking the scan. +func safeMatchPattern(t *testing.T, pattern, value string) (got bool) { + t.Helper() + defer func() { + if r := recover(); r != nil { + t.Fatalf("MatchPattern(%q, %q) panicked: %v", pattern, value, r) + } + }() + return runnersupport.MatchPattern(pattern, value) +} + +// TestMatchPatternMalformed verifies that globs which translate to an invalid +// regex match nothing rather than panicking the walk. +func TestMatchPatternMalformed(t *testing.T) { + // `\x` and a trailing stray backslash both produce invalid regex after the + // glob->regex translation; they must be handled gracefully. + malformed := []string{ + `\x`, + `foo\`, + `bad\q`, + } + for _, pattern := range malformed { + t.Run(pattern, func(t *testing.T) { + if got := safeMatchPattern(t, pattern, "foo/bar"); got { + t.Fatalf("MatchPattern(%q, ...) = true, want false for malformed glob", pattern) + } + }) + } +} + +// TestMatchPatternValid verifies the documented glob semantics: '*' matches +// within a path segment, '**' crosses separators, '?' matches a single +// non-separator char, and exact paths match literally. +func TestMatchPatternValid(t *testing.T) { + cases := []struct { + name string + pattern string + value string + want bool + }{ + {"star_all_single_segment", "*", "main.go", true}, + {"star_not_across_slash", "*", "dir/main.go", false}, + {"star_suffix", "*.go", "main.go", true}, + {"star_suffix_no_match", "*.go", "main.py", false}, + {"doublestar_crosses_slash", "**", "a/b/c.go", true}, + {"dir_doublestar_match", "node_modules/**", "node_modules/pkg/index.js", true}, + {"dir_doublestar_no_match", "node_modules/**", "src/node_modules.go", false}, + {"question_single_char", "?.go", "a.go", true}, + {"question_not_two_chars", "?.go", "ab.go", false}, + {"question_not_slash", "a?b", "a/b", false}, + {"exact_path", "src/main.go", "src/main.go", true}, + {"exact_path_no_match", "src/main.go", "src/other.go", false}, + {"dot_is_literal", "a.b", "axb", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := safeMatchPattern(t, tc.pattern, tc.value); got != tc.want { + t.Fatalf("MatchPattern(%q, %q) = %v, want %v", tc.pattern, tc.value, got, tc.want) + } + }) + } +} + +// TestMatchPatternIdempotent exercises the sync.Map memoization: repeated calls +// with the same pattern must return a stable result (the second call is served +// from the compiled-pattern cache). +func TestMatchPatternIdempotent(t *testing.T) { + cases := []struct { + pattern string + value string + }{ + {"node_modules/**", "node_modules/pkg/index.js"}, + {"*.go", "main.go"}, + {`\x`, "anything"}, // cached as nil (failed compile); still stable. + } + for _, tc := range cases { + t.Run(tc.pattern, func(t *testing.T) { + first := safeMatchPattern(t, tc.pattern, tc.value) + second := safeMatchPattern(t, tc.pattern, tc.value) + if first != second { + t.Fatalf("MatchPattern(%q, %q) not idempotent: first=%v second=%v", + tc.pattern, tc.value, first, second) + } + }) + } +} From d42a9d14726da861e722c4c44400383b3f468f0d Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Tue, 30 Jun 2026 16:26:37 -0400 Subject: [PATCH 4/6] chore(lint): clear golangci-lint backlog to zero and enforce in CI - fix remaining errcheck, govet-shadow, gocritic, unparam findings - scoped //nolint:contextcheck on the git helpers that use a contained timeout (deeper ctx threading tracked as a follow-up) - //nolint:prealloc on the genuinely unbounded finding accumulators - exclude revive's documentation/naming style rules (exported-doc, package-comments, unused-parameter); the correctness/safety linters gate CI. Documenting the public pkg/codeguard API is a tracked follow-up - exclude test fixtures from gosec/errcheck/prealloc/bodyclose - remove continue-on-error from the CI golangci-lint step: lint is now blocking. golangci-lint run reports 0 issues. Build/vet/gofmt clean; 476 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 1 - .golangci.yml | 25 +++++++++++++------ Makefile | 2 +- internal/cli/mcp_client_requester.go | 10 +++----- internal/cli/mcp_fix.go | 6 ++--- internal/cli/mcp_fix_common.go | 6 ++--- internal/cli/mcp_tools.go | 2 +- internal/codeguard/ai/fix/testplan_script.go | 8 ++---- internal/codeguard/ai/fix/verify.go | 5 ++-- internal/codeguard/ai/nlrule/evaluator.go | 7 ------ internal/codeguard/ai/runtime/http_post.go | 6 ++--- internal/codeguard/ai/semantic/analyze.go | 2 +- internal/codeguard/ai/triage/anthropic.go | 2 +- internal/codeguard/ai/triage/openai.go | 2 +- internal/codeguard/ai/triage/provider.go | 4 +-- internal/codeguard/ai/triage/triage.go | 1 + internal/codeguard/cachefile/cachefile.go | 2 +- internal/codeguard/checks/ci/ci.go | 2 +- .../codeguard/checks/contracts/contracts.go | 2 +- .../codeguard/checks/contracts/migrations.go | 2 +- internal/codeguard/checks/design/design_go.go | 4 +-- .../checks/design/design_graph_rust.go | 6 ++--- .../codeguard/checks/design/design_python.go | 2 +- internal/codeguard/checks/quality/quality.go | 4 +-- .../quality/quality_additional_languages.go | 8 +++--- .../codeguard/checks/quality/quality_ai.go | 2 +- .../checks/quality/quality_ai_semantic.go | 2 +- .../quality/quality_ai_target_python.go | 2 +- .../checks/quality/quality_metrics.go | 14 ----------- .../checks/quality/quality_python.go | 2 +- .../checks/quality/quality_typescript.go | 2 +- .../security/security_taint_go_calls.go | 2 +- .../security/security_taint_python_expr.go | 2 +- .../checks/security/security_typescript.go | 2 +- .../codeguard/checks/supplychain/policy.go | 2 +- .../codeguard/checks/support/scan_helpers.go | 2 +- .../checks/support/typescript_parser.go | 8 +++--- internal/codeguard/config/defaults_ai.go | 2 +- internal/codeguard/config/defaults_helpers.go | 5 ++-- internal/codeguard/config/defaults_rules.go | 8 +++--- internal/codeguard/config/io.go | 2 +- internal/codeguard/report/github_comment.go | 7 ------ internal/codeguard/report/write.go | 7 ------ internal/codeguard/runner/checks/checks.go | 10 +++----- internal/codeguard/runner/checks/registry.go | 16 ++++++------ internal/codeguard/runner/custom/custom.go | 2 +- internal/codeguard/runner/runner.go | 2 +- internal/codeguard/runner/support/commands.go | 2 +- .../codeguard/runner/support/diff_command.go | 2 +- internal/codeguard/runner/support/patch.go | 5 ++++ tests/checks/contracts_helpers_test.go | 1 + tests/checks/coverage_lcov_test.go | 2 +- tests/checks/features_nl_test.go | 4 +-- tests/checks/features_test.go | 4 +-- .../quality_ai_semantic_helpers_test.go | 1 + tests/checks/quality_assertions_test.go | 1 + tests/checks/test_helpers_test.go | 6 ++--- tests/codeguard/ai_provider_anthropic_test.go | 6 ++--- tests/codeguard/ai_triage_anthropic_test.go | 2 +- tests/codeguard/ai_triage_test.go | 5 ++-- tests/mcp/http_test.go | 2 +- tests/mcp/smoke_helpers_test.go | 2 +- 62 files changed, 123 insertions(+), 146 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 306123e..dbad84a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,6 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@v6 - continue-on-error: true # TODO(harden): remove continue-on-error after the lint backlog is burned down test: name: test diff --git a/.golangci.yml b/.golangci.yml index 286d740..6eb1f4e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -28,10 +28,21 @@ linters: - fmt.Fprintf - fmt.Fprintln - fmt.Fprint -issues: - exclude-rules: - - path: tests/ - linters: - - errcheck - - gosec - - prealloc + exclusions: + rules: + # Test fixtures intentionally use fake "credentials", 0o755 helper + # scripts, unclosed test HTTP bodies, and unchecked cleanup errors. + - path: tests/ + linters: + - errcheck + - gosec + - prealloc + - bodyclose + # revive's documentation/naming style rules are not enforced: exported-doc + # (a doc comment on every exported symbol), package-comments, and + # unused-parameter (e.g. the unused *http.Request in http.HandlerFunc + # callbacks). The correctness/safety linters above are what gate CI. + # Documenting the public pkg/codeguard API is tracked as a follow-up. + - linters: + - revive + text: "should have comment|should have a package comment|seems to be unused" diff --git a/Makefile b/Makefile index 38da299..ed614f5 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ help: @printf " make fmt Format Go files\n" @printf " make fmt-check Verify Go files are formatted\n" @printf " make lint Run go vet\n" - @printf " make lint-strict Run golangci-lint (not yet enforced in CI)\n" + @printf " make lint-strict Run golangci-lint (enforced in CI)\n" @printf " make test Run the Go test suite\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" diff --git a/internal/cli/mcp_client_requester.go b/internal/cli/mcp_client_requester.go index cd38b51..1348943 100644 --- a/internal/cli/mcp_client_requester.go +++ b/internal/cli/mcp_client_requester.go @@ -56,21 +56,19 @@ func (r *serverRequester) call(ctx context.Context, send func(id string) error) } } -// deliver routes an inbound response to a waiting call. It returns false when no -// server-initiated request is awaiting that id (so the caller can dispatch the -// message normally). -func (r *serverRequester) deliver(id string, raw json.RawMessage) bool { +// deliver routes an inbound response to the call awaiting that id. It is a no-op +// when no server-initiated request is pending for the id. +func (r *serverRequester) deliver(id string, raw json.RawMessage) { r.mu.Lock() ch, ok := r.pending[id] r.mu.Unlock() if !ok { - return false + return } select { case ch <- raw: default: } - return true } func parseServerResponse(raw json.RawMessage) (json.RawMessage, error) { diff --git a/internal/cli/mcp_fix.go b/internal/cli/mcp_fix.go index 7fdc9f5..1643b0e 100644 --- a/internal/cli/mcp_fix.go +++ b/internal/cli/mcp_fix.go @@ -53,7 +53,7 @@ func (s *mcpToolService) callVerifyFix(ctx context.Context, raw json.RawMessage) return result, nil } - result, failure, verifyErr, ok := verifyFixCandidate(ctx, fixCtx.cfg, fixCtx.args) + result, failure, ok, verifyErr := verifyFixCandidate(ctx, fixCtx.cfg, fixCtx.args) if ok { return toolSuccessResult(result), nil } @@ -108,14 +108,14 @@ func (s *mcpToolService) callApplyFix(ctx context.Context, raw json.RawMessage) return result, nil } - verified, failure, verifyErr, ok := verifyFixCandidate(ctx, fixCtx.cfg, fixCtx.args) + verified, failure, ok, verifyErr := verifyFixCandidate(ctx, fixCtx.cfg, fixCtx.args) if !ok { return toolErrorResultData(fmt.Sprintf("fix did not verify, not applied: %v", verifyErr), failure), nil } if reply := confirmApplyResult(ctx, clientCallerFrom(ctx), verified.ChangedFiles, verified.Diff); reply != nil { return reply, nil } - if err := runnersupport.ApplyUnifiedDiff(fixCtx.cfg, verified.Diff); err != nil { + if err := runnersupport.ApplyUnifiedDiff(fixCtx.cfg, verified.Diff); err != nil { //nolint:contextcheck // git helpers use a contained timeout; deeper ctx threading is a tracked follow-up return toolErrorResult(fmt.Sprintf("verified fix failed to apply to the working tree: %v", err)), nil } return toolSuccessResult(map[string]any{ diff --git a/internal/cli/mcp_fix_common.go b/internal/cli/mcp_fix_common.go index 1cfd992..18d6fe0 100644 --- a/internal/cli/mcp_fix_common.go +++ b/internal/cli/mcp_fix_common.go @@ -47,10 +47,10 @@ func requireFixFinding(ruleID string, message string) (map[string]any, bool) { return nil, false } -func verifyFixCandidate(ctx context.Context, cfg service.Config, args fixToolArgs) (service.VerifiedFix, map[string]any, error, bool) { +func verifyFixCandidate(ctx context.Context, cfg service.Config, args fixToolArgs) (service.VerifiedFix, map[string]any, bool, error) { result, err := service.VerifyFix(ctx, cfg, args.Finding, service.FixCandidate{Diff: args.Diff}, args.options()) if err == nil { - return result, nil, nil, true + return result, nil, true, nil } data := map[string]any{ "verified": false, @@ -60,7 +60,7 @@ func verifyFixCandidate(ctx context.Context, cfg service.Config, args fixToolArg if report, perr := service.RunPatch(ctx, cfg, args.Diff); perr == nil { data["remaining_findings"] = report } - return service.VerifiedFix{}, data, err, false + return service.VerifiedFix{}, data, false, err } func toolResultFromError(err error) map[string]any { diff --git a/internal/cli/mcp_tools.go b/internal/cli/mcp_tools.go index 045b780..2ef4a7a 100644 --- a/internal/cli/mcp_tools.go +++ b/internal/cli/mcp_tools.go @@ -50,7 +50,7 @@ func (s *mcpToolService) callScan(ctx context.Context, raw json.RawMessage) (map // Validate the caller-supplied ref once at the trust boundary so a value // beginning with "-" cannot be parsed by git as an option, and so only a // conservative ref/SHA charset reaches the git invocations downstream. - if err := runnersupport.ValidateBaseRef(baseRef); err != nil { + if err = runnersupport.ValidateBaseRef(baseRef); err != nil { return toolErrorResult(err.Error()), nil } diff --git a/internal/codeguard/ai/fix/testplan_script.go b/internal/codeguard/ai/fix/testplan_script.go index 76212c6..7b9cd7f 100644 --- a/internal/codeguard/ai/fix/testplan_script.go +++ b/internal/codeguard/ai/fix/testplan_script.go @@ -10,9 +10,7 @@ import ( ) func inferPythonTestCommands(root string, changed []string, excludes []string, maxNearest int) []core.CommandCheckConfig { - testFiles, err := runnersupport.WalkFiles(root, excludes, func(rel string) bool { - return isPythonTestFile(rel) - }) + testFiles, err := runnersupport.WalkFiles(root, excludes, isPythonTestFile) if err != nil { return nil } @@ -41,9 +39,7 @@ func inferScriptTestCommands(root string, changed []string, excludes []string, m } func inferNodeTestCommands(root string, changed []string, excludes []string, maxNearest int) []core.CommandCheckConfig { - testFiles, err := runnersupport.WalkFiles(root, excludes, func(rel string) bool { - return isNodeTestFile(rel) - }) + testFiles, err := runnersupport.WalkFiles(root, excludes, isNodeTestFile) if err != nil { return nil } diff --git a/internal/codeguard/ai/fix/verify.go b/internal/codeguard/ai/fix/verify.go index 6c504c2..00be93f 100644 --- a/internal/codeguard/ai/fix/verify.go +++ b/internal/codeguard/ai/fix/verify.go @@ -26,6 +26,7 @@ func GenerateVerified(ctx context.Context, req GenerateRequest) (Result, error) return Verify(ctx, req.Config, req.Finding, candidate, req.Options) } +//nolint:revive // finding is part of the exported Verify API shape even though this path does not read it func Verify(ctx context.Context, cfg core.Config, finding core.Finding, candidate Candidate, opts Options) (Result, error) { diffText := strings.TrimSpace(candidate.Diff) if diffText == "" { @@ -44,13 +45,13 @@ func Verify(ctx context.Context, cfg core.Config, finding core.Finding, candidat return Result{}, fmt.Errorf("patch did not verify cleanly: %d changed-line findings remain", report.Summary.TotalFindings) } - patchedCfg, _, cleanup, err := runnersupport.MaterializePatchedTargets(cfg, diffText) + patchedCfg, _, cleanup, err := runnersupport.MaterializePatchedTargets(cfg, diffText) //nolint:contextcheck // git helpers use a contained timeout; deeper ctx threading is a tracked follow-up if err != nil { return Result{}, fmt.Errorf("materialize patched targets: %w", err) } defer cleanup() - changedByTarget := changedFilesByTarget(cfg.Targets, diffText) + changedByTarget := changedFilesByTarget(cfg.Targets, diffText) //nolint:contextcheck // git helpers use a contained timeout; deeper ctx threading is a tracked follow-up testPlan, err := buildTestPlan(cfg, patchedCfg, changedByTarget, opts) if err != nil { return Result{}, err diff --git a/internal/codeguard/ai/nlrule/evaluator.go b/internal/codeguard/ai/nlrule/evaluator.go index dc1ff5d..85612f5 100644 --- a/internal/codeguard/ai/nlrule/evaluator.go +++ b/internal/codeguard/ai/nlrule/evaluator.go @@ -69,10 +69,3 @@ func findingsFromMatches(rule core.CustomRuleConfig, matches []Match) []Evaluate } return findings } - -func max(value int, minimum int) int { - if value < minimum { - return minimum - } - return value -} diff --git a/internal/codeguard/ai/runtime/http_post.go b/internal/codeguard/ai/runtime/http_post.go index 075b75a..7408d95 100644 --- a/internal/codeguard/ai/runtime/http_post.go +++ b/internal/codeguard/ai/runtime/http_post.go @@ -21,9 +21,9 @@ func postProviderJSON(ctx context.Context, providerName string, url string, head return nil, err } resp, err := httpretry.Do(ctx, providerHTTPClient(), httpretry.FromEnv(), func() (*http.Request, error) { - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) - if err != nil { - return nil, err + httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) + if reqErr != nil { + return nil, reqErr } httpReq.Header.Set("Content-Type", "application/json") for key, value := range headers { diff --git a/internal/codeguard/ai/semantic/analyze.go b/internal/codeguard/ai/semantic/analyze.go index 06885eb..b6f4ebc 100644 --- a/internal/codeguard/ai/semantic/analyze.go +++ b/internal/codeguard/ai/semantic/analyze.go @@ -27,7 +27,7 @@ func Analyze(ctx context.Context, opts Options) ([]core.Finding, error) { if !opts.Enabled || !commandConfigured(opts.Command) { return nil, nil } - req, ok := buildRequest(opts) + req, ok := buildRequest(opts) //nolint:contextcheck // git helpers use a contained timeout; deeper ctx threading is a tracked follow-up if !ok { return nil, nil } diff --git a/internal/codeguard/ai/triage/anthropic.go b/internal/codeguard/ai/triage/anthropic.go index 71711fb..1056aea 100644 --- a/internal/codeguard/ai/triage/anthropic.go +++ b/internal/codeguard/ai/triage/anthropic.go @@ -52,7 +52,7 @@ func (provider anthropicProvider) baseURL() string { } func decodeAnthropicVerdicts(resp *http.Response) (map[string]providerVerdict, error) { - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("ai triage provider returned %s", resp.Status) } diff --git a/internal/codeguard/ai/triage/openai.go b/internal/codeguard/ai/triage/openai.go index 22a055b..8df4174 100644 --- a/internal/codeguard/ai/triage/openai.go +++ b/internal/codeguard/ai/triage/openai.go @@ -52,7 +52,7 @@ func (provider openAIProvider) baseURL() string { } func decodeVerdicts(resp *http.Response) (map[string]providerVerdict, error) { - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("ai triage provider returned %s", resp.Status) } diff --git a/internal/codeguard/ai/triage/provider.go b/internal/codeguard/ai/triage/provider.go index 4906c12..df54fae 100644 --- a/internal/codeguard/ai/triage/provider.go +++ b/internal/codeguard/ai/triage/provider.go @@ -26,7 +26,7 @@ func newProvider(cfg runtimeConfig) provider { type noopProvider struct{} -func (noopProvider) Triage(ctx context.Context, candidates []candidate) (map[string]providerVerdict, error) { +func (noopProvider) Triage(_ context.Context, _ []candidate) (map[string]providerVerdict, error) { return map[string]providerVerdict{}, nil } @@ -34,7 +34,7 @@ type mockProvider struct { cfg runtimeConfig } -func (provider mockProvider) Triage(ctx context.Context, candidates []candidate) (map[string]providerVerdict, error) { +func (provider mockProvider) Triage(_ context.Context, candidates []candidate) (map[string]providerVerdict, error) { verdicts := make(map[string]providerVerdict, len(candidates)) decision := provider.cfg.MockDecision if decision == "" { diff --git a/internal/codeguard/ai/triage/triage.go b/internal/codeguard/ai/triage/triage.go index bdfc48c..1a57919 100644 --- a/internal/codeguard/ai/triage/triage.go +++ b/internal/codeguard/ai/triage/triage.go @@ -76,6 +76,7 @@ func Apply(ctx context.Context, cfg core.Config, opts core.ScanOptions, sections if len(pending) > 0 { fresh, err := provider.Triage(ctx, pending) if err != nil { + //nolint:gocritic // intentional: cached verdicts plus an error verdict, written to a different slice artifact.AIAnalysis.Verdicts = append(verdicts, core.AIAnalysisVerdict{ ID: "ai-triage-provider", Kind: "triage", diff --git a/internal/codeguard/cachefile/cachefile.go b/internal/codeguard/cachefile/cachefile.go index 89a7d0a..9512cd2 100644 --- a/internal/codeguard/cachefile/cachefile.go +++ b/internal/codeguard/cachefile/cachefile.go @@ -25,7 +25,7 @@ func Load(path string, payload any) bool { if err != nil { return false } - defer f.Close() + defer func() { _ = f.Close() }() data, err := io.ReadAll(io.LimitReader(f, maxCacheFileBytes)) if err != nil { return false diff --git a/internal/codeguard/checks/ci/ci.go b/internal/codeguard/checks/ci/ci.go index 186d2dd..e1dfcbb 100644 --- a/internal/codeguard/checks/ci/ci.go +++ b/internal/codeguard/checks/ci/ci.go @@ -18,7 +18,7 @@ func Run(ctx context.Context, env support.Context) core.SectionResult { } func findingsForTarget(_ context.Context, env support.Context, target core.TargetConfig) []core.Finding { - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each rule appends a variable number findings = append(findings, requiredWorkflowDirFindings(env, target)...) findings = append(findings, requiredPathFindings(env, target, env.Config.Checks.CIRules.RequiredWorkflowFiles, "required workflow file is missing")...) findings = append(findings, requiredPathFindings(env, target, env.Config.Checks.CIRules.RequiredReleaseFiles, "required release file is missing")...) diff --git a/internal/codeguard/checks/contracts/contracts.go b/internal/codeguard/checks/contracts/contracts.go index d1bbc74..dbb5826 100644 --- a/internal/codeguard/checks/contracts/contracts.go +++ b/internal/codeguard/checks/contracts/contracts.go @@ -21,7 +21,7 @@ func Run(ctx context.Context, env support.Context) core.SectionResult { func findingsForTarget(_ context.Context, env support.Context, target core.TargetConfig) []core.Finding { changed := changedFilesForTarget(env, target) - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each rule appends a variable number findings = append(findings, goBreakingFindings(env, target, changed)...) findings = append(findings, openAPIBreakingFindings(env, target, changed)...) findings = append(findings, protoBreakingFindings(env, target, changed)...) diff --git a/internal/codeguard/checks/contracts/migrations.go b/internal/codeguard/checks/contracts/migrations.go index 82fd96d..c0a115d 100644 --- a/internal/codeguard/checks/contracts/migrations.go +++ b/internal/codeguard/checks/contracts/migrations.go @@ -73,7 +73,7 @@ func isMigrationFile(env support.Context, rel string) bool { // destructiveStatementFindings scans ";"-separated statements so multi-line // SQL statements are evaluated as a unit (for the NOT NULL/DEFAULT pairing). func destructiveStatementFindings(env support.Context, file string, content string) []core.Finding { - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each statement appends a variable number line := 1 for _, statement := range strings.Split(content, ";") { findings = append(findings, statementFindings(env, file, statement, line)...) diff --git a/internal/codeguard/checks/design/design_go.go b/internal/codeguard/checks/design/design_go.go index b60c1e3..15666ce 100644 --- a/internal/codeguard/checks/design/design_go.go +++ b/internal/codeguard/checks/design/design_go.go @@ -27,7 +27,7 @@ func goFindingsForFile(env support.Context, file string, data []byte) []core.Fin return nil } - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each rule appends a variable number findings = append(findings, forbiddenPackageFindings(env, file, parsed.Name.Name)...) methodCounts, interfaceFindings := typeFindings(env, file, fset, parsed) findings = append(findings, interfaceFindings...) @@ -110,7 +110,7 @@ func methodFindings(env support.Context, file string, methodCounts map[string]in } func importFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File) []core.Finding { - findings := make([]core.Finding, 0) + 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, `"`) diff --git a/internal/codeguard/checks/design/design_graph_rust.go b/internal/codeguard/checks/design/design_graph_rust.go index 8ad2be4..fb6cc19 100644 --- a/internal/codeguard/checks/design/design_graph_rust.go +++ b/internal/codeguard/checks/design/design_graph_rust.go @@ -78,13 +78,13 @@ func expandRustUseClause(clause string) []string { if open < 0 { return []string{strings.TrimSpace(clause)} } - close := strings.LastIndex(clause, "}") - if close < open { + closeIdx := strings.LastIndex(clause, "}") + if closeIdx < open { return nil } prefix := strings.TrimSuffix(strings.TrimSpace(clause[:open]), "::") expanded := make([]string, 0) - for _, part := range splitRustGroupItems(clause[open+1 : close]) { + for _, part := range splitRustGroupItems(clause[open+1 : closeIdx]) { part = strings.TrimSpace(part) if part == "" { continue diff --git a/internal/codeguard/checks/design/design_python.go b/internal/codeguard/checks/design/design_python.go index c6d173f..ba46ec6 100644 --- a/internal/codeguard/checks/design/design_python.go +++ b/internal/codeguard/checks/design/design_python.go @@ -9,7 +9,7 @@ import ( "github.com/devr-tools/codeguard/internal/codeguard/core" ) -func pythonTargetFindings(env support.Context, target core.TargetConfig, graph pythonImportGraph) []core.Finding { +func pythonTargetFindings(env support.Context, _ core.TargetConfig, graph pythonImportGraph) []core.Finding { findings := make([]core.Finding, 0, len(graph.graph.Order)) for _, module := range graph.graph.Order { node := graph.graph.Nodes[module] diff --git a/internal/codeguard/checks/quality/quality.go b/internal/codeguard/checks/quality/quality.go index 5b328b7..204553c 100644 --- a/internal/codeguard/checks/quality/quality.go +++ b/internal/codeguard/checks/quality/quality.go @@ -10,7 +10,7 @@ import ( func Run(ctx context.Context, env support.Context) core.SectionResult { findings := support.CollectTargetFindings(ctx, env, qualityTargetFindings) - findings = append(findings, provenancePolicyFindings(env, findings)...) + findings = append(findings, provenancePolicyFindings(env, findings)...) //nolint:contextcheck // git helpers use a contained timeout; deeper ctx threading is a tracked follow-up return env.FinalizeSection("quality", "Code Quality", findings) } @@ -22,7 +22,7 @@ func qualityTargetFindings(ctx context.Context, env support.Context, target core findings = append(findings, commandFindings(ctx, env, target)...) findings = append(findings, coverageDeltaFindings(ctx, env, target)...) maybePutAISlopArtifact(env, target, findings) - findings = append(findings, changeRiskFindings(env, target, findings)...) + findings = append(findings, changeRiskFindings(env, target, findings)...) //nolint:contextcheck // git helpers use a contained timeout; deeper ctx threading is a tracked follow-up return findings } diff --git a/internal/codeguard/checks/quality/quality_additional_languages.go b/internal/codeguard/checks/quality/quality_additional_languages.go index d72680a..152f282 100644 --- a/internal/codeguard/checks/quality/quality_additional_languages.go +++ b/internal/codeguard/checks/quality/quality_additional_languages.go @@ -14,7 +14,7 @@ var ( ) func rustFindingsForFile(env support.Context, file string, data []byte) []core.Finding { - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each function appends a variable number for _, fn := range clikeQualityFunctions(string(data), support.CLikeRust, rustComplexity) { findings = append(findings, maintainabilityFindings(env, file, fn)...) } @@ -22,7 +22,7 @@ func rustFindingsForFile(env support.Context, file string, data []byte) []core.F } func javaFindingsForFile(env support.Context, file string, data []byte) []core.Finding { - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each function appends a variable number for _, fn := range clikeQualityFunctions(string(data), support.CLikeJava, braceComplexity) { findings = append(findings, maintainabilityFindings(env, file, fn)...) } @@ -37,7 +37,7 @@ func clikeQualityFunctions(source string, lang support.CLikeLanguage, complexity } func csharpFindingsForFile(env support.Context, file string, data []byte) []core.Finding { - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each function appends a variable number for _, fn := range braceLanguageFunctions(string(data), csharpMethodPattern, typedParameterCount, braceComplexity, csharpControlWords) { findings = append(findings, maintainabilityFindings(env, file, fn)...) } @@ -45,7 +45,7 @@ func csharpFindingsForFile(env support.Context, file string, data []byte) []core } func rubyFindingsForFile(env support.Context, file string, data []byte) []core.Finding { - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each function appends a variable number for _, fn := range rubyFunctions(string(data)) { findings = append(findings, maintainabilityFindings(env, file, fn)...) } diff --git a/internal/codeguard/checks/quality/quality_ai.go b/internal/codeguard/checks/quality/quality_ai.go index 6602294..1d70b54 100644 --- a/internal/codeguard/checks/quality/quality_ai.go +++ b/internal/codeguard/checks/quality/quality_ai.go @@ -10,7 +10,7 @@ import ( "github.com/devr-tools/codeguard/internal/codeguard/core" ) -func goAIQualityFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File, data []byte) []core.Finding { +func goAIQualityFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File, _ []byte) []core.Finding { findings := make([]core.Finding, 0) ast.Inspect(parsed, func(n ast.Node) bool { assign, ok := n.(*ast.AssignStmt) diff --git a/internal/codeguard/checks/quality/quality_ai_semantic.go b/internal/codeguard/checks/quality/quality_ai_semantic.go index 1aec92a..2ea6e0b 100644 --- a/internal/codeguard/checks/quality/quality_ai_semantic.go +++ b/internal/codeguard/checks/quality/quality_ai_semantic.go @@ -44,7 +44,7 @@ func semanticFindings(ctx context.Context, env support.Context, target core.Targ return findings } -func semanticRuntimeFinding(env support.Context, target core.TargetConfig, message string) core.Finding { +func semanticRuntimeFinding(env support.Context, _ core.TargetConfig, message string) core.Finding { return env.NewFinding(support.FindingInput{ RuleID: "quality.ai.semantic-runtime", Level: "fail", diff --git a/internal/codeguard/checks/quality/quality_ai_target_python.go b/internal/codeguard/checks/quality/quality_ai_target_python.go index 0b3787b..ca12e6c 100644 --- a/internal/codeguard/checks/quality/quality_ai_target_python.go +++ b/internal/codeguard/checks/quality/quality_ai_target_python.go @@ -70,7 +70,7 @@ func pythonFileAIQualityFindings(env support.Context, root string, rel string, i // pythonLocalModuleNames collects top-level module and package names that // exist on disk so that local imports resolve without manifests. -func pythonLocalModuleNames(root string, files []string) map[string]struct{} { +func pythonLocalModuleNames(_ string, files []string) map[string]struct{} { names := map[string]struct{}{} for _, rel := range files { slash := filepath.ToSlash(rel) diff --git a/internal/codeguard/checks/quality/quality_metrics.go b/internal/codeguard/checks/quality/quality_metrics.go index c5eec88..7b342b9 100644 --- a/internal/codeguard/checks/quality/quality_metrics.go +++ b/internal/codeguard/checks/quality/quality_metrics.go @@ -105,17 +105,3 @@ func splitTopLevelDelimited(signature string) []string { } return appendDelimitedPart(parts, signature[start:]) } - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/internal/codeguard/checks/quality/quality_python.go b/internal/codeguard/checks/quality/quality_python.go index f37677b..63c6166 100644 --- a/internal/codeguard/checks/quality/quality_python.go +++ b/internal/codeguard/checks/quality/quality_python.go @@ -8,7 +8,7 @@ import ( ) func pythonFindingsForFile(env support.Context, file string, data []byte) []core.Finding { - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each function appends a variable number for _, fn := range pythonFunctions(string(data)) { findings = append(findings, maintainabilityFindings(env, file, fn)...) } diff --git a/internal/codeguard/checks/quality/quality_typescript.go b/internal/codeguard/checks/quality/quality_typescript.go index d5bad53..c97e666 100644 --- a/internal/codeguard/checks/quality/quality_typescript.go +++ b/internal/codeguard/checks/quality/quality_typescript.go @@ -15,7 +15,7 @@ type typeScriptScanContext struct { } func typeScriptFindingsForFile(env support.Context, file string, data []byte) []core.Finding { - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each scan stage appends a variable number source := strings.ReplaceAll(string(data), "\r\n", "\n") ctx := typeScriptScanContext{ env: env, diff --git a/internal/codeguard/checks/security/security_taint_go_calls.go b/internal/codeguard/checks/security/security_taint_go_calls.go index b5d081b..4e265ef 100644 --- a/internal/codeguard/checks/security/security_taint_go_calls.go +++ b/internal/codeguard/checks/security/security_taint_go_calls.go @@ -46,7 +46,7 @@ func (s *goScope) callSourceTaint(call *ast.CallExpr, callee string) *goTaint { } // localCallTaint applies same-file function summaries at call sites. -func (s *goScope) localCallTaint(call *ast.CallExpr, callee string, args []*goTaint) *goTaint { +func (s *goScope) localCallTaint(_ *ast.CallExpr, callee string, args []*goTaint) *goTaint { summary, known := s.analyzer.summaries[callee] if !known || summary == nil { return nil diff --git a/internal/codeguard/checks/security/security_taint_python_expr.go b/internal/codeguard/checks/security/security_taint_python_expr.go index 2618e86..2ee0254 100644 --- a/internal/codeguard/checks/security/security_taint_python_expr.go +++ b/internal/codeguard/checks/security/security_taint_python_expr.go @@ -111,7 +111,7 @@ func (s *pyScope) localCallTaint(stripped string, line int) *pyTaint { // taintedIdentifier scans for identifiers bound to tainted values, skipping // attribute accesses like obj.name. -func (s *pyScope) taintedIdentifier(stripped string, line int) *pyTaint { +func (s *pyScope) taintedIdentifier(stripped string, _ int) *pyTaint { var found *pyTaint for _, match := range pyIdentScanPattern.FindAllStringIndex(stripped, -1) { if match[0] > 0 && stripped[match[0]-1] == '.' { diff --git a/internal/codeguard/checks/security/security_typescript.go b/internal/codeguard/checks/security/security_typescript.go index 35046af..1180ffb 100644 --- a/internal/codeguard/checks/security/security_typescript.go +++ b/internal/codeguard/checks/security/security_typescript.go @@ -82,7 +82,7 @@ func typeScriptAliasedShellFindings(ctx typeScriptScanContext) []core.Finding { } func typeScriptVMFindings(ctx typeScriptScanContext) []core.Finding { - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each alias appends a variable number vmMethods := []string{"runInContext", "runInNewContext", "runInThisContext", "compileFunction"} directAliases := collectTypeScriptNamedModuleBindings(ctx.source, "vm", append([]string{"Script"}, vmMethods...)) vmNamespaces := collectTypeScriptNamespaceBindings(ctx.source, "vm") diff --git a/internal/codeguard/checks/supplychain/policy.go b/internal/codeguard/checks/supplychain/policy.go index 39455e3..59c004f 100644 --- a/internal/codeguard/checks/supplychain/policy.go +++ b/internal/codeguard/checks/supplychain/policy.go @@ -8,7 +8,7 @@ import ( ) func targetFindings(_ context.Context, env support.Context, target core.TargetConfig, manifests []core.SupplyChainManifest) []core.Finding { - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each manifest appends a variable number changed := changedFilesSet(env.ChangedFiles) for _, manifest := range manifests { findings = append(findings, unpinnedDependencyFindings(env, manifest)...) diff --git a/internal/codeguard/checks/support/scan_helpers.go b/internal/codeguard/checks/support/scan_helpers.go index 0800fb6..4046e21 100644 --- a/internal/codeguard/checks/support/scan_helpers.go +++ b/internal/codeguard/checks/support/scan_helpers.go @@ -8,7 +8,7 @@ import ( ) func CollectTargetFindings(ctx context.Context, env Context, collect func(context.Context, Context, core.TargetConfig) []core.Finding) []core.Finding { - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each target appends a variable number for _, target := range env.Config.Targets { findings = append(findings, collect(ctx, env, target)...) } diff --git a/internal/codeguard/checks/support/typescript_parser.go b/internal/codeguard/checks/support/typescript_parser.go index 1daed51..4641348 100644 --- a/internal/codeguard/checks/support/typescript_parser.go +++ b/internal/codeguard/checks/support/typescript_parser.go @@ -56,7 +56,7 @@ func (parser *scriptCallArgumentParser) consumeNonCode(idx *int) bool { case scriptParserBlockComment: if parser.matchesAt(*idx, "*/") { parser.state = scriptParserCode - *idx = *idx + 1 + *idx++ } return true case scriptParserSingleQuote: @@ -72,7 +72,7 @@ func (parser *scriptCallArgumentParser) consumeNonCode(idx *int) bool { func (parser *scriptCallArgumentParser) consumeQuotedState(idx *int, ch byte, quote byte) bool { if ch == '\\' && *idx+1 < len(parser.source) { - *idx = *idx + 1 + *idx++ return true } if ch == quote { @@ -85,11 +85,11 @@ func (parser *scriptCallArgumentParser) beginComment(idx *int) bool { switch { case parser.matchesAt(*idx, "//"): parser.state = scriptParserLineComment - *idx = *idx + 1 + *idx++ return true case parser.matchesAt(*idx, "/*"): parser.state = scriptParserBlockComment - *idx = *idx + 1 + *idx++ return true default: return false diff --git a/internal/codeguard/config/defaults_ai.go b/internal/codeguard/config/defaults_ai.go index 2e5131b..a8569e0 100644 --- a/internal/codeguard/config/defaults_ai.go +++ b/internal/codeguard/config/defaults_ai.go @@ -49,7 +49,7 @@ func applyAIHybridTriageDefaults(dst *core.AIHybridTriageConfig, def core.AIHybr } } -func applyAISemanticDefaults(dst *core.AISemanticConfig, def core.AISemanticConfig) { +func applyAISemanticDefaults(dst *core.AISemanticConfig, _ core.AISemanticConfig) { if dst.Enabled == nil { dst.Enabled = boolPtr(true) } diff --git a/internal/codeguard/config/defaults_helpers.go b/internal/codeguard/config/defaults_helpers.go index 105b483..41cfbba 100644 --- a/internal/codeguard/config/defaults_helpers.go +++ b/internal/codeguard/config/defaults_helpers.go @@ -35,9 +35,10 @@ func defaultBoolPtr(dst **bool, value bool) { } } -func valueOrDefault(ptr *bool, def bool) bool { +// boolValueOrTrue reads an optional bool, treating an unset (nil) pointer as true. +func boolValueOrTrue(ptr *bool) bool { if ptr == nil { - return def + return true } return *ptr } diff --git a/internal/codeguard/config/defaults_rules.go b/internal/codeguard/config/defaults_rules.go index 50ea61f..827273c 100644 --- a/internal/codeguard/config/defaults_rules.go +++ b/internal/codeguard/config/defaults_rules.go @@ -105,7 +105,7 @@ func applySecurityDefaults(dst *core.SecurityRulesConfig, def core.SecurityRules } func applyAIChangeRiskDefaults(dst *core.AIChangeRiskConfig, def core.AIChangeRiskConfig) { - defaultBoolPtr(&dst.Enabled, valueOrDefault(def.Enabled, true)) + defaultBoolPtr(&dst.Enabled, boolValueOrTrue(def.Enabled)) if dst.WarnThreshold == 0 { dst.WarnThreshold = def.WarnThreshold } @@ -115,9 +115,9 @@ func applyAIChangeRiskDefaults(dst *core.AIChangeRiskConfig, def core.AIChangeRi } func applySupplyChainDefaults(dst *core.SupplyChainRulesConfig, def core.SupplyChainRulesConfig) { - defaultBoolPtr(&dst.RequireLockfile, valueOrDefault(def.RequireLockfile, true)) - defaultBoolPtr(&dst.DetectLockfileDrift, valueOrDefault(def.DetectLockfileDrift, true)) - defaultBoolPtr(&dst.DetectUnpinned, valueOrDefault(def.DetectUnpinned, true)) + defaultBoolPtr(&dst.RequireLockfile, boolValueOrTrue(def.RequireLockfile)) + defaultBoolPtr(&dst.DetectLockfileDrift, boolValueOrTrue(def.DetectLockfileDrift)) + defaultBoolPtr(&dst.DetectUnpinned, boolValueOrTrue(def.DetectUnpinned)) defaultStringSlice(&dst.AllowedLicenses, def.AllowedLicenses, false) defaultStringSlice(&dst.DeniedLicenses, def.DeniedLicenses, false) defaultSingleCommandMap(&dst.LicenseCommands, def.LicenseCommands) diff --git a/internal/codeguard/config/io.go b/internal/codeguard/config/io.go index 89142cb..9dfdd78 100644 --- a/internal/codeguard/config/io.go +++ b/internal/codeguard/config/io.go @@ -31,7 +31,7 @@ func LoadFile(path string) (core.Config, error) { if err != nil { return core.Config{}, err } - defer f.Close() + defer func() { _ = f.Close() }() data, err := io.ReadAll(io.LimitReader(f, maxConfigFileBytes)) if err != nil { return core.Config{}, err diff --git a/internal/codeguard/report/github_comment.go b/internal/codeguard/report/github_comment.go index 985ae1e..23a4a20 100644 --- a/internal/codeguard/report/github_comment.go +++ b/internal/codeguard/report/github_comment.go @@ -106,10 +106,3 @@ func githubCommentSentence(value string, fallback string) string { } return trimmed } - -func min(a int, b int) int { - if a < b { - return a - } - return b -} diff --git a/internal/codeguard/report/write.go b/internal/codeguard/report/write.go index fb17abe..f049827 100644 --- a/internal/codeguard/report/write.go +++ b/internal/codeguard/report/write.go @@ -114,13 +114,6 @@ func escapeGitHubAnnotation(value string) string { return replacer.Replace(value) } -func max(a, b int) int { - if a > b { - return a - } - return b -} - func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { diff --git a/internal/codeguard/runner/checks/checks.go b/internal/codeguard/runner/checks/checks.go index c61bb86..8b21170 100644 --- a/internal/codeguard/runner/checks/checks.go +++ b/internal/codeguard/runner/checks/checks.go @@ -12,7 +12,7 @@ import ( func Build(ctx context.Context, sc runnersupport.Context) []core.SectionResult { sections := make([]core.SectionResult, 0, len(sectionRegistry)) - checkEnv := buildCheckContext(sc) + checkEnv := buildCheckContext(sc) //nolint:contextcheck // git helpers use a contained timeout; deeper ctx threading is a tracked follow-up for _, def := range sectionRegistry { if !def.enabled(sc) { continue @@ -117,12 +117,8 @@ func buildCheckContext(sc runnersupport.Context) checkSupport.Context { RunGovulncheck: func(ctx context.Context, dir string, cmdName string) ([]core.Finding, error) { return govulncheckrunner.Run(ctx, dir, cmdName, sc) }, - RunCommandCheck: func(ctx context.Context, dir string, check core.CommandCheckConfig) (string, error) { - return runnersupport.RunCommandCheck(ctx, dir, check) - }, - RunCommandCheckWithEnv: func(ctx context.Context, dir string, check core.CommandCheckConfig, env []string) (string, error) { - return runnersupport.RunCommandCheckWithEnv(ctx, dir, check, env) - }, + RunCommandCheck: runnersupport.RunCommandCheck, + RunCommandCheckWithEnv: runnersupport.RunCommandCheckWithEnv, RunDiffCommandCheck: func(ctx context.Context, dir string, baseRef string, check core.CommandCheckConfig) (string, error) { return runnersupport.RunDiffCommandCheckWithContext(ctx, sc, dir, baseRef, check) }, diff --git a/internal/codeguard/runner/checks/registry.go b/internal/codeguard/runner/checks/registry.go index 8ab88e3..d95a0dd 100644 --- a/internal/codeguard/runner/checks/registry.go +++ b/internal/codeguard/runner/checks/registry.go @@ -40,7 +40,7 @@ var sectionRegistry = []sectionDef{ id: "quality", name: "Quality", enabled: func(sc runnersupport.Context) bool { return sc.Cfg.Checks.Quality }, - run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + run: func(ctx context.Context, _ runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { return qualityCheck.Run(ctx, checkEnv) }, }, @@ -48,7 +48,7 @@ var sectionRegistry = []sectionDef{ id: "design", name: "Design", enabled: func(sc runnersupport.Context) bool { return sc.Cfg.Checks.Design }, - run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + run: func(ctx context.Context, _ runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { return designCheck.Run(ctx, checkEnv) }, }, @@ -56,7 +56,7 @@ var sectionRegistry = []sectionDef{ id: "security", name: "Security", enabled: func(sc runnersupport.Context) bool { return sc.Cfg.Checks.Security }, - run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + run: func(ctx context.Context, _ runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { return securityCheck.Run(ctx, checkEnv) }, }, @@ -64,7 +64,7 @@ var sectionRegistry = []sectionDef{ id: "prompts", name: "Prompts", enabled: func(sc runnersupport.Context) bool { return sc.Cfg.Checks.Prompts }, - run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + run: func(ctx context.Context, _ runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { return promptsCheck.Run(ctx, checkEnv) }, }, @@ -72,7 +72,7 @@ var sectionRegistry = []sectionDef{ id: "ci", name: "CI", enabled: func(sc runnersupport.Context) bool { return sc.Cfg.Checks.CI }, - run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + run: func(ctx context.Context, _ runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { return ciCheck.Run(ctx, checkEnv) }, }, @@ -80,7 +80,7 @@ var sectionRegistry = []sectionDef{ id: "supply-chain", name: "Supply Chain", enabled: func(sc runnersupport.Context) bool { return sc.Cfg.Checks.SupplyChain }, - run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + run: func(ctx context.Context, _ runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { return supplyChainCheck.Run(ctx, checkEnv) }, }, @@ -88,7 +88,7 @@ var sectionRegistry = []sectionDef{ id: "contracts", name: "Contracts", enabled: contractsEnabled, - run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + run: func(ctx context.Context, _ runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { return contractsCheck.Run(ctx, checkEnv) }, }, @@ -96,7 +96,7 @@ var sectionRegistry = []sectionDef{ id: "custom", name: "Custom Rules", enabled: func(sc runnersupport.Context) bool { return len(sc.CustomRules) > 0 }, - run: func(ctx context.Context, sc runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + run: func(ctx context.Context, sc runnersupport.Context, _ checkSupport.Context) core.SectionResult { return customrunner.RunSection(ctx, sc) }, }, diff --git a/internal/codeguard/runner/custom/custom.go b/internal/codeguard/runner/custom/custom.go index 48f8fe0..b3d7445 100644 --- a/internal/codeguard/runner/custom/custom.go +++ b/internal/codeguard/runner/custom/custom.go @@ -10,7 +10,7 @@ import ( ) func RunSection(ctx context.Context, sc runnersupport.Context) core.SectionResult { - findings := make([]core.Finding, 0) + findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each target appends a variable number for _, target := range sc.Cfg.Targets { findings = append(findings, runnersupport.ScanTargetFiles(sc, target, "custom", func(string) bool { return true }, func(file string, data []byte) []core.Finding { localFindings := make([]core.Finding, 0) diff --git a/internal/codeguard/runner/runner.go b/internal/codeguard/runner/runner.go index 802ebb8..39bbf0d 100644 --- a/internal/codeguard/runner/runner.go +++ b/internal/codeguard/runner/runner.go @@ -34,7 +34,7 @@ func RunWithOptions(ctx context.Context, cfg core.Config, opts core.ScanOptions) return core.Report{}, err } - sc, err := runnersupport.NewContext(cfg, runnersupport.NormalizeScanOptions(opts)) + sc, err := runnersupport.NewContext(cfg, runnersupport.NormalizeScanOptions(opts)) //nolint:contextcheck // git helpers use a contained timeout; deeper ctx threading is a tracked follow-up if err != nil { return core.Report{}, err } diff --git a/internal/codeguard/runner/support/commands.go b/internal/codeguard/runner/support/commands.go index 86ed07c..e21e63d 100644 --- a/internal/codeguard/runner/support/commands.go +++ b/internal/codeguard/runner/support/commands.go @@ -21,7 +21,7 @@ func RunCommandCheckWithEnv(ctx context.Context, dir string, check core.CommandC } func RunDiffCommandCheck(ctx context.Context, dir string, baseRef string, check core.CommandCheckConfig) (string, error) { - diffEnv, cleanup, err := prepareDiffCommandEnv(dir, baseRef) + diffEnv, cleanup, err := prepareDiffCommandEnv(dir, baseRef) //nolint:contextcheck // git helpers use a contained timeout; deeper ctx threading is a tracked follow-up if err != nil { return "", err } diff --git a/internal/codeguard/runner/support/diff_command.go b/internal/codeguard/runner/support/diff_command.go index 3cb0154..6f4e7e3 100644 --- a/internal/codeguard/runner/support/diff_command.go +++ b/internal/codeguard/runner/support/diff_command.go @@ -146,7 +146,7 @@ func copyFile(srcPath string, dstPath string, mode os.FileMode) (err error) { } }() - if err := os.MkdirAll(filepath.Dir(dstPath), 0o750); err != nil { + if err = os.MkdirAll(filepath.Dir(dstPath), 0o750); err != nil { return err } diff --git a/internal/codeguard/runner/support/patch.go b/internal/codeguard/runner/support/patch.go index 8b51bdb..a9f776e 100644 --- a/internal/codeguard/runner/support/patch.go +++ b/internal/codeguard/runner/support/patch.go @@ -22,6 +22,11 @@ func LoadDiffScopeFromUnifiedDiff(targets []core.TargetConfig, diffText string) return out } +// MaterializePatchedTargets writes diffText into a temporary worktree copy of +// every configured target, returning a config rebased onto that copy, the +// per-target diff command environments, a cleanup func, and any error. +// +//nolint:revive // unexported diffCommandEnv stays package-private; this is an internal-only helper func MaterializePatchedTargets(cfg core.Config, diffText string) (core.Config, map[string]diffCommandEnv, func(), error) { tempRoot, err := os.MkdirTemp("", "codeguard-patch-*") if err != nil { diff --git a/tests/checks/contracts_helpers_test.go b/tests/checks/contracts_helpers_test.go index faedfa5..53e0bc3 100644 --- a/tests/checks/contracts_helpers_test.go +++ b/tests/checks/contracts_helpers_test.go @@ -17,6 +17,7 @@ func initContractsRepo(t *testing.T) string { return dir } +//nolint:unparam // general-purpose test helper; message is part of its API shape func commitAll(t *testing.T, dir string, message string) { t.Helper() runGit(t, dir, "add", ".") diff --git a/tests/checks/coverage_lcov_test.go b/tests/checks/coverage_lcov_test.go index bcfe784..a0d4207 100644 --- a/tests/checks/coverage_lcov_test.go +++ b/tests/checks/coverage_lcov_test.go @@ -31,7 +31,7 @@ end_of_record if app[1] != 3 || app[2] != 0 || app[4] != 1 { t.Fatalf("unexpected hits for src/app.ts: %v", app) } - if _, ok := app[3]; ok { + if _, hasLine3 := app[3]; hasLine3 { t.Fatalf("line 3 has no DA record and must stay unmeasured: %v", app) } other, ok := profile["src/other.ts"] diff --git a/tests/checks/features_nl_test.go b/tests/checks/features_nl_test.go index 4fc907b..bd92549 100644 --- a/tests/checks/features_nl_test.go +++ b/tests/checks/features_nl_test.go @@ -121,8 +121,8 @@ func TestCacheFileCreatedAndInvalidatedOnContentChange(t *testing.T) { t.Fatalf("run: %v", err) } assertSectionStatus(t, report, "AI Prompts", "fail") - if _, err := os.Stat(cfg.Cache.Path); err != nil { - t.Fatalf("expected cache file: %v", err) + if _, statErr := os.Stat(cfg.Cache.Path); statErr != nil { + t.Fatalf("expected cache file: %v", statErr) } writeFile(t, filepath.Join(dir, "prompts", "system.prompt"), "Safe prompt line.\n") diff --git a/tests/checks/features_test.go b/tests/checks/features_test.go index f6aad92..465d46c 100644 --- a/tests/checks/features_test.go +++ b/tests/checks/features_test.go @@ -51,8 +51,8 @@ func TestBaselineSuppressesExistingFinding(t *testing.T) { assertSectionStatus(t, report, "AI Prompts", "fail") baselinePath := filepath.Join(dir, "codeguard-baseline.json") - if err := codeguard.WriteBaselineFile(baselinePath, codeguard.BaselineEntriesFromReport(report)); err != nil { - t.Fatalf("write baseline: %v", err) + if writeErr := codeguard.WriteBaselineFile(baselinePath, codeguard.BaselineEntriesFromReport(report)); writeErr != nil { + t.Fatalf("write baseline: %v", writeErr) } cfg.Baseline.Path = baselinePath diff --git a/tests/checks/quality_ai_semantic_helpers_test.go b/tests/checks/quality_ai_semantic_helpers_test.go index fc5184d..ad5aac0 100644 --- a/tests/checks/quality_ai_semantic_helpers_test.go +++ b/tests/checks/quality_ai_semantic_helpers_test.go @@ -49,6 +49,7 @@ func runSemanticGit(t *testing.T, dir string, args ...string) { } } +//nolint:unparam // general-purpose test helper; want is part of its API shape func assertFileEquals(t *testing.T, path string, want string) { t.Helper() data, err := os.ReadFile(path) diff --git a/tests/checks/quality_assertions_test.go b/tests/checks/quality_assertions_test.go index f100ed0..14a1a3b 100644 --- a/tests/checks/quality_assertions_test.go +++ b/tests/checks/quality_assertions_test.go @@ -22,6 +22,7 @@ func assertFindingRulePresent(t *testing.T, report codeguard.Report, section str t.Fatalf("section %q not found", section) } +//nolint:unparam // general-purpose test helper; section is part of its API shape func assertFindingLevel(t *testing.T, report codeguard.Report, section string, ruleID string, level string) { t.Helper() for _, result := range report.Sections { diff --git a/tests/checks/test_helpers_test.go b/tests/checks/test_helpers_test.go index e9499dd..825f236 100644 --- a/tests/checks/test_helpers_test.go +++ b/tests/checks/test_helpers_test.go @@ -55,12 +55,12 @@ func assertSectionStatus(t *testing.T, report codeguard.Report, name string, wan t.Fatalf("section %q not found", name) } -func assertSectionFindingCountAtLeast(t *testing.T, report codeguard.Report, name string, min int) { +func assertSectionFindingCountAtLeast(t *testing.T, report codeguard.Report, name string, minCount int) { t.Helper() for _, section := range report.Sections { if section.Name == name { - if len(section.Findings) < min { - t.Fatalf("%s findings = %d, want at least %d", name, len(section.Findings), min) + if len(section.Findings) < minCount { + t.Fatalf("%s findings = %d, want at least %d", name, len(section.Findings), minCount) } return } diff --git a/tests/codeguard/ai_provider_anthropic_test.go b/tests/codeguard/ai_provider_anthropic_test.go index 342d792..081374a 100644 --- a/tests/codeguard/ai_provider_anthropic_test.go +++ b/tests/codeguard/ai_provider_anthropic_test.go @@ -125,7 +125,7 @@ func TestAnthropicRuntimeProviderRetriesRateLimit(t *testing.T) { t.Setenv("CODEGUARD_AI_RETRY_BASE_DELAY", "1ms") var calls atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { if calls.Add(1) == 1 { w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusTooManyRequests) @@ -161,7 +161,7 @@ func TestOpenAIRuntimeProviderRetriesServerError(t *testing.T) { t.Setenv("CODEGUARD_AI_RETRY_BASE_DELAY", "1ms") var calls atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { if calls.Add(1) == 1 { w.WriteHeader(http.StatusInternalServerError) return @@ -198,7 +198,7 @@ func TestRuntimeProviderRetriesExhaustGracefully(t *testing.T) { t.Setenv("CODEGUARD_AI_MAX_RETRIES", "1") var calls atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { calls.Add(1) w.WriteHeader(http.StatusTooManyRequests) })) diff --git a/tests/codeguard/ai_triage_anthropic_test.go b/tests/codeguard/ai_triage_anthropic_test.go index 87756d3..6ef2201 100644 --- a/tests/codeguard/ai_triage_anthropic_test.go +++ b/tests/codeguard/ai_triage_anthropic_test.go @@ -155,7 +155,7 @@ func TestHybridTriageAnthropicProviderRetriesRateLimit(t *testing.T) { func TestHybridTriageProviderFailureKeepsFindings(t *testing.T) { root := t.TempDir() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer server.Close() diff --git a/tests/codeguard/ai_triage_test.go b/tests/codeguard/ai_triage_test.go index e858e2f..272d686 100644 --- a/tests/codeguard/ai_triage_test.go +++ b/tests/codeguard/ai_triage_test.go @@ -152,8 +152,8 @@ func doThing() error { return nil } if err != nil { t.Fatalf("second Run returned error: %v", err) } - if data, err := os.ReadFile(counterPath); err != nil { - t.Fatalf("read count file: %v", err) + if data, readErr := os.ReadFile(counterPath); readErr != nil { + t.Fatalf("read count file: %v", readErr) } else if strings.TrimSpace(string(data)) != "1" { t.Fatalf("expected 1 provider call across cached rerun, got %q", strings.TrimSpace(string(data))) } @@ -172,6 +172,7 @@ func doThing() error { return nil } } } +//nolint:unparam // general-purpose test helper; name is part of its API shape func findSection(t *testing.T, report codeguard.Report, name string) codeguard.SectionResult { t.Helper() for _, section := range report.Sections { diff --git a/tests/mcp/http_test.go b/tests/mcp/http_test.go index a56bcb7..44ab650 100644 --- a/tests/mcp/http_test.go +++ b/tests/mcp/http_test.go @@ -19,7 +19,7 @@ import ( const httpTestToken = "http-smoke-token" -func TestMCPServeHTTPHelperProcess(t *testing.T) { +func TestMCPServeHTTPHelperProcess(_ *testing.T) { if os.Getenv("GO_WANT_MCP_HTTP_HELPER_PROCESS") != "1" { return } diff --git a/tests/mcp/smoke_helpers_test.go b/tests/mcp/smoke_helpers_test.go index 31b23ab..390871d 100644 --- a/tests/mcp/smoke_helpers_test.go +++ b/tests/mcp/smoke_helpers_test.go @@ -13,7 +13,7 @@ import ( "github.com/devr-tools/codeguard/internal/cli" ) -func TestMCPServeHelperProcess(t *testing.T) { +func TestMCPServeHelperProcess(_ *testing.T) { if os.Getenv("GO_WANT_MCP_HELPER_PROCESS") != "1" { return } From d4075412a75e2a7177dc620868c3a65e2dad8fda Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Tue, 30 Jun 2026 16:27:30 -0400 Subject: [PATCH 5/6] docs(knowledge): capture section registry, git hardening, and lint enforcement Note the data-driven sectionRegistry + safeRun panic recovery, the base_ref/ git subprocess hardening contract (and why the contextcheck nolints are deliberate), and that golangci-lint is now CI-blocking with revive style rules excluded. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/knowledge/architecture-boundaries.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.claude/knowledge/architecture-boundaries.md b/.claude/knowledge/architecture-boundaries.md index 310ed1f..01dae23 100644 --- a/.claude/knowledge/architecture-boundaries.md +++ b/.claude/knowledge/architecture-boundaries.md @@ -21,3 +21,6 @@ Key architectural decisions, service boundaries, data flow, integration points, - **MCP `config_path` confinement**: caller-supplied `config_path` is confined (`confinePath` in `mcp_tools.go`) to the server config dir + cwd + client roots; the server's own `--config` default is never confined. This closed a remote arbitrary-file-read finding. Streaming: `core.ScanOptions.OnSectionComplete` (tagged `json:"-"` — marshaling a non-nil func errors) fires in `runner/support.FinalizeSection`. - **`codeguard serve --mcp --http`** serves Streamable HTTP for remote hosts (Devin): `POST {mcp-path}` returns `application/json` for sync methods and `text/event-stream` for `tools/call`; optional static-bearer auth (`--auth-token`/`$CODEGUARD_MCP_AUTH_TOKEN`, constant-time compared), `GET /healthz`, body/concurrency caps, SIGINT/SIGTERM graceful shutdown. No OAuth. MCP resources (`codeguard://rules`, `codeguard://config`, `codeguard://rules/{rule_id}`) and prompts (`review-diff`, `triage-findings`, `explain-rule`) wrap the same SDK accessors the tools use. Devin onboarding pack: `examples/hooks/devin/`. - The taint engine emits a single rule ID per language, chosen by sink: `goTaintRuleID`/`pyTaintRuleID` return `security.ssrf.{go,python}` when the sink is an outbound-HTTP callee (`goHTTPSinkArgIndex`/`pyHTTPSinkArgIndex`), else the generic `security.taint.{go,python}`. To add an SSRF sink, add it to the arg-index map; the rule routing and OWASP A10 mapping follow automatically. +- **Section dispatch is data-driven, not a hardcoded ladder.** `runner/checks/Build` iterates `sectionRegistry` (`runner/checks/registry.go`) — a `[]sectionDef{id, name, enabled(sc), run(ctx, sc, checkEnv)}`. To add a check section, append a `sectionDef`; do NOT reintroduce an `if cfg.X {...}` ladder. Every section runs through `safeRun` (in `checks.go`), which recovers panics into a `StatusWarn` SectionResult with a `checks.section.panic` finding — so a panic in one check degrades that section to a warning instead of aborting the whole scan. Preserve registry ordering; it determines output section order. +- **Git subprocess hardening contract (threat model: untrusted PR).** `base_ref` from MCP/CLI is validated by `runnersupport.ValidateBaseRef` (rejects leading `-` to block git option injection; restricts charset; preserves the `stdin` sentinel) BEFORE reaching git, and `--end-of-options` is passed to git diff/show/worktree. All git/govulncheck subprocesses use `exec.CommandContext` with a contained timeout and read output through `io.LimitReader` (`maxGitOutputBytes`) to prevent hangs/OOM. The `//nolint:contextcheck` markers in the git layer (`runner/support`, `ai/fix`, `ai/semantic`, `checks/quality/quality.go`, `runner/runner.go`) are DELIBERATE — the contained timeout is the chosen design; threading caller `ctx` end-to-end is a tracked follow-up. Don't "fix" them by removing the nolint without actually threading ctx. `runner/support/MatchPattern` (glob→regex) uses `regexp.Compile` + a `sync.Map` cache and returns false (never panics) on a malformed config glob. +- **Lint is enforced in CI.** `make lint` is `go vet`; `make lint-strict` / the CI `golangci-lint-action` step (now blocking — no `continue-on-error`) runs the 16-linter `.golangci.yml`. `golangci-lint run` must report 0 before pushing. The config excludes revive's documentation/naming style rules (`exported`, `package-comments`, `unused-parameter`) and excludes `tests/` from gosec/errcheck/prealloc/bodyclose (intentional fixtures). Cache/identity keys use sha256 (not sha1). Documenting the public `pkg/codeguard` API with doc comments is a known follow-up (revive `exported` is currently excluded rather than satisfied). From adaff9e7e57d0a9191c1bd726a33c7d890985fe1 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Tue, 30 Jun 2026 21:22:22 -0400 Subject: [PATCH 6/6] ci: use golangci-lint-action@v8 with golangci-lint v2 The config is golangci-lint v2 schema, but action@v6 installs v1.64.8, which rejects it ("additional properties 'version'/'settings' not allowed"). Bump to @v8 (installs golangci-lint v2, runs on Node 24) and pin version v2.12.2 to match local. `golangci-lint config verify` passes; run reports 0 issues. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbad84a..29b1756 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,9 @@ jobs: run: make lint - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v8 + with: + version: v2.12.2 test: name: test