From 5e532a2a23b60c19e357cbc155f8fe8e695bf306 Mon Sep 17 00:00:00 2001 From: AJMini Date: Sat, 29 Aug 2026 15:09:52 -0400 Subject: [PATCH 1/3] feat: add baseline governance --- README.md | 39 +++ docs/production.md | 18 + .../plans/2026-08-29-baseline-governance.md | 114 ++++++ internal/cli/baseline.go | 230 +++++++++++++ internal/cli/baseline_audit.go | 325 ++++++++++++++++++ internal/cli/baseline_audit_test.go | 69 ++++ internal/cli/baseline_io.go | 79 +++++ internal/cli/baseline_io_test.go | 81 +++++ internal/cli/baseline_policy.go | 72 ++++ internal/cli/baseline_policy_test.go | 41 +++ internal/cli/commands.go | 5 +- internal/cli/commands_scan_helpers.go | 11 +- internal/cli/menu.go | 2 +- .../config/baseline_governance_test.go | 35 ++ internal/codeguard/config/validate.go | 24 ++ internal/codeguard/core/config_types.go | 17 +- internal/codeguard/core/report_types.go | 16 +- .../codeguard/core/rule_scan_pack_types.go | 3 + internal/codeguard/runner/runner.go | 3 + internal/codeguard/runner/support/context.go | 30 ++ .../runner/support/findings_section.go | 10 +- .../codeguard/runner/support/suppressions.go | 39 ++- pkg/codeguard/sdk_types_runtime_report.go | 1 + pkg/codeguard/sdk_types_state.go | 2 + tests/checks/fingerprint_baseline_test.go | 16 +- tests/cli/baseline_governance_test.go | 128 +++++++ tests/cli/features_test.go | 45 +++ tests/codeguard/rule_stats_artifact_test.go | 44 +++ 28 files changed, 1472 insertions(+), 27 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-29-baseline-governance.md create mode 100644 internal/cli/baseline.go create mode 100644 internal/cli/baseline_audit.go create mode 100644 internal/cli/baseline_audit_test.go create mode 100644 internal/cli/baseline_io.go create mode 100644 internal/cli/baseline_io_test.go create mode 100644 internal/cli/baseline_policy.go create mode 100644 internal/cli/baseline_policy_test.go create mode 100644 internal/codeguard/config/baseline_governance_test.go create mode 100644 tests/cli/baseline_governance_test.go diff --git a/README.md b/README.md index 2e7dc486..6e0a7a3e 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,9 @@ codeguard rules codeguard profiles codeguard explain security.hardcoded-credential codeguard baseline -config codeguard.yaml -output codeguard-baseline.json +codeguard baseline audit -config codeguard.yaml -format json +codeguard baseline prune -config codeguard.yaml -check +codeguard baseline policy -config codeguard.yaml -compare-baseline /tmp/base-baseline.json ``` `codeguard rules` prints each rule's level, execution model, language coverage, section, and title. `codeguard explain ` includes the same metadata for a single rule. @@ -150,6 +153,42 @@ When a scan fails: - section names such as `Design Patterns`, `Security`, or `Code Quality` tell you what kind of action is expected. - rule IDs are stable handles for waivers, baselines, dashboards, and agent workflows. +### Baseline governance + +Baseline creation accepts all findings visible in that scan; it is not a cleanup +operation. Use `baseline audit` to classify an existing baseline without adding +findings, and `baseline prune -check` in CI to detect stale, invalid, or duplicate +entries. After review, `baseline prune -write` atomically removes stale entries; +`-output ` writes a candidate instead of replacing the source. + +An entry remains active when its exact fingerprint, line-shift-resilient context +fingerprint, or path-insensitive content fingerprint matches a current finding. +Identical snippets can legitimately collide on context or content fingerprints. +Audits report those collisions and preserve every matching entry; pruning does +not impose one-to-one matching or change scan suppression behavior. + +Opt-in governance rejects suppression growth and selected new rule families: + +```yaml +baseline: + path: codeguard-baseline.json + governance: + max_entries: 9771 + forbid_growth: true + require_no_stale_entries: true + prohibited_new_rule_prefixes: [security., defensive., error.] + sample_limit: 3 + ownership: + - pattern: "services/**" + owner: services +``` + +`baseline policy -compare-baseline` compares exact entries with a trusted base +branch baseline. Existing prohibited-family debt remains allowed; only additions +violate that policy. Use `scan -include-suppressed -format json` when a consumer +needs individual baseline, waiver, and inline suppression records. Default scan +output remains unchanged. + ## SDK Import the SDK from `github.com/devr-tools/codeguard/pkg/codeguard`. diff --git a/docs/production.md b/docs/production.md index 6a3f3670..cfeebff3 100644 --- a/docs/production.md +++ b/docs/production.md @@ -38,6 +38,24 @@ In production, `codeguard` should do three things well: codeguard baseline -config codeguard.yaml -output codeguard-baseline.json ``` + Creating a baseline accepts the scan's current findings. It must not be used + as a substitute for pruning. Audit and validate an existing baseline without + accepting new findings: + + ```bash + codeguard baseline audit -config codeguard.yaml -format json + codeguard baseline prune -config codeguard.yaml -check + codeguard baseline prune -config codeguard.yaml -write -output /tmp/candidate-baseline.json + codeguard baseline policy -config codeguard.yaml -compare-baseline /tmp/base-baseline.json + ``` + + Audit exits nonzero for scan/load failures. Prune check exits nonzero for + stale, invalid, or duplicate entries and never writes. Prune write removes + only stale entries, preserves fingerprint collisions, and refuses invalid + entries unless `-allow-invalid-entries` is explicitly supplied. Policy exits + nonzero for configured growth, maximum-entry, or prohibited-addition + violations. + Then reference it from config so new regressions still fail while existing debt stays visible but suppressed. diff --git a/docs/superpowers/plans/2026-08-29-baseline-governance.md b/docs/superpowers/plans/2026-08-29-baseline-governance.md new file mode 100644 index 00000000..d20ead0c --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-baseline-governance.md @@ -0,0 +1,114 @@ +# Baseline Governance Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add safe baseline auditing, pruning, suppression-level JSON reporting, governance policy enforcement, ownership/risk summaries, and deterministic false-positive review samples. + +**Architecture:** The runner will optionally retain suppressed findings with structured suppression metadata while preserving existing report defaults. Focused baseline-governance files in the existing `internal/cli` package will compare an existing baseline with a suppression-free full scan, classify entries through exact, context, or content fingerprints, report collisions without changing v1.7.3 suppression semantics, prune only stale entries, and compare baselines for policy enforcement without adding a new dependency on the central core package. The CLI will retain the existing `codeguard baseline` creation command while adding `audit`, `prune`, and `policy` subcommands. + +**Tech Stack:** Go 1.23+, standard-library JSON/file APIs, existing CodeGuard runner/config/report packages. + +**Spec:** User-approved baseline-governance prompt in the 2026-08-29 conversation, corrected to preserve many-to-many context/content fingerprint semantics. + +## Global Constraints + +- Existing baseline files and scan behavior remain compatible; governance is opt-in. +- Audit and prune run full scans with the configured baseline disabled and never add current findings. +- An entry is active if any exact, context, or content fingerprint matches a current finding. +- Collisions are reported and preserved; matching is not forced one-to-one. +- `prune --check` never writes; `prune --write` atomically removes only stale entries and refuses invalid entries unless explicitly overridden. +- JSON, text, SARIF, GitHub, and CycloneDX defaults remain unchanged unless suppressed findings are explicitly requested. +- Governance output and deterministic samples use stable ordering. + +--- + +### Task 1: Structured suppression reporting + +**Files:** +- Modify: `internal/codeguard/core/report_types.go` +- Modify: `internal/codeguard/core/diff_types.go` +- Modify: `internal/codeguard/runner/support/context.go` +- Modify: `internal/codeguard/runner/support/suppressions.go` +- Modify: `internal/codeguard/runner/support/findings_section.go` +- Modify: `internal/codeguard/runner/runner.go` +- Modify: `internal/cli/scan_flags.go` +- Test: `tests/checks/suppressed_findings_test.go` +- Test: `tests/cli/scan_suppressed_test.go` + +**Interfaces:** +- Produces: `Suppression{Kind, Match, BaselineFingerprint}`, `Report.SuppressedFindings`, and `ScanOptions.IncludeSuppressed`. +- Preserves: existing emitted `SectionResult.Findings`, summary counts, and default JSON shape. + +- [ ] Write a runner test proving baseline exact/context/content, waiver, and inline suppressions are individually distinguishable and reconcile with summary/rule statistics. +- [ ] Run the focused test and verify it fails because suppressed records are absent. +- [ ] Add structured suppression matching and opt-in collection with no default-output change. +- [ ] Run the focused runner tests and verify they pass. +- [ ] Write and fail a CLI test for `scan -include-suppressed -format json`. +- [ ] Wire the flag through scan options and verify the CLI test passes. + +### Task 2: Deterministic baseline audit and pruning engine + +**Files:** +- Create: `internal/cli/baseline_audit.go` +- Create: `internal/cli/baseline_io.go` +- Test: `internal/cli/baseline_audit_test.go` +- Test: `internal/cli/baseline_io_test.go` + +**Interfaces:** +- Consumes: `core.BaselineFile` and current `[]core.Finding`. +- Produces: `AuditResult` with active-exact/context/content, stale, invalid, duplicate, collision, rule, owner, risk, language, confidence, and deterministic sample data. +- Produces: `Prune(source, output string, audit AuditResult, allowInvalid bool) error` using atomic replacement. + +- [ ] Write table-driven failing tests for exact/context/content activation, moved findings, duplicate snippets, collisions, invalid entries, stable ordering, ownership/risk grouping, and deterministic samples. +- [ ] Implement the smallest audit engine that passes those tests while preserving all collision-matched entries. +- [ ] Write failing filesystem tests for check-mode immutability, stale-only pruning, output candidates, malformed JSON, and failed atomic replacement preserving the source. +- [ ] Implement strict loading and atomic deterministic writing, then run all baseline package tests. + +### Task 3: Governance configuration and policy comparison + +**Files:** +- Modify: `internal/codeguard/core/config_types.go` +- Modify: `internal/codeguard/config/validate.go` +- Create: `internal/cli/baseline_policy.go` +- Test: `internal/cli/baseline_policy_test.go` +- Test: `internal/codeguard/config/baseline_governance_test.go` + +**Interfaces:** +- Produces: opt-in `baseline.governance` fields for limits, stale checks, prohibited prefixes, ownership mappings, and sample limits. +- Produces: deterministic `ComparePolicy(current, comparison, governance)` with exact additions/removals and violations. + +- [ ] Write failing tests for invalid limits/mappings/prefixes and valid omitted governance. +- [ ] Add config types and validation, then verify config tests pass. +- [ ] Write failing policy tests for growth, prohibited high-risk additions, allowed existing debt, and deterministic diffs. +- [ ] Implement policy comparison and verify baseline package tests pass. + +### Task 4: Baseline audit, prune, and policy CLI + +**Files:** +- Modify: `internal/cli/commands.go` +- Create: `internal/cli/baseline.go` +- Test: `tests/cli/baseline_governance_test.go` + +**Interfaces:** +- Produces: `codeguard baseline audit`, `baseline prune --check|--write`, and `baseline policy -compare-baseline`. +- Preserves: legacy `codeguard baseline -config ... -output ...` creation. + +- [ ] Write failing end-to-end CLI tests for audit text/JSON, prune check/write/output, invalid refusal/override, policy failures, and legacy creation. +- [ ] Add subcommand parsing and a shared full-scan-with-baseline-disabled path. +- [ ] Render stable text/JSON, enforce exit codes, and verify all CLI tests pass. + +### Task 5: Documentation, compatibility, and version evidence + +**Files:** +- Modify: `README.md` +- Modify: `docs/production.md` +- Modify: `docs/features.md` +- Test: `tests/cli/version_test.go` + +**Interfaces:** +- Documents command distinctions, matching semantics, collision behavior, CI usage, review workflow, configuration, and exit codes. + +- [ ] Add a failing version test covering linker/build-info precedence and JSON report agreement with `codeguard version`. +- [ ] Fix version plumbing only if the test demonstrates a defect. +- [ ] Document create/audit/check/write/policy workflows and compatible suppression semantics. +- [ ] Run formatting, focused tests, full tests, vet/lint targets, and review the complete diff against every specification requirement. diff --git a/internal/cli/baseline.go b/internal/cli/baseline.go new file mode 100644 index 00000000..e21608f3 --- /dev/null +++ b/internal/cli/baseline.go @@ -0,0 +1,230 @@ +package cli + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" + service "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func runBaseline(args []string, stdout io.Writer, stderr io.Writer) int { + if len(args) == 0 { + return runBaselineCreate(args, stdout, stderr) + } + switch args[0] { + case "audit": + return runBaselineAudit(args[1:], stdout, stderr) + case "prune": + return runBaselinePrune(args[1:], stdout, stderr) + case "policy": + return runBaselinePolicy(args[1:], stdout, stderr) + default: + return runBaselineCreate(args, stdout, stderr) + } +} + +type governanceInputs struct { + cfg service.Config + baselinePath string + format string +} + +func parseGovernanceInputs(command string, args []string, stderr io.Writer) (governanceInputs, bool) { + fs := flag.NewFlagSet("baseline "+command, flag.ContinueOnError) + fs.SetOutput(stderr) + flags := registerScanRunFlags(fs) + baselinePath := fs.String("baseline", "", "existing baseline path (defaults to baseline.path from config)") + format := fs.String("format", "text", "output format: text or json") + if ok, _ := parseFlags(fs, args, stderr); !ok { + return governanceInputs{}, false + } + flags.applyTrustPolicy() + cfg, ok := loadConfigOrFail(*flags.configPath, *flags.profile, stderr) + if !ok { + return governanceInputs{}, false + } + if err := applyConfigOverrides(&cfg, *flags.overrides); err != nil { + _, _ = fmt.Fprintf(stderr, "invalid config override: %v\n", err) + return governanceInputs{}, false + } + path := strings.TrimSpace(*baselinePath) + if path == "" { + path = cfg.Baseline.Path + } + if path == "" { + _, _ = fmt.Fprintln(stderr, "baseline path is required through -baseline or baseline.path") + return governanceInputs{}, false + } + if *format != "text" && *format != "json" { + _, _ = fmt.Fprintln(stderr, "format must be text or json") + return governanceInputs{}, false + } + return governanceInputs{cfg: cfg, baselinePath: path, format: *format}, true +} + +// governanceFlagArgs parses command-specific flags without making flag order +// significant. The standard flag package stops at positional arguments, so +// each subcommand owns all of its flags directly instead of sharing a parent. +func runBaselineAudit(args []string, stdout io.Writer, stderr io.Writer) int { + inputs, ok := parseGovernanceInputs("audit", args, stderr) + if !ok { + return exitError + } + result, err := auditCurrentBaseline(inputs) + if err != nil { + _, _ = fmt.Fprintf(stderr, "baseline audit: %v\n", err) + return exitError + } + if err := writeAudit(stdout, result, inputs.format); err != nil { + _, _ = fmt.Fprintf(stderr, "write audit: %v\n", err) + return exitError + } + return exitOK +} + +func runBaselinePrune(args []string, stdout io.Writer, stderr io.Writer) int { + fs := flag.NewFlagSet("baseline prune", flag.ContinueOnError) + fs.SetOutput(stderr) + flags := registerScanRunFlags(fs) + baselinePath := fs.String("baseline", "", "existing baseline path") + format := fs.String("format", "text", "output format: text or json") + check := fs.Bool("check", false, "check for stale, invalid, or duplicate entries without writing") + write := fs.Bool("write", false, "write a stale-only pruned baseline") + output := fs.String("output", "", "candidate output path (defaults to replacing the source with -write)") + allowInvalid := fs.Bool("allow-invalid-entries", false, "preserve invalid entries during an explicitly reviewed write") + if ok, code := parseFlags(fs, args, stderr); !ok { + return code + } + if *check == *write { + _, _ = fmt.Fprintln(stderr, "exactly one of -check or -write is required") + return exitError + } + flags.applyTrustPolicy() + cfg, ok := loadConfigOrFail(*flags.configPath, *flags.profile, stderr) + if !ok { + return exitError + } + if err := applyConfigOverrides(&cfg, *flags.overrides); err != nil { + _, _ = fmt.Fprintf(stderr, "invalid config override: %v\n", err) + return exitError + } + path := strings.TrimSpace(*baselinePath) + if path == "" { + path = cfg.Baseline.Path + } + if path == "" { + _, _ = fmt.Fprintln(stderr, "baseline path is required") + return exitError + } + inputs := governanceInputs{cfg: cfg, baselinePath: path, format: *format} + result, err := auditCurrentBaseline(inputs) + if err != nil { + _, _ = fmt.Fprintf(stderr, "baseline prune: %v\n", err) + return exitError + } + if err := writeAudit(stdout, result, inputs.format); err != nil { + _, _ = fmt.Fprintf(stderr, "write audit: %v\n", err) + return exitError + } + if *check { + if result.Counts.Stale > 0 || result.Counts.Invalid > 0 || len(result.Duplicates) > 0 { + return exitError + } + return exitOK + } + if err := WritePruned(path, strings.TrimSpace(*output), result, PruneOptions{AllowInvalid: *allowInvalid}); err != nil { + _, _ = fmt.Fprintf(stderr, "write pruned baseline: %v\n", err) + return exitError + } + return exitOK +} + +func runBaselinePolicy(args []string, stdout io.Writer, stderr io.Writer) int { + fs := flag.NewFlagSet("baseline policy", flag.ContinueOnError) + fs.SetOutput(stderr) + configPath := fs.String("config", service.DefaultConfigPath(), "config file or directory path") + profile := fs.String("profile", "", "optional policy profile override") + baselinePath := fs.String("baseline", "", "current baseline path") + comparisonPath := fs.String("compare-baseline", "", "base-branch baseline path") + format := fs.String("format", "text", "output format: text or json") + if ok, code := parseFlags(fs, args, stderr); !ok { + return code + } + cfg, ok := loadConfigOrFail(*configPath, *profile, stderr) + if !ok { + return exitError + } + currentPath := strings.TrimSpace(*baselinePath) + if currentPath == "" { + currentPath = cfg.Baseline.Path + } + if currentPath == "" || strings.TrimSpace(*comparisonPath) == "" { + _, _ = fmt.Fprintln(stderr, "-baseline/baseline.path and -compare-baseline are required") + return exitError + } + current, err := Load(currentPath) + if err != nil { + _, _ = fmt.Fprintf(stderr, "load current baseline: %v\n", err) + return exitError + } + comparison, err := Load(*comparisonPath) + if err != nil { + _, _ = fmt.Fprintf(stderr, "load comparison baseline: %v\n", err) + return exitError + } + result := ComparePolicy(current, comparison, cfg.Baseline.Governance) + if *format == "json" { + err = writeJSONValue(stdout, result) + } else { + _, err = fmt.Fprintf(stdout, "added=%d removed=%d violations=%d\n", len(result.Added), len(result.Removed), len(result.Violations)) + } + if err != nil { + _, _ = fmt.Fprintf(stderr, "write policy: %v\n", err) + return exitError + } + if len(result.Violations) > 0 { + return exitError + } + return exitOK +} + +func auditCurrentBaseline(inputs governanceInputs) (AuditResult, error) { + file, err := Load(inputs.baselinePath) + if err != nil { + return AuditResult{}, err + } + cfg := inputs.cfg + cfg.Baseline.Path = "" + report, err := service.RunWithOptions(context.Background(), cfg, service.ScanOptions{Mode: service.ScanModeFull, IncludeSuppressed: true}) + if err != nil { + return AuditResult{}, err + } + findings := append([]core.Finding(nil), report.SuppressedFindings...) + for _, section := range report.Sections { + findings = append(findings, section.Findings...) + } + ownership := make([]OwnershipMapping, 0, len(cfg.Baseline.Governance.Ownership)) + for _, mapping := range cfg.Baseline.Governance.Ownership { + ownership = append(ownership, OwnershipMapping{Pattern: mapping.Pattern, Owner: mapping.Owner}) + } + return Audit(file, findings, Options{SampleLimit: cfg.Baseline.Governance.SampleLimit, Ownership: ownership}), nil +} + +func writeAudit(w io.Writer, result AuditResult, format string) error { + if format == "json" { + return writeJSONValue(w, result) + } + _, err := fmt.Fprintf(w, "before=%d active=%d active_exact=%d active_context=%d active_content=%d removed=%d final=%d invalid=%d collisions=%d duplicates=%d\n", result.Counts.Before, result.Counts.Active, result.Counts.ActiveExact, result.Counts.ActiveContext, result.Counts.ActiveContent, result.Counts.Removed, result.Counts.Final, result.Counts.Invalid, len(result.Collisions), len(result.Duplicates)) + return err +} + +func writeJSONValue(w io.Writer, value any) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} diff --git a/internal/cli/baseline_audit.go b/internal/cli/baseline_audit.go new file mode 100644 index 00000000..640b2053 --- /dev/null +++ b/internal/cli/baseline_audit.go @@ -0,0 +1,325 @@ +package cli + +import ( + "path/filepath" + "sort" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +type Options struct { + SampleLimit int + Ownership []OwnershipMapping +} + +type OwnershipMapping struct { + Pattern string `json:"pattern" yaml:"pattern"` + Owner string `json:"owner" yaml:"owner"` +} + +type Counts struct { + Before int `json:"before"` + Active int `json:"active"` + ActiveExact int `json:"active_exact"` + ActiveContext int `json:"active_context"` + ActiveContent int `json:"active_content"` + Stale int `json:"stale"` + Removed int `json:"removed"` + Final int `json:"final"` + Invalid int `json:"invalid"` +} + +type EntryAudit struct { + Entry core.BaselineEntry `json:"entry"` + Status string `json:"status"` + Matches []FindingRef `json:"matches,omitempty"` +} + +type FindingRef struct { + RuleID string `json:"rule_id"` + Path string `json:"path,omitempty"` + Line int `json:"line,omitempty"` + Message string `json:"message,omitempty"` + Confidence string `json:"confidence,omitempty"` + Language string `json:"language,omitempty"` +} + +type Collision struct { + Kind string `json:"kind"` + Fingerprint string `json:"fingerprint"` + BaselineEntries int `json:"baseline_entries"` + CurrentFindings int `json:"current_findings"` +} + +type Duplicate struct { + Kind string `json:"kind"` + Fingerprint string `json:"fingerprint"` + Count int `json:"count"` +} + +type Group struct { + Name string `json:"name"` + Count int `json:"count"` + Samples []FindingRef `json:"samples,omitempty"` + Confidence []NamedCount `json:"confidence_distribution,omitempty"` + Languages []NamedCount `json:"language_distribution,omitempty"` +} + +type NamedCount struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type AuditResult struct { + Counts Counts `json:"counts"` + Entries []EntryAudit `json:"entries"` + Collisions []Collision `json:"collisions,omitempty"` + Duplicates []Duplicate `json:"duplicates,omitempty"` + ByRule []Group `json:"by_rule"` + ByOwner []Group `json:"by_owner"` + ByRisk []Group `json:"by_risk"` +} + +func Audit(file core.BaselineFile, findings []core.Finding, opts Options) AuditResult { + result := AuditResult{Counts: Counts{Before: len(file.Entries)}} + exactCurrent := indexFindings(findings, func(f core.Finding) string { return f.Fingerprint }) + contextCurrent := indexFindings(findings, func(f core.Finding) string { return f.ContextFingerprint }) + contentCurrent := indexFindings(findings, func(f core.Finding) string { return f.ContentFingerprint }) + + for _, entry := range file.Entries { + audit := EntryAudit{Entry: entry} + switch { + case strings.TrimSpace(entry.Fingerprint) == "": + audit.Status = "invalid" + result.Counts.Invalid++ + case len(exactCurrent[entry.Fingerprint]) > 0: + audit.Status = "active_exact" + audit.Matches = refs(exactCurrent[entry.Fingerprint]) + result.Counts.ActiveExact++ + case entry.ContextFingerprint != "" && len(contextCurrent[entry.ContextFingerprint]) > 0: + audit.Status = "active_context" + audit.Matches = refs(contextCurrent[entry.ContextFingerprint]) + result.Counts.ActiveContext++ + case entry.ContentFingerprint != "" && len(contentCurrent[entry.ContentFingerprint]) > 0: + audit.Status = "active_content" + audit.Matches = refs(contentCurrent[entry.ContentFingerprint]) + result.Counts.ActiveContent++ + default: + audit.Status = "stale" + result.Counts.Stale++ + } + result.Entries = append(result.Entries, audit) + } + sort.Slice(result.Entries, func(i, j int) bool { return entryKey(result.Entries[i].Entry) < entryKey(result.Entries[j].Entry) }) + result.Duplicates, result.Collisions = fingerprintDiagnostics(file.Entries, exactCurrent, contextCurrent, contentCurrent) + result.Counts.Active = result.Counts.ActiveExact + result.Counts.ActiveContext + result.Counts.ActiveContent + result.Counts.Removed = result.Counts.Stale + result.Counts.Final = result.Counts.Active + result.ByRule = groupActive(result.Entries, opts, func(e core.BaselineEntry) string { return fallback(e.RuleID, "unknown") }) + result.ByOwner = groupActive(result.Entries, opts, func(e core.BaselineEntry) string { return ownerFor(e.Path, opts.Ownership) }) + result.ByRisk = groupActive(result.Entries, opts, func(e core.BaselineEntry) string { return riskFamily(e.RuleID) }) + sort.SliceStable(result.ByRisk, func(i, j int) bool { return riskRank(result.ByRisk[i].Name) < riskRank(result.ByRisk[j].Name) }) + return result +} + +func (result AuditResult) ActiveEntries() []core.BaselineEntry { + return entriesWithStatus(result.Entries, "active_") +} + +func (result AuditResult) PrunableEntries() []core.BaselineEntry { + return entriesWithStatus(result.Entries, "stale") +} + +func entriesWithStatus(entries []EntryAudit, prefix string) []core.BaselineEntry { + out := make([]core.BaselineEntry, 0) + for _, entry := range entries { + if strings.HasPrefix(entry.Status, prefix) { + out = append(out, entry.Entry) + } + } + return out +} + +func indexFindings(findings []core.Finding, key func(core.Finding) string) map[string][]core.Finding { + out := map[string][]core.Finding{} + for _, finding := range findings { + if value := key(finding); value != "" { + out[value] = append(out[value], finding) + } + } + for value := range out { + sortFindings(out[value]) + } + return out +} + +func refs(findings []core.Finding) []FindingRef { + out := make([]FindingRef, 0, len(findings)) + for _, f := range findings { + out = append(out, FindingRef{RuleID: f.RuleID, Path: f.Path, Line: f.Line, Message: f.Message, Confidence: f.Confidence, Language: languageFor(f.Path)}) + } + return out +} + +func sortFindings(findings []core.Finding) { + sort.Slice(findings, func(i, j int) bool { + if findings[i].Path != findings[j].Path { + return findings[i].Path < findings[j].Path + } + if findings[i].Line != findings[j].Line { + return findings[i].Line < findings[j].Line + } + return findings[i].Fingerprint < findings[j].Fingerprint + }) +} + +func fingerprintDiagnostics(entries []core.BaselineEntry, indexes ...map[string][]core.Finding) ([]Duplicate, []Collision) { + types := []string{"exact", "context", "content"} + baselineIndexes := []map[string]int{{}, {}, {}} + for _, e := range entries { + for idx, value := range []string{e.Fingerprint, e.ContextFingerprint, e.ContentFingerprint} { + if value != "" { + baselineIndexes[idx][value]++ + } + } + } + var duplicates []Duplicate + var collisions []Collision + for idx, baselineIndex := range baselineIndexes { + for fingerprint, count := range baselineIndex { + if count > 1 { + duplicates = append(duplicates, Duplicate{Kind: types[idx], Fingerprint: fingerprint, Count: count}) + } + currentCount := len(indexes[idx][fingerprint]) + if currentCount > 0 && (count > 1 || currentCount > 1) { + collisions = append(collisions, Collision{Kind: types[idx], Fingerprint: fingerprint, BaselineEntries: count, CurrentFindings: currentCount}) + } + } + } + sort.Slice(duplicates, func(i, j int) bool { + return duplicates[i].Kind+duplicates[i].Fingerprint < duplicates[j].Kind+duplicates[j].Fingerprint + }) + sort.Slice(collisions, func(i, j int) bool { + return collisions[i].Kind+collisions[i].Fingerprint < collisions[j].Kind+collisions[j].Fingerprint + }) + return duplicates, collisions +} + +func groupActive(entries []EntryAudit, opts Options, name func(core.BaselineEntry) string) []Group { + type accumulator struct { + count int + samples []FindingRef + } + groups := map[string]*accumulator{} + for _, audited := range entries { + if !strings.HasPrefix(audited.Status, "active_") { + continue + } + key := name(audited.Entry) + if groups[key] == nil { + groups[key] = &accumulator{} + } + groups[key].count++ + groups[key].samples = append(groups[key].samples, audited.Matches...) + } + out := make([]Group, 0, len(groups)) + for name, group := range groups { + sort.Slice(group.samples, func(i, j int) bool { return sampleKey(group.samples[i]) < sampleKey(group.samples[j]) }) + confidence := distribution(group.samples, func(sample FindingRef) string { return fallback(sample.Confidence, "unknown") }) + languages := distribution(group.samples, func(sample FindingRef) string { return fallback(sample.Language, "unknown") }) + limit := opts.SampleLimit + if limit <= 0 { + limit = 3 + } + if len(group.samples) > limit { + group.samples = group.samples[:limit] + } + out = append(out, Group{Name: name, Count: group.count, Samples: group.samples, Confidence: confidence, Languages: languages}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func distribution(samples []FindingRef, name func(FindingRef) string) []NamedCount { + counts := map[string]int{} + for _, sample := range samples { + counts[name(sample)]++ + } + out := make([]NamedCount, 0, len(counts)) + for name, count := range counts { + out = append(out, NamedCount{Name: name, Count: count}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func ownerFor(path string, mappings []OwnershipMapping) string { + path = filepath.ToSlash(path) + for _, mapping := range mappings { + if matchGlob(mapping.Pattern, path) { + return mapping.Owner + } + } + if idx := strings.IndexByte(path, '/'); idx >= 0 { + return path[:idx] + } + return fallback(path, "root") +} + +func matchGlob(pattern, path string) bool { + pattern = filepath.ToSlash(pattern) + if strings.HasSuffix(pattern, "/**") { + return strings.HasPrefix(path, strings.TrimSuffix(pattern, "**")) + } + matched, _ := filepath.Match(pattern, path) + return matched +} + +func riskFamily(rule string) string { + switch { + case strings.HasPrefix(rule, "security."): + return "security" + case strings.HasPrefix(rule, "defensive."): + return "boundary" + case strings.HasPrefix(rule, "error."): + return "error-handling" + case hasAnyPrefix(rule, "quality.", "function.", "smell.", "naming."): + return "structural-quality" + default: + return "other" + } +} + +func riskRank(name string) int { + for idx, value := range []string{"security", "boundary", "error-handling", "structural-quality", "other"} { + if name == value { + return idx + } + } + return 99 +} +func hasAnyPrefix(value string, prefixes ...string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(value, prefix) { + return true + } + } + return false +} +func languageFor(path string) string { + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".") + return fallback(ext, "unknown") +} +func fallback(value, fallbackValue string) string { + if strings.TrimSpace(value) == "" { + return fallbackValue + } + return value +} +func entryKey(e core.BaselineEntry) string { + return strings.Join([]string{e.Fingerprint, e.ContextFingerprint, e.ContentFingerprint, e.RuleID, e.Path, e.Message}, "\x00") +} +func sampleKey(s FindingRef) string { + return strings.Join([]string{s.Path, s.RuleID, s.Message}, "\x00") +} diff --git a/internal/cli/baseline_audit_test.go b/internal/cli/baseline_audit_test.go new file mode 100644 index 00000000..836ee451 --- /dev/null +++ b/internal/cli/baseline_audit_test.go @@ -0,0 +1,69 @@ +package cli + +import ( + "encoding/json" + "testing" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func TestAuditPreservesEveryEntryMatchingAnySupportedFingerprint(t *testing.T) { + file := core.BaselineFile{Entries: []core.BaselineEntry{ + {Fingerprint: "exact", ContextFingerprint: "ctx-a", RuleID: "security.secret", Path: "services/a.go"}, + {Fingerprint: "old-line", ContextFingerprint: "shared-context", RuleID: "defensive.input", Path: "domains/a.go"}, + {Fingerprint: "old-twin", ContextFingerprint: "shared-context", RuleID: "defensive.input", Path: "domains/a.go"}, + {Fingerprint: "old-path", ContentFingerprint: "shared-content", RuleID: "error.context", Path: "platform/old.go"}, + {Fingerprint: "stale", RuleID: "quality.dead-code", Path: "legacy/a.go"}, + {Fingerprint: "", RuleID: "quality.invalid", Path: "bad.go"}, + }} + findings := []core.Finding{ + {Fingerprint: "exact", ContextFingerprint: "different", RuleID: "security.secret", Path: "services/a.go", Line: 2}, + {Fingerprint: "new-line-1", ContextFingerprint: "shared-context", RuleID: "defensive.input", Path: "domains/a.go", Line: 10}, + {Fingerprint: "new-line-2", ContextFingerprint: "shared-context", RuleID: "defensive.input", Path: "domains/a.go", Line: 20}, + {Fingerprint: "new-path", ContentFingerprint: "shared-content", RuleID: "error.context", Path: "platform/new.go", Line: 4}, + } + + result := Audit(file, findings, Options{SampleLimit: 2}) + if result.Counts.ActiveExact != 1 || result.Counts.ActiveContext != 2 || result.Counts.ActiveContent != 1 || result.Counts.Stale != 1 || result.Counts.Invalid != 1 { + t.Fatalf("counts = %#v", result.Counts) + } + if len(result.Collisions) == 0 { + t.Fatal("expected shared-context collision to be reported") + } + if got := len(result.ActiveEntries()); got != 4 { + t.Fatalf("active entries = %d, want 4", got) + } + if got := len(result.PrunableEntries()); got != 1 || got == len(result.ActiveEntries()) { + t.Fatalf("prunable entries = %d, want only stale entry", got) + } +} + +func TestAuditOutputIsDeterministicAndHighRiskFirst(t *testing.T) { + file := core.BaselineFile{Entries: []core.BaselineEntry{ + {Fingerprint: "q", RuleID: "quality.mutable-global", Path: "platform/z.go", Message: "quality"}, + {Fingerprint: "e", RuleID: "error.lost-context", Path: "services/b.go", Message: "error"}, + {Fingerprint: "s", RuleID: "security.secret", Path: "domains/a.go", Message: "security"}, + }} + findings := []core.Finding{ + {Fingerprint: "q", RuleID: "quality.mutable-global", Path: "platform/z.go", Line: 3, Confidence: "low", Message: "quality"}, + {Fingerprint: "e", RuleID: "error.lost-context", Path: "services/b.go", Line: 2, Confidence: "medium", Message: "error"}, + {Fingerprint: "s", RuleID: "security.secret", Path: "domains/a.go", Line: 1, Confidence: "high", Message: "security"}, + } + + first := Audit(file, findings, Options{SampleLimit: 1}) + second := Audit(core.BaselineFile{Entries: []core.BaselineEntry{file.Entries[2], file.Entries[0], file.Entries[1]}}, []core.Finding{findings[1], findings[2], findings[0]}, Options{SampleLimit: 1}) + a, _ := json.Marshal(first) + b, _ := json.Marshal(second) + if string(a) != string(b) { + t.Fatalf("audit output changed with input order:\n%s\n%s", a, b) + } + if len(first.ByRisk) < 3 || first.ByRisk[0].Name != "security" || first.ByRisk[1].Name != "error-handling" || first.ByRisk[2].Name != "structural-quality" { + t.Fatalf("risk ordering = %#v", first.ByRisk) + } + if len(first.ByOwner) != 3 || first.ByOwner[0].Name != "domains" { + t.Fatalf("owner grouping = %#v", first.ByOwner) + } + if len(first.ByRule[0].Confidence) == 0 || len(first.ByRule[0].Languages) == 0 { + t.Fatalf("rule distributions missing: %#v", first.ByRule[0]) + } +} diff --git a/internal/cli/baseline_io.go b/internal/cli/baseline_io.go new file mode 100644 index 00000000..daf1021d --- /dev/null +++ b/internal/cli/baseline_io.go @@ -0,0 +1,79 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +type PruneOptions struct{ AllowInvalid bool } + +func Load(path string) (core.BaselineFile, error) { + data, err := os.ReadFile(path) //nolint:gosec // operator-supplied baseline path + if err != nil { + return core.BaselineFile{}, err + } + var file core.BaselineFile + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&file); err != nil { + return core.BaselineFile{}, fmt.Errorf("decode baseline: %w", err) + } + return file, nil +} + +func WritePruned(source, output string, result AuditResult, opts PruneOptions) error { + if result.Counts.Invalid > 0 && !opts.AllowInvalid { + return errors.New("baseline contains invalid entries; use the explicit invalid-entry override after review") + } + entries := result.ActiveEntries() + if opts.AllowInvalid { + for _, audited := range result.Entries { + if audited.Status == "invalid" { + entries = append(entries, audited.Entry) + } + } + } + sort.Slice(entries, func(i, j int) bool { return entryKey(entries[i]) < entryKey(entries[j]) }) + file := core.BaselineFile{GeneratedAt: time.Now().UTC().Format(time.RFC3339), Entries: entries} + data, err := json.MarshalIndent(file, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + if output == "" { + output = source + } + if err := os.MkdirAll(filepath.Dir(output), 0o750); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(output), ".codeguard-baseline-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, output) +} diff --git a/internal/cli/baseline_io_test.go b/internal/cli/baseline_io_test.go new file mode 100644 index 00000000..e42da0a4 --- /dev/null +++ b/internal/cli/baseline_io_test.go @@ -0,0 +1,81 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func TestPruneWritesOnlyActiveEntriesAndNeverAddsFindings(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "baseline.json") + output := filepath.Join(dir, "candidate.json") + original := core.BaselineFile{GeneratedAt: "old", Entries: []core.BaselineEntry{ + {Fingerprint: "active", RuleID: "quality.a"}, + {Fingerprint: "stale", RuleID: "quality.b"}, + }} + writeFixture(t, source, original) + result := Audit(original, []core.Finding{{Fingerprint: "active", RuleID: "quality.a"}, {Fingerprint: "new", RuleID: "security.new"}}, Options{}) + + if err := WritePruned(source, output, result, PruneOptions{}); err != nil { + t.Fatalf("WritePruned: %v", err) + } + got, err := Load(output) + if err != nil { + t.Fatal(err) + } + if len(got.Entries) != 1 || got.Entries[0].Fingerprint != "active" { + t.Fatalf("pruned entries = %#v", got.Entries) + } + sourceAfter, _ := os.ReadFile(source) + var unchanged core.BaselineFile + if err := json.Unmarshal(sourceAfter, &unchanged); err != nil || len(unchanged.Entries) != 2 { + t.Fatalf("source was modified: %s err=%v", sourceAfter, err) + } +} + +func TestWritePrunedRefusesInvalidEntries(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "baseline.json") + file := core.BaselineFile{Entries: []core.BaselineEntry{{Fingerprint: ""}}} + writeFixture(t, source, file) + result := Audit(file, nil, Options{}) + if err := WritePruned(source, source, result, PruneOptions{}); err == nil { + t.Fatal("expected invalid baseline refusal") + } +} + +func TestWritePrunedPreservesAllEntriesInAContextCollision(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "baseline.json") + file := core.BaselineFile{Entries: []core.BaselineEntry{ + {Fingerprint: "old-a", ContextFingerprint: "shared", RuleID: "quality.duplicate"}, + {Fingerprint: "old-b", ContextFingerprint: "shared", RuleID: "quality.duplicate"}, + }} + writeFixture(t, path, file) + result := Audit(file, []core.Finding{{Fingerprint: "current", ContextFingerprint: "shared", RuleID: "quality.duplicate"}}, Options{}) + if err := WritePruned(path, path, result, PruneOptions{}); err != nil { + t.Fatal(err) + } + got, err := Load(path) + if err != nil { + t.Fatal(err) + } + if len(got.Entries) != 2 { + t.Fatalf("collision entries = %#v", got.Entries) + } +} + +func writeFixture(t *testing.T, path string, file core.BaselineFile) { + t.Helper() + data, err := json.Marshal(file) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/cli/baseline_policy.go b/internal/cli/baseline_policy.go new file mode 100644 index 00000000..9bc48b16 --- /dev/null +++ b/internal/cli/baseline_policy.go @@ -0,0 +1,72 @@ +package cli + +import ( + "sort" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +type PolicyViolation struct { + Kind string `json:"kind"` + Message string `json:"message"` + Entry *core.BaselineEntry `json:"entry,omitempty"` +} + +type PolicyResult struct { + Added []core.BaselineEntry `json:"added"` + Removed []core.BaselineEntry `json:"removed"` + Violations []PolicyViolation `json:"violations"` +} + +func ComparePolicy(current, comparison core.BaselineFile, policy core.BaselineGovernanceConfig) PolicyResult { + result := PolicyResult{ + Added: multisetDifference(current.Entries, comparison.Entries), + Removed: multisetDifference(comparison.Entries, current.Entries), + } + if policy.MaxEntries > 0 && len(current.Entries) > policy.MaxEntries { + result.Violations = append(result.Violations, PolicyViolation{Kind: "max_entries", Message: "baseline exceeds configured maximum"}) + } + if policy.ForbidGrowth && len(current.Entries) > len(comparison.Entries) { + result.Violations = append(result.Violations, PolicyViolation{Kind: "growth", Message: "baseline entry count increased"}) + } + for idx := range result.Added { + entry := &result.Added[idx] + for _, prefix := range policy.ProhibitedNewRulePrefixes { + if strings.HasPrefix(entry.RuleID, prefix) { + result.Violations = append(result.Violations, PolicyViolation{Kind: "prohibited_rule", Message: "new baseline entry belongs to a prohibited rule family", Entry: entry}) + break + } + } + } + sort.Slice(result.Violations, func(i, j int) bool { + left, right := result.Violations[i], result.Violations[j] + leftKey, rightKey := left.Kind, right.Kind + if left.Entry != nil { + leftKey += entryKey(*left.Entry) + } + if right.Entry != nil { + rightKey += entryKey(*right.Entry) + } + return leftKey < rightKey + }) + return result +} + +func multisetDifference(left, right []core.BaselineEntry) []core.BaselineEntry { + counts := map[string]int{} + for _, entry := range right { + counts[entryKey(entry)]++ + } + out := make([]core.BaselineEntry, 0) + for _, entry := range left { + key := entryKey(entry) + if counts[key] > 0 { + counts[key]-- + continue + } + out = append(out, entry) + } + sort.Slice(out, func(i, j int) bool { return entryKey(out[i]) < entryKey(out[j]) }) + return out +} diff --git a/internal/cli/baseline_policy_test.go b/internal/cli/baseline_policy_test.go new file mode 100644 index 00000000..c5d43f10 --- /dev/null +++ b/internal/cli/baseline_policy_test.go @@ -0,0 +1,41 @@ +package cli + +import ( + "testing" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func TestComparePolicyRejectsGrowthAndNewHighRiskEntries(t *testing.T) { + comparison := core.BaselineFile{Entries: []core.BaselineEntry{ + {Fingerprint: "existing-security", RuleID: "security.secret"}, + {Fingerprint: "old-quality", RuleID: "quality.old"}, + }} + current := core.BaselineFile{Entries: []core.BaselineEntry{ + {Fingerprint: "existing-security", RuleID: "security.secret"}, + {Fingerprint: "new-security", RuleID: "security.injection"}, + {Fingerprint: "new-quality", RuleID: "quality.new"}, + }} + policy := core.BaselineGovernanceConfig{ForbidGrowth: true, ProhibitedNewRulePrefixes: []string{"security.", "defensive.", "error."}} + + result := ComparePolicy(current, comparison, policy) + if len(result.Added) != 2 || len(result.Removed) != 1 { + t.Fatalf("diff = added %#v removed %#v", result.Added, result.Removed) + } + if len(result.Violations) != 2 { + t.Fatalf("violations = %#v, want growth and prohibited addition", result.Violations) + } + for _, violation := range result.Violations { + if violation.Entry != nil && violation.Entry.Fingerprint == "existing-security" { + t.Fatal("existing approved high-risk entry was rejected") + } + } +} + +func TestComparePolicyHonorsMaximumWithoutComparison(t *testing.T) { + current := core.BaselineFile{Entries: []core.BaselineEntry{{Fingerprint: "a"}, {Fingerprint: "b"}}} + result := ComparePolicy(current, core.BaselineFile{}, core.BaselineGovernanceConfig{MaxEntries: 1}) + if len(result.Violations) != 1 || result.Violations[0].Kind != "max_entries" { + t.Fatalf("violations = %#v", result.Violations) + } +} diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 427c5e11..5b0f62a8 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -78,6 +78,7 @@ func runScan(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) folderPath := fs.String("folder", "", "folder path to scan instead of all configured targets") pathAlias := fs.String("path", "", "alias for -folder") enableAI := fs.Bool("ai", false, "enable optional AI-assisted analysis") + includeSuppressed := fs.Bool("include-suppressed", false, "include individual suppressed findings in JSON output") interactive := fs.Bool("interactive", false, "prompt for scan inputs in the terminal") if ok, code := parseFlags(fs, args, stderr); !ok { return code @@ -115,7 +116,7 @@ func runScan(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) return exitError } - if err := executeScan(stdout, cfg, scanMode, strings.TrimSpace(*inputs.baseRef), targetPath, *enableAI); err != nil { + if err := executeScan(stdout, cfg, scanMode, strings.TrimSpace(*inputs.baseRef), targetPath, *enableAI, *includeSuppressed); err != nil { _, _ = fmt.Fprintf(stderr, "scan failed: %v\n", err) return exitError } @@ -177,7 +178,7 @@ func runValidatePatch(args []string, stdin io.Reader, stdout io.Writer, stderr i return exitOK } -func runBaseline(args []string, stdout io.Writer, stderr io.Writer) int { +func runBaselineCreate(args []string, stdout io.Writer, stderr io.Writer) int { fs := flag.NewFlagSet("baseline", flag.ContinueOnError) fs.SetOutput(stderr) flags := registerScanRunFlags(fs) diff --git a/internal/cli/commands_scan_helpers.go b/internal/cli/commands_scan_helpers.go index cdd6a5af..578ee4e2 100644 --- a/internal/cli/commands_scan_helpers.go +++ b/internal/cli/commands_scan_helpers.go @@ -58,12 +58,13 @@ func parseScanMode(mode string) (service.ScanMode, error) { return scanMode, nil } -func executeScan(stdout io.Writer, cfg service.Config, scanMode service.ScanMode, baseRef string, targetPath string, enableAI bool) error { +func executeScan(stdout io.Writer, cfg service.Config, scanMode service.ScanMode, baseRef string, targetPath string, enableAI bool, includeSuppressed bool) error { report, err := service.RunWithOptions(context.Background(), cfg, service.ScanOptions{ - Mode: scanMode, - BaseRef: baseRef, - TargetPath: targetPath, - EnableAI: enableAI, + Mode: scanMode, + BaseRef: baseRef, + TargetPath: targetPath, + EnableAI: enableAI, + IncludeSuppressed: includeSuppressed, }) if err != nil { return err diff --git a/internal/cli/menu.go b/internal/cli/menu.go index 7888e19a..1309d717 100644 --- a/internal/cli/menu.go +++ b/internal/cli/menu.go @@ -36,7 +36,7 @@ var menuGroups = []menuGroup{ {"scan", "Scan the working tree or a diff for violations"}, {"scan-history", "Scan git history for committed secrets"}, {"validate-patch", "Scan a unified diff piped on stdin"}, - {"baseline", "Record current findings as an accepted baseline"}, + {"baseline", "Create, audit, prune, and govern accepted findings"}, }, }, { diff --git a/internal/codeguard/config/baseline_governance_test.go b/internal/codeguard/config/baseline_governance_test.go new file mode 100644 index 00000000..4a2fcd8f --- /dev/null +++ b/internal/codeguard/config/baseline_governance_test.go @@ -0,0 +1,35 @@ +package config + +import ( + "strings" + "testing" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func TestValidateBaselineGovernanceRejectsInvalidValues(t *testing.T) { + cfg := ExampleConfig() + cfg.Baseline.Governance.MaxEntries = -1 + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "max_entries") { + t.Fatalf("Validate error = %v", err) + } + + cfg = ExampleConfig() + cfg.Baseline.Governance.Ownership = []core.BaselineOwnershipConfig{{Pattern: "services/**", Owner: ""}} + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "owner") { + t.Fatalf("Validate error = %v", err) + } +} + +func TestValidateBaselineGovernanceAllowsOmittedAndValidPolicy(t *testing.T) { + for _, governance := range []core.BaselineGovernanceConfig{ + {}, + {MaxEntries: 10, ForbidGrowth: true, RequireNoStaleEntries: true, ProhibitedNewRulePrefixes: []string{"security."}, SampleLimit: 3, Ownership: []core.BaselineOwnershipConfig{{Pattern: "services/**", Owner: "services"}}}, + } { + cfg := ExampleConfig() + cfg.Baseline.Governance = governance + if err := Validate(cfg); err != nil { + t.Fatalf("Validate(%#v): %v", governance, err) + } + } +} diff --git a/internal/codeguard/config/validate.go b/internal/codeguard/config/validate.go index 1a3c7f60..9e0066b0 100644 --- a/internal/codeguard/config/validate.go +++ b/internal/codeguard/config/validate.go @@ -17,6 +17,7 @@ func Validate(cfg core.Config) error { validateTargets(cfg.Targets), validateOutput(cfg.Output), validateWaivers(cfg.Waivers), + validateBaselineGovernance(cfg.Baseline.Governance), validateCommandChecks(cfg), validateAIConfig(cfg.AI), validateAIProvenance(cfg.Checks.QualityRules.AIProvenance), @@ -48,6 +49,29 @@ func Validate(cfg core.Config) error { ) } +func validateBaselineGovernance(cfg core.BaselineGovernanceConfig) error { + if cfg.MaxEntries < 0 { + return errors.New("baseline.governance.max_entries must not be negative") + } + if cfg.SampleLimit < 0 { + return errors.New("baseline.governance.sample_limit must not be negative") + } + for idx, prefix := range cfg.ProhibitedNewRulePrefixes { + if strings.TrimSpace(prefix) == "" { + return fmt.Errorf("baseline.governance.prohibited_new_rule_prefixes[%d] must not be blank", idx) + } + } + for idx, mapping := range cfg.Ownership { + if strings.TrimSpace(mapping.Pattern) == "" { + return fmt.Errorf("baseline.governance.ownership[%d].pattern is required", idx) + } + if strings.TrimSpace(mapping.Owner) == "" { + return fmt.Errorf("baseline.governance.ownership[%d].owner is required", idx) + } + } + return nil +} + func validateQualityNaming(cfg core.QualityNamingConfig) error { if cfg.RoleSuffixWarnThreshold < 0 { return fmt.Errorf("quality_rules.naming.role_suffix_warn_threshold must not be negative, got %d", cfg.RoleSuffixWarnThreshold) diff --git a/internal/codeguard/core/config_types.go b/internal/codeguard/core/config_types.go index 97c406a6..dadbff09 100644 --- a/internal/codeguard/core/config_types.go +++ b/internal/codeguard/core/config_types.go @@ -138,7 +138,22 @@ type CacheConfig struct { } type BaselineConfig struct { - Path string `json:"path,omitempty" yaml:"path,omitempty"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` + Governance BaselineGovernanceConfig `json:"governance,omitempty" yaml:"governance,omitempty"` +} + +type BaselineGovernanceConfig struct { + MaxEntries int `json:"max_entries,omitempty" yaml:"max_entries,omitempty"` + ForbidGrowth bool `json:"forbid_growth,omitempty" yaml:"forbid_growth,omitempty"` + RequireNoStaleEntries bool `json:"require_no_stale_entries,omitempty" yaml:"require_no_stale_entries,omitempty"` + ProhibitedNewRulePrefixes []string `json:"prohibited_new_rule_prefixes,omitempty" yaml:"prohibited_new_rule_prefixes,omitempty"` + Ownership []BaselineOwnershipConfig `json:"ownership,omitempty" yaml:"ownership,omitempty"` + SampleLimit int `json:"sample_limit,omitempty" yaml:"sample_limit,omitempty"` +} + +type BaselineOwnershipConfig struct { + Pattern string `json:"pattern" yaml:"pattern"` + Owner string `json:"owner" yaml:"owner"` } type WaiverConfig struct { diff --git a/internal/codeguard/core/report_types.go b/internal/codeguard/core/report_types.go index b7cdb0a4..c9b9e25e 100644 --- a/internal/codeguard/core/report_types.go +++ b/internal/codeguard/core/report_types.go @@ -35,6 +35,9 @@ type Report struct { Sections []SectionResult `json:"sections"` Artifacts []Artifact `json:"artifacts,omitempty"` Summary ReportSummary `json:"summary"` + // SuppressedFindings is populated only when ScanOptions.IncludeSuppressed + // is enabled, keeping existing report payloads unchanged by default. + SuppressedFindings []Finding `json:"suppressed_findings,omitempty"` } type SectionResult struct { @@ -66,14 +69,21 @@ type Finding struct { ContextFingerprint string `json:"context_fingerprint,omitempty"` // ContentFingerprint hashes the rule and normalized source context without // the path, preserving baseline suppression across file moves. - ContentFingerprint string `json:"content_fingerprint,omitempty"` - Suppressed bool `json:"suppressed,omitempty"` - SuppressionReason string `json:"suppression_reason,omitempty"` + ContentFingerprint string `json:"content_fingerprint,omitempty"` + Suppressed bool `json:"suppressed,omitempty"` + SuppressionReason string `json:"suppression_reason,omitempty"` + Suppression *Suppression `json:"suppression,omitempty"` // Metadata carries machine-readable, non-sensitive finding attributes. It // must never contain source snippets or credential values. Metadata map[string]string `json:"metadata,omitempty"` } +type Suppression struct { + Kind string `json:"kind"` + Match string `json:"match,omitempty"` + BaselineFingerprint string `json:"baseline_fingerprint,omitempty"` +} + type ReportSummary struct { PassedSections int `json:"passed_sections"` WarnedSections int `json:"warned_sections"` diff --git a/internal/codeguard/core/rule_scan_pack_types.go b/internal/codeguard/core/rule_scan_pack_types.go index 8e0d8d17..83244a27 100644 --- a/internal/codeguard/core/rule_scan_pack_types.go +++ b/internal/codeguard/core/rule_scan_pack_types.go @@ -17,6 +17,9 @@ type ScanOptions struct { // EnableWaiverAudit records which configured waivers matched findings // before waiver suppression removes them from the report. EnableWaiverAudit bool + // IncludeSuppressed retains individually suppressed findings in the report. + // It does not mix them into section findings or alter section status. + IncludeSuppressed bool // OnSectionComplete, when set, is invoked once per section as soon as that // section finishes, enabling callers (e.g. the MCP server) to stream // partial results. It is never serialized — json.Marshal errors on a diff --git a/internal/codeguard/runner/runner.go b/internal/codeguard/runner/runner.go index 2b7449ac..849f4961 100644 --- a/internal/codeguard/runner/runner.go +++ b/internal/codeguard/runner/runner.go @@ -76,6 +76,9 @@ func RunWithOptions(ctx context.Context, cfg core.Config, opts core.ScanOptions) } report.Artifacts = sc.Artifacts.List() report.Summary = runnersupport.SummarizeSections(report.Sections) + if opts.IncludeSuppressed { + report.SuppressedFindings = sc.Suppressed.Snapshot() + } if sc.Cache != nil { _ = sc.Cache.Save() } diff --git a/internal/codeguard/runner/support/context.go b/internal/codeguard/runner/support/context.go index 00a8adad..dc3d8681 100644 --- a/internal/codeguard/runner/support/context.go +++ b/internal/codeguard/runner/support/context.go @@ -7,6 +7,7 @@ import ( "os" "sort" "strings" + "sync" "time" "github.com/devr-tools/codeguard/internal/codeguard/ai/nlrule" @@ -21,6 +22,7 @@ type Context struct { Diff map[string]LineRanges Artifacts *ArtifactStore RuleStats *RuleStatsCollector + Suppressed *SuppressedFindingCollector WaiverAudit *WaiverAuditCollector Today time.Time RuleCatalog map[string]core.RuleMetadata @@ -65,6 +67,7 @@ func NewContext(ctx context.Context, cfg core.Config, opts core.ScanOptions) (Co Opts: opts, Artifacts: NewArtifactStore(), RuleStats: NewRuleStatsCollector(), + Suppressed: &SuppressedFindingCollector{}, Today: time.Now(), RuleCatalog: ruleCatalog, CustomRules: customRules, @@ -119,6 +122,33 @@ func NewContext(ctx context.Context, cfg core.Config, opts core.ScanOptions) (Co return sc, nil } +type SuppressedFindingCollector struct { + mu sync.Mutex + findings []core.Finding +} + +func (collector *SuppressedFindingCollector) Add(finding core.Finding) { + collector.mu.Lock() + defer collector.mu.Unlock() + collector.findings = append(collector.findings, finding) +} + +func (collector *SuppressedFindingCollector) Snapshot() []core.Finding { + collector.mu.Lock() + defer collector.mu.Unlock() + out := append([]core.Finding(nil), collector.findings...) + sort.Slice(out, func(i, j int) bool { + if out[i].Path != out[j].Path { + return out[i].Path < out[j].Path + } + if out[i].Line != out[j].Line { + return out[i].Line < out[j].Line + } + return out[i].RuleID < out[j].RuleID + }) + return out +} + func (sc Context) Close() { sc.cleanup() } diff --git a/internal/codeguard/runner/support/findings_section.go b/internal/codeguard/runner/support/findings_section.go index a6b6b7e7..a8009900 100644 --- a/internal/codeguard/runner/support/findings_section.go +++ b/internal/codeguard/runner/support/findings_section.go @@ -84,9 +84,15 @@ func FinalizeSection(sc Context, id string, name string, findings []core.Finding continue } sc.WaiverAudit.RecordMatches(MatchingWaivers(sc, finding), finding) - if suppressed, reason := IsSuppressed(sc, finding); suppressed { + if suppression := MatchSuppression(sc, finding); suppression != nil { section.SuppressedCount++ - sc.RuleStats.RecordSuppressed(finding.RuleID, reason) + sc.RuleStats.RecordSuppressed(finding.RuleID, suppressionReason(suppression)) + if sc.Opts.IncludeSuppressed { + finding.Suppressed = true + finding.SuppressionReason = suppressionReason(suppression) + finding.Suppression = suppression + sc.Suppressed.Add(finding) + } continue } sc.RuleStats.RecordEmitted(finding.RuleID) diff --git a/internal/codeguard/runner/support/suppressions.go b/internal/codeguard/runner/support/suppressions.go index 6dac0636..19c98e61 100644 --- a/internal/codeguard/runner/support/suppressions.go +++ b/internal/codeguard/runner/support/suppressions.go @@ -27,9 +27,26 @@ const ( ) func IsSuppressed(sc Context, finding core.Finding) (bool, string) { + suppression := MatchSuppression(sc, finding) + return suppression != nil, suppressionReason(suppression) +} + +func suppressionReason(suppression *core.Suppression) string { + if suppression == nil { + return "" + } + if suppression.Kind == "inline" { + return SuppressionReasonInline + } + return suppression.Kind +} + +// MatchSuppression returns structured suppression evidence while retaining the +// exact precedence and many-to-many fingerprint semantics of IsSuppressed. +func MatchSuppression(sc Context, finding core.Finding) *core.Suppression { if sc.Baseline != nil { - if _, ok := sc.Baseline[finding.Fingerprint]; ok { - return true, SuppressionReasonBaseline + if entry, ok := sc.Baseline[finding.Fingerprint]; ok { + return &core.Suppression{Kind: SuppressionReasonBaseline, Match: "exact", BaselineFingerprint: entry.Fingerprint} } // The context fingerprint deliberately omits the line number, so two // identical findings in the same file (same rule, same normalized @@ -40,31 +57,31 @@ func IsSuppressed(sc Context, finding core.Finding) (bool, string) { // finding. Baseline files written before context fingerprints existed // carry legacy-only entries and are matched by the check above. if finding.ContextFingerprint != "" { - if _, ok := sc.Baseline[finding.ContextFingerprint]; ok { - return true, "baseline" + if entry, ok := sc.Baseline[finding.ContextFingerprint]; ok { + return &core.Suppression{Kind: SuppressionReasonBaseline, Match: "context", BaselineFingerprint: entry.Fingerprint} } } if finding.ContentFingerprint != "" { - if _, ok := sc.Baseline[finding.ContentFingerprint]; ok { - return true, SuppressionReasonBaseline + if entry, ok := sc.Baseline[finding.ContentFingerprint]; ok { + return &core.Suppression{Kind: SuppressionReasonBaseline, Match: "content", BaselineFingerprint: entry.Fingerprint} } } } if len(MatchingWaivers(sc, finding)) > 0 { - return true, SuppressionReasonWaiver + return &core.Suppression{Kind: SuppressionReasonWaiver} } fullPath := findingFullPath(sc, finding.Path) if fullPath == "" { - return false, "" + return nil } directives, err := parseInlineSuppressions(fullPath) if err != nil { - return false, "" + return nil } if inlineSuppressionMatches(sc, finding, directives) { - return true, SuppressionReasonInline + return &core.Suppression{Kind: "inline"} } - return false, "" + return nil } type WaiverMatch struct { diff --git a/pkg/codeguard/sdk_types_runtime_report.go b/pkg/codeguard/sdk_types_runtime_report.go index 9bf5cd85..3152651c 100644 --- a/pkg/codeguard/sdk_types_runtime_report.go +++ b/pkg/codeguard/sdk_types_runtime_report.go @@ -34,6 +34,7 @@ type ( AIFixArtifact = core.AIFixArtifact SectionResult = core.SectionResult Finding = core.Finding + Suppression = core.Suppression ChangeImpactArtifact = core.ChangeImpactArtifact ChangeImpactEntry = core.ChangeImpactEntry diff --git a/pkg/codeguard/sdk_types_state.go b/pkg/codeguard/sdk_types_state.go index a13a16a8..1e4123c4 100644 --- a/pkg/codeguard/sdk_types_state.go +++ b/pkg/codeguard/sdk_types_state.go @@ -9,6 +9,8 @@ type CustomSecretPattern = core.CustomSecretPattern type OutputConfig = core.OutputConfig type CacheConfig = core.CacheConfig type BaselineConfig = core.BaselineConfig +type BaselineGovernanceConfig = core.BaselineGovernanceConfig +type BaselineOwnershipConfig = core.BaselineOwnershipConfig type WaiverConfig = core.WaiverConfig type BaselineFile = core.BaselineFile type BaselineEntry = core.BaselineEntry diff --git a/tests/checks/fingerprint_baseline_test.go b/tests/checks/fingerprint_baseline_test.go index 43b8b5fa..2903da2f 100644 --- a/tests/checks/fingerprint_baseline_test.go +++ b/tests/checks/fingerprint_baseline_test.go @@ -88,7 +88,7 @@ func TestBaselineSuppressesFindingAfterLineShift(t *testing.T) { } cfg.Baseline.Path = baselinePath - report, err = codeguard.Run(context.Background(), cfg) + report, err = codeguard.RunWithOptions(context.Background(), cfg, codeguard.ScanOptions{Mode: codeguard.ScanModeFull, IncludeSuppressed: true}) if err != nil { t.Fatalf("run with baseline: %v", err) } @@ -96,6 +96,7 @@ func TestBaselineSuppressesFindingAfterLineShift(t *testing.T) { if report.Summary.SuppressedFindings == 0 { t.Fatal("expected the pre-edit baseline to suppress the shifted finding") } + assertSuppressionMatch(t, report, "context") } func TestBaselineSuppressesFindingAfterMoveToSplitFile(t *testing.T) { @@ -122,7 +123,7 @@ func TestBaselineSuppressesFindingAfterMoveToSplitFile(t *testing.T) { writeFile(t, filepath.Join(dir, "prompts", "split", "system.prompt"), shiftPromptBody) cfg.Baseline.Path = baselinePath - report, err = codeguard.Run(context.Background(), cfg) + report, err = codeguard.RunWithOptions(context.Background(), cfg, codeguard.ScanOptions{Mode: codeguard.ScanModeFull, IncludeSuppressed: true}) if err != nil { t.Fatalf("run with baseline after move: %v", err) } @@ -130,6 +131,17 @@ func TestBaselineSuppressesFindingAfterMoveToSplitFile(t *testing.T) { if report.Summary.SuppressedFindings == 0 { t.Fatal("expected the pre-move baseline to suppress the moved finding") } + assertSuppressionMatch(t, report, "content") +} + +func assertSuppressionMatch(t *testing.T, report codeguard.Report, want string) { + t.Helper() + for _, finding := range report.SuppressedFindings { + if finding.RuleID == "prompts.secret-interpolation" && finding.Suppression != nil && finding.Suppression.Match == want { + return + } + } + t.Fatalf("suppressed findings did not contain %q match: %#v", want, report.SuppressedFindings) } // Baseline files written before context fingerprints existed carry legacy-only diff --git a/tests/cli/baseline_governance_test.go b/tests/cli/baseline_governance_test.go new file mode 100644 index 00000000..409c1ad3 --- /dev/null +++ b/tests/cli/baseline_governance_test.go @@ -0,0 +1,128 @@ +package cli_test + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/internal/cli" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func TestBaselineAuditAndPruneDoNotAcceptNewFindings(t *testing.T) { + dir, configPath, baselinePath := governanceFixture(t) + var stdout, stderr bytes.Buffer + if code := cli.Run([]string{"baseline", "-config", configPath, "-output", baselinePath}, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("create exit=%d stderr=%s", code, stderr.String()) + } + file := readBaselineFixture(t, baselinePath) + file.Entries = append(file.Entries, core.BaselineEntry{Fingerprint: "stale", RuleID: "quality.stale", Path: "old.go"}) + writeBaselineFixture(t, baselinePath, file) + original, _ := os.ReadFile(baselinePath) + + stdout.Reset() + stderr.Reset() + if code := cli.Run([]string{"baseline", "audit", "-config", configPath, "-baseline", baselinePath, "-format", "json"}, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("audit exit=%d stderr=%s", code, stderr.String()) + } + var audit struct { + Counts struct { + Stale int `json:"stale"` + } `json:"counts"` + } + if err := json.Unmarshal(stdout.Bytes(), &audit); err != nil || audit.Counts.Stale != 1 { + t.Fatalf("audit=%#v err=%v body=%s", audit, err, stdout.String()) + } + + stdout.Reset() + stderr.Reset() + if code := cli.Run([]string{"baseline", "prune", "-config", configPath, "-baseline", baselinePath, "-check"}, strings.NewReader(""), &stdout, &stderr); code == 0 { + t.Fatal("prune --check should fail for stale entries") + } + afterCheck, _ := os.ReadFile(baselinePath) + if !bytes.Equal(original, afterCheck) { + t.Fatal("prune --check modified baseline") + } + + candidate := filepath.Join(dir, "candidate.json") + stdout.Reset() + stderr.Reset() + if code := cli.Run([]string{"baseline", "prune", "-config", configPath, "-baseline", baselinePath, "-write", "-output", candidate}, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("prune --write exit=%d stderr=%s", code, stderr.String()) + } + pruned := readBaselineFixture(t, candidate) + if len(pruned.Entries) != len(file.Entries)-1 { + t.Fatalf("entries=%d want=%d", len(pruned.Entries), len(file.Entries)-1) + } + for _, entry := range pruned.Entries { + if entry.Fingerprint == "stale" { + t.Fatal("stale entry retained") + } + } +} + +func TestBaselinePolicyRejectsGrowthAndProhibitedAddition(t *testing.T) { + dir := t.TempDir() + currentPath := filepath.Join(dir, "current.json") + comparisonPath := filepath.Join(dir, "comparison.json") + configPath := filepath.Join(dir, "codeguard.json") + writeBaselineFixture(t, comparisonPath, core.BaselineFile{Entries: []core.BaselineEntry{{Fingerprint: "old", RuleID: "security.old"}}}) + writeBaselineFixture(t, currentPath, core.BaselineFile{Entries: []core.BaselineEntry{{Fingerprint: "old", RuleID: "security.old"}, {Fingerprint: "new", RuleID: "security.new"}}}) + config := `{"name":"policy","targets":[{"name":"repo","path":"` + dir + `","language":"go"}],"baseline":{"path":"current.json","governance":{"forbid_growth":true,"prohibited_new_rule_prefixes":["security."]}}}` + if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if code := cli.Run([]string{"baseline", "policy", "-config", configPath, "-baseline", currentPath, "-compare-baseline", comparisonPath, "-format", "json"}, strings.NewReader(""), &stdout, &stderr); code == 0 { + t.Fatalf("policy unexpectedly passed: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"prohibited_rule"`) || !strings.Contains(stdout.String(), `"growth"`) { + t.Fatalf("policy output=%s stderr=%s", stdout.String(), stderr.String()) + } +} + +func governanceFixture(t *testing.T) (string, string, string) { + t.Helper() + dir := t.TempDir() + configPath := filepath.Join(dir, "codeguard.json") + baselinePath := filepath.Join(dir, "baseline.json") + promptPath := filepath.Join(dir, "prompts", "system.prompt") + if err := os.MkdirAll(filepath.Dir(promptPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(promptPath, []byte("Use ${OPENAI_API_KEY}.\n"), 0o644); err != nil { + t.Fatal(err) + } + config := `{"name":"governance","targets":[{"name":"repo","path":"` + dir + `","language":"go"}],"checks":{"quality":false,"design":false,"security":false,"prompts":true,"ci":false},"baseline":{"path":"baseline.json","governance":{"require_no_stale_entries":true}},"output":{"format":"json"}}` + if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil { + t.Fatal(err) + } + return dir, configPath, baselinePath +} + +func readBaselineFixture(t *testing.T, path string) core.BaselineFile { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var file core.BaselineFile + if err := json.Unmarshal(data, &file); err != nil { + t.Fatal(err) + } + return file +} + +func writeBaselineFixture(t *testing.T, path string, file core.BaselineFile) { + t.Helper() + data, err := json.Marshal(file) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/tests/cli/features_test.go b/tests/cli/features_test.go index 15aeb095..fc347eb9 100644 --- a/tests/cli/features_test.go +++ b/tests/cli/features_test.go @@ -136,6 +136,51 @@ func TestRunBaselineWritesFile(t *testing.T) { } } +func TestRunScanIncludeSuppressedEmitsIndividualRecords(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "codeguard.json") + baselinePath := filepath.Join(dir, "codeguard-baseline.json") + promptPath := filepath.Join(dir, "prompts", "system.prompt") + if err := os.MkdirAll(filepath.Dir(promptPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(promptPath, []byte("Use ${OPENAI_API_KEY}.\n"), 0o644); err != nil { + t.Fatal(err) + } + config := `{"name":"suppressed-json","targets":[{"name":"repo","path":"` + dir + `","language":"go"}],"checks":{"quality":false,"design":false,"security":false,"prompts":true,"ci":false},"baseline":{"path":"` + baselinePath + `"},"output":{"format":"json"}}` + if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + if code := cli.Run([]string{"baseline", "-config", configPath, "-output", baselinePath}, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("baseline exit=%d stderr=%s", code, stderr.String()) + } + stdout.Reset() + stderr.Reset() + if code := cli.Run([]string{"scan", "-config", configPath, "-include-suppressed"}, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("scan exit=%d stderr=%s", code, stderr.String()) + } + var payload struct { + SuppressedFindings []struct { + Suppression struct { + Kind string `json:"kind"` + } `json:"suppression"` + } `json:"suppressed_findings"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("decode: %v body=%s", err, stdout.String()) + } + if len(payload.SuppressedFindings) == 0 { + t.Fatalf("suppressed findings = %#v", payload.SuppressedFindings) + } + for _, finding := range payload.SuppressedFindings { + if finding.Suppression.Kind != "baseline" { + t.Fatalf("suppressed findings = %#v", payload.SuppressedFindings) + } + } +} + func TestRunRulesWithConfigIncludesCustomRules(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "codeguard.json") diff --git a/tests/codeguard/rule_stats_artifact_test.go b/tests/codeguard/rule_stats_artifact_test.go index 5ad6b1fd..8acb6db1 100644 --- a/tests/codeguard/rule_stats_artifact_test.go +++ b/tests/codeguard/rule_stats_artifact_test.go @@ -52,6 +52,50 @@ func TestRunPublishesRuleStatsArtifact(t *testing.T) { assertRuleStatsSerialized(t, report) } +// Removing suppression collection or losing the mechanism/match metadata would +// make baseline audits unable to reconcile aggregate counts with real findings. +func TestRunCanIncludeStructuredSuppressedFindings(t *testing.T) { + root := t.TempDir() + writeArtifactFile(t, filepath.Join(root, "keep.go"), "package keep\n// TODO keep\n") + writeArtifactFile(t, filepath.Join(root, "waived.go"), "package waived\n// TODO waived\n") + writeArtifactFile(t, filepath.Join(root, "base.go"), "package base\n// TODO base\n") + writeArtifactFile(t, filepath.Join(root, "inline.go"), "package inline\n// TODO inline codeguard:ignore custom.no-todo\n") + + cfg := ruleStatsFixtureConfig(root, "") + first, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("first Run returned error: %v", err) + } + baselinePath := filepath.Join(t.TempDir(), "codeguard-baseline.json") + writeRuleStatsBaseline(t, baselinePath, first, "base.go") + cfg.Baseline.Path = baselinePath + + report, err := codeguard.RunWithOptions(context.Background(), cfg, codeguard.ScanOptions{ + Mode: codeguard.ScanModeFull, + IncludeSuppressed: true, + }) + if err != nil { + t.Fatalf("RunWithOptions returned error: %v", err) + } + if got, want := len(report.SuppressedFindings), report.Summary.SuppressedFindings; got != want { + t.Fatalf("suppressed records = %d, summary = %d", got, want) + } + + got := map[string]codeguard.Suppression{} + for _, finding := range report.SuppressedFindings { + got[finding.Path] = *finding.Suppression + } + if got["base.go"].Kind != "baseline" || got["base.go"].Match != "exact" || got["base.go"].BaselineFingerprint == "" { + t.Fatalf("baseline suppression = %#v", got["base.go"]) + } + if got["waived.go"].Kind != "waiver" { + t.Fatalf("waiver suppression = %#v", got["waived.go"]) + } + if got["inline.go"].Kind != "inline" { + t.Fatalf("inline suppression = %#v", got["inline.go"]) + } +} + func ruleStatsFixtureConfig(root string, baselinePath string) codeguard.Config { cacheEnabled := false cfg := codeguard.Config{ From 7ef22b777b7c95ef247907f32146611a470f173e Mon Sep 17 00:00:00 2001 From: AJMini Date: Sat, 29 Aug 2026 15:11:22 -0400 Subject: [PATCH 2/3] docs: describe baseline governance --- docs/features.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/features.md b/docs/features.md index d153dde9..b9e8350c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -84,6 +84,29 @@ This page lists the current `codeguard` feature surface and the main config entr - verified auto-fix through SDK and CLI - hook-pack examples for Claude Code and Cursor +## Baseline governance + +- `codeguard baseline` creates a new accepted-debt snapshot. +- `codeguard baseline audit` classifies existing entries as active through an + exact, context, or path-insensitive content fingerprint, or as stale/invalid. +- `codeguard baseline prune -check` provides a non-mutating CI gate; + `-write` atomically removes only stale entries, and `-output` creates a + reviewable candidate file. +- `codeguard baseline policy -compare-baseline ` rejects configured net + growth, maximum-entry violations, and newly baselined prohibited rule + families while allowing existing approved debt. +- Audit reports group active debt by rule, ownership area, and risk family and + provide deterministic evidence samples with confidence/language counts. +- Context/content collisions from identical snippets are reported and + preserved. Governance never forces one-to-one matching or changes v1.7.3 + suppression semantics. +- `codeguard scan -include-suppressed -format json` emits individual baseline, + waiver, and inline suppression records when explicitly requested; default + reports remain unchanged. + +See [Production rollout](production.md) for configuration, safe CI usage, +review workflow, and exit-code behavior. + ## External report ingestion CodeGuard can import findings from scanners that have already run. It does not From 19f18407bda355a70a6e4f59b8b24608bfff7e5b Mon Sep 17 00:00:00 2001 From: AJMini Date: Sat, 29 Aug 2026 15:19:23 -0400 Subject: [PATCH 3/3] fix: satisfy baseline governance lint checks --- internal/cli/baseline_io.go | 4 ++-- internal/cli/baseline_io_test.go | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/cli/baseline_io.go b/internal/cli/baseline_io.go index daf1021d..61f7fe4b 100644 --- a/internal/cli/baseline_io.go +++ b/internal/cli/baseline_io.go @@ -51,8 +51,8 @@ func WritePruned(source, output string, result AuditResult, opts PruneOptions) e if output == "" { output = source } - if err := os.MkdirAll(filepath.Dir(output), 0o750); err != nil { - return err + if mkdirErr := os.MkdirAll(filepath.Dir(output), 0o750); mkdirErr != nil { + return mkdirErr } tmp, err := os.CreateTemp(filepath.Dir(output), ".codeguard-baseline-*.tmp") if err != nil { diff --git a/internal/cli/baseline_io_test.go b/internal/cli/baseline_io_test.go index e42da0a4..1f169f6c 100644 --- a/internal/cli/baseline_io_test.go +++ b/internal/cli/baseline_io_test.go @@ -30,7 +30,10 @@ func TestPruneWritesOnlyActiveEntriesAndNeverAddsFindings(t *testing.T) { if len(got.Entries) != 1 || got.Entries[0].Fingerprint != "active" { t.Fatalf("pruned entries = %#v", got.Entries) } - sourceAfter, _ := os.ReadFile(source) + sourceAfter, err := os.ReadFile(source) //nolint:gosec // source is created inside t.TempDir + if err != nil { + t.Fatal(err) + } var unchanged core.BaselineFile if err := json.Unmarshal(sourceAfter, &unchanged); err != nil || len(unchanged.Entries) != 2 { t.Fatalf("source was modified: %s err=%v", sourceAfter, err)