Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/rules/engine/suppression-expired.md
Original file line number Diff line number Diff line change
@@ -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).

<!-- docs-gen: end stub. Hand-authored content below this line is preserved across regenerations. -->
17 changes: 17 additions & 0 deletions docs/signals/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
18 changes: 18 additions & 0 deletions internal/engine/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
}
Expand Down
91 changes: 91 additions & 0 deletions internal/engine/suppressions.go
Original file line number Diff line number Diff line change
@@ -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),
)
}
}
177 changes: 177 additions & 0 deletions internal/engine/suppressions_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
6 changes: 6 additions & 0 deletions internal/models/signal_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/signals/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion internal/signals/signal_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading