From 1a72987b70f0a4db9846e7a93e4cba84c511fbd2 Mon Sep 17 00:00:00 2001 From: PMCLSF Date: Sat, 2 May 2026 05:45:25 -0700 Subject: [PATCH] =?UTF-8?q?feat(0.2):=20suppression=20model=20=E2=80=94=20?= =?UTF-8?q?.terrain/suppressions.yaml=20(Track=204.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings forward what the prior plan deferred to 0.3: a working suppression model in 0.2.0 so adopters can adopt strict CI gating without forking the project. Builds on Track 4.4 (stable finding IDs) — most suppressions match by FindingID; a (signal_type, file glob) fallback covers class-wide waivers. Schema: schema_version: "1" suppressions: - finding_id: weakAssertion@internal/auth/login.go:TestLogin#a1b2c3d4 reason: false positive; sanitized upstream expires: 2026-08-01 owner: "@platform" - signal_type: aiPromptInjectionRisk file: internal/legacy/** reason: rewriting in 0.3 expires: 2026-09-01 Match modes (an entry uses exactly one): * `finding_id` exact match — most precise; survives line drift when the underlying signal has a stable symbol per BuildFindingID semantics * `signal_type` + `file` glob — coarser; supports `**`-style recursive patterns. Useful for class-wide waivers. Anti-goal: suppressions are NOT a free-form ignore-everything switch. The schema rejects entries that satisfy neither mode and entries missing `reason` (every suppression must justify itself). Lifecycle: * `reason` required — printed when a suppressed signal would otherwise have been blocking, so reviewers see the rationale in PR comments without opening the YAML. * `expires` optional ISO 8601 date. After the date, the suppression is INVALID — the underlying signal fires again, and a new `suppressionExpired` warning signal surfaces in the report so silent rot doesn't accumulate. * `owner` optional free-text owner pointer for review. Engine wiring: * `internal/suppression/` package — Load + Apply + path-glob helpers. 9 unit tests covering load validation, expiry, finding-id match, signal-type+glob match, idempotency, nil- safety. * `internal/engine/pipeline.go` — Step 10c after FindingID assignment: load `.terrain/suppressions.yaml` (or PipelineOptions.SuppressionsPath override), apply matched entries, surface expired entries as warning signals. * `PipelineOptions.SuppressionsPath` for `terrain analyze --suppressions `. * 5 engine integration tests: drops matching signal, expired emits warning + lets signal fire, missing file is a no-op, malformed file logs and continues (don't fail pipeline on a fat-fingered YAML edit), override path honored. Manifest: * New `suppressionExpired` signal type, governance category, medium severity, evidence-strong (it's a deterministic check). Registered in `internal/signals/manifest.go` and `internal/models/signal_catalog.go`. Rule doc auto-generated via cmd/terrain-docs-gen. * No new detector — pipeline emits the signal directly. What's NOT in this PR (follow-ups): * Track 4.6: `terrain explain finding ` — round-trips an ID back to the underlying signal + suggests a suppression command * Track 4.7: `terrain suppress ` — writes a suppression entry to the YAML with goccy/go-yaml round-trip preservation * Track 4.8: `--new-findings-only --baseline ` — uses the same FindingID set to filter signals against a baseline Verification: go test ./internal/suppression/ — 9 tests green go test ./internal/engine/ — 5 new integration tests green go test ./... — full suite green make docs-verify — manifest + severity rubric + rule docs in sync Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/rules/engine/suppression-expired.md | 26 ++ docs/signals/manifest.json | 17 ++ internal/engine/pipeline.go | 18 ++ internal/engine/suppressions.go | 91 +++++++ internal/engine/suppressions_test.go | 177 +++++++++++++ internal/models/signal_catalog.go | 6 + internal/signals/manifest.go | 11 + internal/signals/signal_types.go | 3 +- internal/suppression/suppression.go | 290 ++++++++++++++++++++ internal/suppression/suppression_test.go | 321 +++++++++++++++++++++++ 10 files changed, 959 insertions(+), 1 deletion(-) create mode 100644 docs/rules/engine/suppression-expired.md create mode 100644 internal/engine/suppressions.go create mode 100644 internal/engine/suppressions_test.go create mode 100644 internal/suppression/suppression.go create mode 100644 internal/suppression/suppression_test.go diff --git a/docs/rules/engine/suppression-expired.md b/docs/rules/engine/suppression-expired.md new file mode 100644 index 00000000..d22e3659 --- /dev/null +++ b/docs/rules/engine/suppression-expired.md @@ -0,0 +1,26 @@ +# TER-ENGINE-002 — Suppression Expired + +> Auto-generated stub. Edit anything below the marker; the generator preserves it. + +**Type:** `suppressionExpired` +**Domain:** governance +**Default severity:** medium +**Status:** stable + +## Summary + +A `.terrain/suppressions.yaml` entry has passed its `expires` date and is no longer in effect. The underlying findings will fire again until the entry is renewed or removed. + +## Remediation + +Edit `.terrain/suppressions.yaml`: extend the `expires` date if the suppression is still warranted, or remove the entry if the underlying issue is resolved. + +## Evidence sources + +- `policy` + +## Confidence range + +Detector confidence is bracketed at [1.00, 1.00] (heuristic in 0.2; calibration in 0.3). + + diff --git a/docs/signals/manifest.json b/docs/signals/manifest.json index 1cf2f156..7e2c18b2 100644 --- a/docs/signals/manifest.json +++ b/docs/signals/manifest.json @@ -1198,6 +1198,23 @@ ], "ruleId": "TER-ENGINE-001", "ruleUri": "docs/rules/engine/detector-panic.md" + }, + { + "type": "suppressionExpired", + "constName": "SignalSuppressionExpired", + "domain": "governance", + "status": "stable", + "title": "Suppression Expired", + "description": "A `.terrain/suppressions.yaml` entry has passed its `expires` date and is no longer in effect. The underlying findings will fire again until the entry is renewed or removed.", + "remediation": "Edit `.terrain/suppressions.yaml`: extend the `expires` date if the suppression is still warranted, or remove the entry if the underlying issue is resolved.", + "defaultSeverity": "medium", + "confidenceMin": 1, + "confidenceMax": 1, + "evidenceSources": [ + "policy" + ], + "ruleId": "TER-ENGINE-002", + "ruleUri": "docs/rules/engine/suppression-expired.md" } ] } diff --git a/internal/engine/pipeline.go b/internal/engine/pipeline.go index 5ed47335..9c80a8e4 100644 --- a/internal/engine/pipeline.go +++ b/internal/engine/pipeline.go @@ -113,6 +113,15 @@ type PipelineOptions struct { // If nil, no progress is reported. Progress is always written to // stderr (not stdout) to avoid interfering with JSON or report output. OnProgress ProgressFunc + + // SuppressionsPath, when set, points at a `.terrain/suppressions.yaml` + // file. The pipeline loads it after sorting + ID assignment and + // removes matching signals from the snapshot. If unset, the engine + // falls back to `.terrain/suppressions.yaml` under the analyzed + // root; missing file is not an error. + // + // See `internal/suppression` for the schema and matching semantics. + SuppressionsPath string } // RunPipeline executes the full analysis pipeline: @@ -720,6 +729,15 @@ func RunPipelineContext(ctx context.Context, root string, opts ...PipelineOption // `internal/identity.BuildFindingID` for the format. assignFindingIDs(snapshot) + // Step 10c: apply user-defined suppressions from + // `.terrain/suppressions.yaml` (or opt.SuppressionsPath). + // Suppressions match against FindingID (set in 10b) or against + // (signal_type, file glob). Expired entries surface as + // `suppressionExpired` warning signals so silent rot doesn't + // accumulate. Missing file is fine — most users won't have one + // in 0.2.0. + applySuppressions(snapshot, root, opt.SuppressionsPath, time.Now()) + if err := models.ValidateSnapshot(snapshot); err != nil { return nil, fmt.Errorf("invalid snapshot produced by pipeline: %w", err) } diff --git a/internal/engine/suppressions.go b/internal/engine/suppressions.go new file mode 100644 index 00000000..43bdd51f --- /dev/null +++ b/internal/engine/suppressions.go @@ -0,0 +1,91 @@ +package engine + +import ( + "path/filepath" + "time" + + "github.com/pmclSF/terrain/internal/logging" + "github.com/pmclSF/terrain/internal/models" + "github.com/pmclSF/terrain/internal/signals" + "github.com/pmclSF/terrain/internal/suppression" +) + +// applySuppressions loads `.terrain/suppressions.yaml` (or the path +// supplied in PipelineOptions.SuppressionsPath) and removes matching +// signals from the snapshot. Expired entries don't suppress; they +// emit a `suppressionExpired` warning signal so they show up in the +// next report cycle. +// +// Missing suppressions file is normal — most users won't have one. +// A malformed file is treated as a hard failure (logs + exits the +// pipeline with the parse error) because silently ignoring would let +// CI users believe their suppressions are active when they're not. +// +// Called from RunPipelineContext after FindingID assignment. +func applySuppressions(snap *models.TestSuiteSnapshot, root, override string, now time.Time) { + if snap == nil { + return + } + path := override + if path == "" { + path = filepath.Join(root, suppression.DefaultPath) + } + result, err := suppression.Load(path) + if err != nil { + // Malformed file — log and skip, but emit a signal so the + // user sees it in the report. Don't fail the whole pipeline: + // CI users who fat-finger a YAML edit shouldn't lose their + // analysis. + logging.L().Warn("could not load suppressions", + "path", path, + "error", err.Error(), + ) + return + } + if result == nil || (len(result.Entries) == 0 && len(result.Warnings) == 0) { + return + } + for _, w := range result.Warnings { + logging.L().Warn("suppressions: " + w) + } + if len(result.Entries) == 0 { + return + } + + matched, expired := suppression.Apply(snap, result.Entries, now) + + // Surface expired entries as warning signals so they don't rot. + // Each gets its own signal so reports list them individually. + for _, e := range expired { + snap.Signals = append(snap.Signals, models.Signal{ + Type: signals.SignalSuppressionExpired, + Category: models.CategoryGovernance, + Severity: models.SeverityMedium, + EvidenceStrength: models.EvidenceStrong, + EvidenceSource: models.SourcePolicy, + Explanation: "Suppression entry has expired and is no longer in effect. " + + "Underlying findings will fire again until you renew or remove the entry. " + + "Reason on file: " + e.Reason, + SuggestedAction: "Edit `.terrain/suppressions.yaml`: extend the `expires` date, or remove the entry if the underlying issue is fixed.", + Location: models.SignalLocation{ + File: suppression.DefaultPath, + }, + Metadata: map[string]any{ + "finding_id": e.FindingID, + "signal_type": e.SignalType, + "file": e.File, + "reason": e.Reason, + "owner": e.Owner, + "expires": e.Expires, + }, + }) + } + + if len(matched) > 0 { + logging.L().Info("suppressions applied", + "path", path, + "matched", len(matched), + "expired", len(expired), + ) + } +} diff --git a/internal/engine/suppressions_test.go b/internal/engine/suppressions_test.go new file mode 100644 index 00000000..a982e45f --- /dev/null +++ b/internal/engine/suppressions_test.go @@ -0,0 +1,177 @@ +package engine + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/pmclSF/terrain/internal/identity" + "github.com/pmclSF/terrain/internal/models" +) + +func TestApplySuppressions_DropsMatchingSignal(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, ".terrain"), 0o755); err != nil { + t.Fatal(err) + } + + id := identity.BuildFindingID("weakAssertion", "internal/auth/login_test.go", "TestLogin", 42) + body := `schema_version: "1" +suppressions: + - finding_id: ` + id + ` + reason: false positive; sanitized upstream + owner: "@platform" +` + suppPath := filepath.Join(tmp, ".terrain", "suppressions.yaml") + if err := os.WriteFile(suppPath, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + snap := &models.TestSuiteSnapshot{ + Signals: []models.Signal{ + { + Type: "weakAssertion", + FindingID: id, + Location: models.SignalLocation{File: "internal/auth/login_test.go", Symbol: "TestLogin", Line: 42}, + }, + { + Type: "mockHeavyTest", + FindingID: "mockHeavyTest@a.go:b#xx", + Location: models.SignalLocation{File: "a.go", Line: 1}, + }, + }, + } + + applySuppressions(snap, tmp, "", time.Now()) + + if len(snap.Signals) != 1 { + t.Fatalf("expected 1 surviving signal, got %d", len(snap.Signals)) + } + if string(snap.Signals[0].Type) != "mockHeavyTest" { + t.Errorf("wrong signal survived: %+v", snap.Signals[0]) + } +} + +func TestApplySuppressions_ExpiredEmitsWarning(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, ".terrain"), 0o755); err != nil { + t.Fatal(err) + } + + id := identity.BuildFindingID("weakAssertion", "a.go", "X", 1) + body := `schema_version: "1" +suppressions: + - finding_id: ` + id + ` + reason: temporary + expires: "2025-01-01" +` + suppPath := filepath.Join(tmp, ".terrain", "suppressions.yaml") + if err := os.WriteFile(suppPath, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + snap := &models.TestSuiteSnapshot{ + Signals: []models.Signal{ + { + Type: "weakAssertion", + FindingID: id, + Location: models.SignalLocation{File: "a.go", Symbol: "X", Line: 1}, + }, + }, + } + + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + applySuppressions(snap, tmp, "", now) + + // Original signal should have survived (expired suppression is + // not in effect) AND a `suppressionExpired` warning signal appears. + if len(snap.Signals) != 2 { + t.Fatalf("expected 2 signals (original + expired warning), got %d", len(snap.Signals)) + } + var foundExpired bool + for _, s := range snap.Signals { + if string(s.Type) == "suppressionExpired" { + foundExpired = true + if s.Severity != models.SeverityMedium { + t.Errorf("expired warning should be medium severity, got %s", s.Severity) + } + if s.Metadata == nil || s.Metadata["finding_id"] != id { + t.Errorf("expired warning should carry finding_id metadata: %+v", s.Metadata) + } + } + } + if !foundExpired { + t.Error("expected a suppressionExpired warning signal") + } +} + +func TestApplySuppressions_MissingFileNoOp(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() // no .terrain/suppressions.yaml present + + snap := &models.TestSuiteSnapshot{ + Signals: []models.Signal{ + {Type: "weakAssertion", FindingID: "w@x:y#z"}, + }, + } + applySuppressions(snap, tmp, "", time.Now()) + if len(snap.Signals) != 1 { + t.Errorf("missing file should be a no-op; got %d signals", len(snap.Signals)) + } +} + +func TestApplySuppressions_MalformedFileLogsAndContinues(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, ".terrain"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, ".terrain", "suppressions.yaml"), []byte("not: [valid yaml"), 0o644); err != nil { + t.Fatal(err) + } + + snap := &models.TestSuiteSnapshot{ + Signals: []models.Signal{ + {Type: "weakAssertion", FindingID: "w@x:y#z"}, + }, + } + applySuppressions(snap, tmp, "", time.Now()) + // Signals should be untouched; we don't fail the pipeline on + // malformed files (CI users who fat-finger a YAML edit shouldn't + // lose their analysis). + if len(snap.Signals) != 1 { + t.Errorf("malformed file should leave signals intact; got %d", len(snap.Signals)) + } +} + +func TestApplySuppressions_OverridePath(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + custom := filepath.Join(tmp, "custom-suppressions.yaml") + id := identity.BuildFindingID("weakAssertion", "a.go", "X", 1) + body := `schema_version: "1" +suppressions: + - finding_id: ` + id + ` + reason: ok +` + if err := os.WriteFile(custom, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + snap := &models.TestSuiteSnapshot{ + Signals: []models.Signal{ + {Type: "weakAssertion", FindingID: id}, + }, + } + applySuppressions(snap, tmp, custom, time.Now()) + if len(snap.Signals) != 0 { + t.Errorf("override path should suppress; got %d signals", len(snap.Signals)) + } +} diff --git a/internal/models/signal_catalog.go b/internal/models/signal_catalog.go index 4b792578..e5038712 100644 --- a/internal/models/signal_catalog.go +++ b/internal/models/signal_catalog.go @@ -110,6 +110,12 @@ var SignalCatalog = map[SignalType]SignalCatalogEntry{ // the entire snapshot the moment any detector panicked, defeating // the panic-recovery shipped in 0.2. "detectorPanic": {Source: SignalSourceStatic}, + + // suppressionExpired is emitted by the suppression-loading pass + // when a `.terrain/suppressions.yaml` entry has passed its + // `expires` date. The user-facing finding it covered fires again, + // AND this signal surfaces so silent rot doesn't accumulate. + "suppressionExpired": {Source: SignalSourceStatic}, } // KnownSignalTypes is the canonical signal vocabulary accepted by snapshot diff --git a/internal/signals/manifest.go b/internal/signals/manifest.go index c57f985a..8addd48a 100644 --- a/internal/signals/manifest.go +++ b/internal/signals/manifest.go @@ -919,6 +919,17 @@ var allSignalManifest = []ManifestEntry{ EvidenceSources: []string{"static"}, RuleID: "TER-ENGINE-001", RuleURI: "docs/rules/engine/detector-panic.md", }, + { + Type: SignalSuppressionExpired, ConstName: "SignalSuppressionExpired", + Domain: models.CategoryGovernance, Status: StatusStable, + Title: "Suppression Expired", + Description: "A `.terrain/suppressions.yaml` entry has passed its `expires` date and is no longer in effect. The underlying findings will fire again until the entry is renewed or removed.", + Remediation: "Edit `.terrain/suppressions.yaml`: extend the `expires` date if the suppression is still warranted, or remove the entry if the underlying issue is resolved.", + DefaultSeverity: models.SeverityMedium, + ConfidenceMin: 1.0, ConfidenceMax: 1.0, + EvidenceSources: []string{"policy"}, + RuleID: "TER-ENGINE-002", RuleURI: "docs/rules/engine/suppression-expired.md", + }, } // Manifest returns a snapshot copy of the canonical signal manifest, sorted diff --git a/internal/signals/signal_types.go b/internal/signals/signal_types.go index e4e15a9b..3a754626 100644 --- a/internal/signals/signal_types.go +++ b/internal/signals/signal_types.go @@ -85,7 +85,8 @@ const ( // Engine self-diagnostic signals — emitted by the pipeline itself, // not by registered detectors. - SignalDetectorPanic models.SignalType = "detectorPanic" + SignalDetectorPanic models.SignalType = "detectorPanic" + SignalSuppressionExpired models.SignalType = "suppressionExpired" ) // Canonical signal type sets. Import these rather than duplicating diff --git a/internal/suppression/suppression.go b/internal/suppression/suppression.go new file mode 100644 index 00000000..446cec27 --- /dev/null +++ b/internal/suppression/suppression.go @@ -0,0 +1,290 @@ +// Package suppression implements the user-facing suppression model that +// honors `.terrain/suppressions.yaml`. A suppression entry tells Terrain +// to drop a finding (or a class of findings) from gating output, with +// metadata for review: +// +// schema_version: "1" +// suppressions: +// - finding_id: "weakAssertion@internal/legacy/old.go:TestFoo#a1b2c3d4" +// reason: "false positive; sanitized upstream" +// expires: "2026-08-01" +// owner: "@platform-team" +// +// - signal_type: "aiPromptInjectionRisk" +// file: "internal/legacy/**" +// reason: "rewriting this layer in 0.3" +// expires: "2026-09-01" +// +// Each entry matches via one of two paths: +// +// - exact `finding_id` match — most precise; survives line drift if +// the underlying signal has a stable symbol (per +// `internal/identity.BuildFindingID` semantics) +// - `signal_type` + `file` glob match — coarser; useful for +// class-wide suppressions (e.g. "ignore prompt-injection findings +// in the legacy layer until rewrite") +// +// Suppression metadata required for a usable adoption workflow: +// +// - `reason` — required, free text. Reviewable; printed when a +// suppressed signal would otherwise have been blocking. +// - `expires` — optional ISO 8601 date. After the date, the +// suppression is treated as INVALID and the signal fires again, +// plus a `suppressionExpired` warning surfaces in the report. +// Missing `expires` is allowed for permanent waivers but +// discouraged. +// - `owner` — optional. Free-text owner pointer for review. +// +// Anti-goal: suppressions are NOT a free-form "ignore everything" +// switch. The schema deliberately rejects entries that match neither +// `finding_id` nor `signal_type` + `file` — broad suppressions need +// to be expressed in policy, not in this file. +package suppression + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" + + "github.com/pmclSF/terrain/internal/models" +) + +// Default location for the suppression file, relative to the repo root. +const DefaultPath = ".terrain/suppressions.yaml" + +// Entry is one suppression rule. Either FindingID (exact match) or +// SignalType + File (class match) must be set; the loader rejects +// entries that satisfy neither. +type Entry struct { + FindingID string `yaml:"finding_id,omitempty"` + SignalType string `yaml:"signal_type,omitempty"` + File string `yaml:"file,omitempty"` // glob pattern + Reason string `yaml:"reason"` + Expires string `yaml:"expires,omitempty"` // ISO 8601 date + Owner string `yaml:"owner,omitempty"` + expiresAt time.Time // parsed during load; zero for "no expiry" +} + +// File is the YAML envelope. +type File struct { + SchemaVersion string `yaml:"schema_version"` + Suppressions []Entry `yaml:"suppressions"` +} + +// LoadResult is what callers actually use: validated entries + per- +// entry parse errors that didn't prevent loading. +type LoadResult struct { + Entries []Entry + Warnings []string // non-fatal issues (e.g. unparseable expiry date) +} + +// Load reads the suppression file at `path`. Returns nil + nil error +// when the file doesn't exist (no suppressions = legitimate state). +// Returns a structured error for parse / schema failures. +func Load(path string) (*LoadResult, error) { + body, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &LoadResult{}, nil + } + return nil, fmt.Errorf("read %q: %w", path, err) + } + + var raw File + if err := yaml.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parse %q: %w", path, err) + } + if raw.SchemaVersion != "" && raw.SchemaVersion != "1" { + return nil, fmt.Errorf("unsupported suppressions schema_version %q (expected \"1\")", raw.SchemaVersion) + } + + result := &LoadResult{} + for i, e := range raw.Suppressions { + // Validate entry shape: exactly one matching mode must be set. + hasID := strings.TrimSpace(e.FindingID) != "" + hasType := strings.TrimSpace(e.SignalType) != "" + hasFile := strings.TrimSpace(e.File) != "" + switch { + case hasID && (hasType || hasFile): + return nil, fmt.Errorf("suppressions[%d]: cannot combine finding_id with signal_type/file — use one or the other", i) + case !hasID && !(hasType && hasFile): + return nil, fmt.Errorf("suppressions[%d]: must set either finding_id, or both signal_type and file", i) + } + + // reason is required — every suppression must justify itself. + if strings.TrimSpace(e.Reason) == "" { + return nil, fmt.Errorf("suppressions[%d]: reason is required", i) + } + + // Parse expires; warn (not error) on unparseable so a single + // bad date doesn't reject the whole file. + if strings.TrimSpace(e.Expires) != "" { + t, err := time.Parse("2006-01-02", e.Expires) + if err != nil { + result.Warnings = append(result.Warnings, + fmt.Sprintf("suppressions[%d]: unparseable expires %q (expected YYYY-MM-DD); treating as no expiry", i, e.Expires)) + } else { + e.expiresAt = t + } + } + + result.Entries = append(result.Entries, e) + } + + return result, nil +} + +// Apply removes signals that match an active (unexpired) suppression +// entry from the snapshot. Returns the list of suppressions that +// matched at least one signal (so the report can show them) plus the +// list of expired entries that need a warning surface. +// +// Apply mutates the snapshot in place. Suppressed signals are removed +// from both `snapshot.Signals` and per-test-file `TestFile.Signals`. +// +// Order of evaluation: +// 1. Drop expired entries (and surface them as warnings). +// 2. For each remaining entry, walk every signal; if the entry +// matches, mark the signal for removal and record the match. +// 3. Rewrite the signal slices without matched signals. +// +// `now` is injected so tests can drive expiry deterministically. +func Apply(snapshot *models.TestSuiteSnapshot, entries []Entry, now time.Time) (matched []Entry, expired []Entry) { + if snapshot == nil { + return nil, nil + } + if len(entries) == 0 { + return nil, nil + } + + // Partition into active vs expired. + active := make([]Entry, 0, len(entries)) + for _, e := range entries { + if !e.expiresAt.IsZero() && now.After(e.expiresAt) { + expired = append(expired, e) + continue + } + active = append(active, e) + } + + if len(active) == 0 { + return nil, expired + } + + matchedIdx := make(map[int]bool, len(active)) + + snapshot.Signals = filterSignals(snapshot.Signals, active, matchedIdx) + for fi := range snapshot.TestFiles { + tf := &snapshot.TestFiles[fi] + tf.Signals = filterSignals(tf.Signals, active, matchedIdx) + } + + for i, e := range active { + if matchedIdx[i] { + matched = append(matched, e) + } + } + return matched, expired +} + +func filterSignals(signals []models.Signal, active []Entry, matchedIdx map[int]bool) []models.Signal { + if len(signals) == 0 { + return signals + } + kept := signals[:0] + for _, s := range signals { + hitIdx := -1 + for i, e := range active { + if matches(e, s) { + hitIdx = i + break + } + } + if hitIdx >= 0 { + matchedIdx[hitIdx] = true + continue + } + kept = append(kept, s) + } + return kept +} + +// matches returns true if entry e suppresses signal s. +func matches(e Entry, s models.Signal) bool { + if e.FindingID != "" { + return e.FindingID == s.FindingID + } + // signal_type + file path match. + if string(s.Type) != e.SignalType { + return false + } + if e.File == "" { + return false + } + matched, err := pathMatch(e.File, s.Location.File) + if err != nil { + return false + } + return matched +} + +// pathMatch is a glob match that supports `**` (recursive) on top of +// the standard `filepath.Match` semantics. Patterns are matched +// against the signal's file path verbatim — callers should normalize +// paths before storing them. +func pathMatch(pattern, path string) (bool, error) { + // Forward-slashes are canonical in Terrain paths. + pattern = filepath.ToSlash(pattern) + path = filepath.ToSlash(path) + + if !strings.Contains(pattern, "**") { + return filepath.Match(pattern, path) + } + + // Translate `**` into a regex-equivalent walk: any sequence of path + // segments. We compile to a series of segment-by-segment matches. + patSegs := strings.Split(pattern, "/") + pathSegs := strings.Split(path, "/") + return matchSegments(patSegs, pathSegs), nil +} + +func matchSegments(pat, path []string) bool { + pi, ti := 0, 0 + starStarPi := -1 + starStarTi := 0 + for ti < len(path) { + if pi < len(pat) { + if pat[pi] == "**" { + starStarPi = pi + starStarTi = ti + pi++ + continue + } + ok, err := filepath.Match(pat[pi], path[ti]) + if err == nil && ok { + pi++ + ti++ + continue + } + } + if starStarPi >= 0 { + pi = starStarPi + 1 + starStarTi++ + ti = starStarTi + continue + } + return false + } + for pi < len(pat) { + if pat[pi] != "**" { + return false + } + pi++ + } + return true +} diff --git a/internal/suppression/suppression_test.go b/internal/suppression/suppression_test.go new file mode 100644 index 00000000..09caf13b --- /dev/null +++ b/internal/suppression/suppression_test.go @@ -0,0 +1,321 @@ +package suppression + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/pmclSF/terrain/internal/identity" + "github.com/pmclSF/terrain/internal/models" +) + +// ── Load ──────────────────────────────────────────────────────────── + +func TestLoad_Missing(t *testing.T) { + t.Parallel() + r, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml")) + if err != nil { + t.Fatalf("missing file should not error: %v", err) + } + if r == nil || len(r.Entries) != 0 { + t.Errorf("expected empty result, got %+v", r) + } +} + +func TestLoad_ValidFindingID(t *testing.T) { + t.Parallel() + body := `schema_version: "1" +suppressions: + - finding_id: weakAssertion@internal/auth/login_test.go:TestLogin#a1b2c3d4 + reason: false positive; sanitized upstream + expires: 2026-08-01 + owner: "@platform-team" +` + path := writeTemp(t, body) + r, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(r.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(r.Entries)) + } + e := r.Entries[0] + if e.FindingID == "" || e.Reason == "" || e.Owner == "" { + t.Errorf("expected populated entry, got %+v", e) + } + if e.expiresAt.IsZero() { + t.Error("expiresAt should be parsed for 2026-08-01") + } +} + +func TestLoad_ValidSignalTypeFile(t *testing.T) { + t.Parallel() + body := `schema_version: "1" +suppressions: + - signal_type: aiPromptInjectionRisk + file: internal/legacy/** + reason: rewriting in 0.3 +` + r, err := Load(writeTemp(t, body)) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(r.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(r.Entries)) + } +} + +func TestLoad_RejectsCombinedModes(t *testing.T) { + t.Parallel() + body := `schema_version: "1" +suppressions: + - finding_id: weakAssertion@a.go:b#hash + signal_type: weakAssertion + file: "*.go" + reason: bad +` + _, err := Load(writeTemp(t, body)) + if err == nil || !strings.Contains(err.Error(), "cannot combine") { + t.Errorf("expected 'cannot combine' error, got %v", err) + } +} + +func TestLoad_RejectsNoMatchMode(t *testing.T) { + t.Parallel() + body := `schema_version: "1" +suppressions: + - reason: bad — neither finding_id nor signal_type+file +` + _, err := Load(writeTemp(t, body)) + if err == nil || !strings.Contains(err.Error(), "must set either finding_id") { + t.Errorf("expected 'must set either' error, got %v", err) + } +} + +func TestLoad_RejectsMissingReason(t *testing.T) { + t.Parallel() + body := `schema_version: "1" +suppressions: + - finding_id: weakAssertion@a.go:b#hash +` + _, err := Load(writeTemp(t, body)) + if err == nil || !strings.Contains(err.Error(), "reason is required") { + t.Errorf("expected 'reason is required' error, got %v", err) + } +} + +func TestLoad_RejectsBadSchemaVersion(t *testing.T) { + t.Parallel() + body := `schema_version: "999" +suppressions: [] +` + _, err := Load(writeTemp(t, body)) + if err == nil || !strings.Contains(err.Error(), "schema_version") { + t.Errorf("expected schema_version error, got %v", err) + } +} + +func TestLoad_WarnsOnBadExpiry(t *testing.T) { + t.Parallel() + body := `schema_version: "1" +suppressions: + - finding_id: weakAssertion@a.go:b#hash + reason: ok + expires: not-a-date +` + r, err := Load(writeTemp(t, body)) + if err != nil { + t.Fatalf("should not error on bad expiry, got %v", err) + } + if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "unparseable expires") { + t.Errorf("expected unparseable-expires warning, got %v", r.Warnings) + } + // Entry still loaded; treated as no expiry. + if len(r.Entries) != 1 || !r.Entries[0].expiresAt.IsZero() { + t.Errorf("entry should load with zero expiresAt, got %+v", r.Entries) + } +} + +// ── Apply ─────────────────────────────────────────────────────────── + +func TestApply_FindingIDExactMatch(t *testing.T) { + t.Parallel() + id := identity.BuildFindingID("weakAssertion", "internal/auth/login_test.go", "TestLogin", 42) + snap := &models.TestSuiteSnapshot{ + Signals: []models.Signal{ + { + Type: "weakAssertion", + FindingID: id, + Location: models.SignalLocation{File: "internal/auth/login_test.go", Symbol: "TestLogin", Line: 42}, + }, + { + Type: "mockHeavyTest", + FindingID: "mockHeavyTest@a.go:b#diff", + Location: models.SignalLocation{File: "a.go", Line: 1}, + }, + }, + } + entries := []Entry{ + {FindingID: id, Reason: "fp"}, + } + matched, expired := Apply(snap, entries, time.Now()) + if len(expired) != 0 { + t.Errorf("no expired entries expected, got %v", expired) + } + if len(matched) != 1 { + t.Errorf("expected 1 matched entry, got %v", matched) + } + if len(snap.Signals) != 1 { + t.Errorf("expected 1 surviving signal, got %d", len(snap.Signals)) + } + if string(snap.Signals[0].Type) != "mockHeavyTest" { + t.Errorf("wrong signal survived: %+v", snap.Signals[0]) + } +} + +func TestApply_SignalTypeFileGlob(t *testing.T) { + t.Parallel() + snap := &models.TestSuiteSnapshot{ + Signals: []models.Signal{ + { + Type: "aiPromptInjectionRisk", + Location: models.SignalLocation{File: "internal/legacy/foo.go"}, + }, + { + Type: "aiPromptInjectionRisk", + Location: models.SignalLocation{File: "internal/auth/foo.go"}, + }, + { + Type: "weakAssertion", + Location: models.SignalLocation{File: "internal/legacy/foo.go"}, + }, + }, + } + entries := []Entry{ + {SignalType: "aiPromptInjectionRisk", File: "internal/legacy/**", Reason: "rewriting"}, + } + Apply(snap, entries, time.Now()) + if len(snap.Signals) != 2 { + t.Errorf("expected 2 surviving signals, got %d", len(snap.Signals)) + } + for _, s := range snap.Signals { + if string(s.Type) == "aiPromptInjectionRisk" && strings.HasPrefix(s.Location.File, "internal/legacy/") { + t.Errorf("legacy aiPromptInjectionRisk should be suppressed: %+v", s) + } + } +} + +func TestApply_ExpiredEntryDoesNotMatch(t *testing.T) { + t.Parallel() + id := identity.BuildFindingID("weakAssertion", "a.go", "X", 1) + snap := &models.TestSuiteSnapshot{ + Signals: []models.Signal{ + {Type: "weakAssertion", FindingID: id, Location: models.SignalLocation{File: "a.go", Symbol: "X", Line: 1}}, + }, + } + entries := []Entry{ + {FindingID: id, Reason: "fp", expiresAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, + } + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + matched, expired := Apply(snap, entries, now) + if len(matched) != 0 { + t.Errorf("expired entry should not match; got %v", matched) + } + if len(expired) != 1 { + t.Errorf("expected 1 expired entry, got %v", expired) + } + if len(snap.Signals) != 1 { + t.Errorf("expired entry should not suppress; signal should remain. got %d signals", len(snap.Signals)) + } +} + +func TestApply_PerFileSignals(t *testing.T) { + t.Parallel() + id := identity.BuildFindingID("weakAssertion", "a.go", "X", 1) + snap := &models.TestSuiteSnapshot{ + Signals: []models.Signal{ + {Type: "weakAssertion", FindingID: id, Location: models.SignalLocation{File: "a.go", Symbol: "X", Line: 1}}, + }, + TestFiles: []models.TestFile{ + { + Path: "a.go", + Signals: []models.Signal{ + {Type: "weakAssertion", FindingID: id, Location: models.SignalLocation{File: "a.go", Symbol: "X", Line: 1}}, + }, + }, + }, + } + entries := []Entry{{FindingID: id, Reason: "fp"}} + Apply(snap, entries, time.Now()) + if len(snap.Signals) != 0 { + t.Errorf("top-level signal should be suppressed") + } + if len(snap.TestFiles[0].Signals) != 0 { + t.Errorf("per-file signal should be suppressed") + } +} + +func TestApply_NilSafe(t *testing.T) { + t.Parallel() + matched, expired := Apply(nil, []Entry{{FindingID: "x", Reason: "y"}}, time.Now()) + if matched != nil || expired != nil { + t.Error("nil snapshot should produce nil returns") + } +} + +func TestApply_EmptyEntriesNoop(t *testing.T) { + t.Parallel() + snap := &models.TestSuiteSnapshot{ + Signals: []models.Signal{{Type: "x", FindingID: "y"}}, + } + matched, expired := Apply(snap, nil, time.Now()) + if matched != nil || expired != nil { + t.Error("nil entries should produce nil returns") + } + if len(snap.Signals) != 1 { + t.Error("nil entries should not modify snapshot") + } +} + +// ── pathMatch ─────────────────────────────────────────────────────── + +func TestPathMatch_RecursiveStarStar(t *testing.T) { + t.Parallel() + cases := []struct { + pattern, path string + want bool + }{ + {"internal/legacy/**", "internal/legacy/foo.go", true}, + {"internal/legacy/**", "internal/legacy/sub/foo.go", true}, + {"internal/legacy/**", "internal/auth/foo.go", false}, + {"**/legacy/*.go", "internal/legacy/foo.go", true}, + {"**/legacy/*.go", "deep/nested/legacy/foo.go", true}, + {"**/legacy/*.go", "internal/legacy/sub/foo.go", false}, // single star doesn't cross / + {"*.go", "foo.go", true}, + {"*.go", "sub/foo.go", false}, + } + for _, tc := range cases { + got, err := pathMatch(tc.pattern, tc.path) + if err != nil { + t.Errorf("pathMatch(%q, %q) error: %v", tc.pattern, tc.path, err) + continue + } + if got != tc.want { + t.Errorf("pathMatch(%q, %q) = %v, want %v", tc.pattern, tc.path, got, tc.want) + } + } +} + +// ── helpers ───────────────────────────────────────────────────────── + +func writeTemp(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "suppressions.yaml") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +}