Skip to content
Merged
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
5 changes: 5 additions & 0 deletions cmd/server/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,11 @@ func NewServices(deps *ServiceDeps) (*Services, error) {
// when a finding transitions to fix_applied and the user requests scan-based verification.
s.FindingActions.SetVerificationScanTrigger(app.NewVerificationScanTriggerAdapter(s.Scan))

// Closed-loop CTEM: auto-queue a proof-of-fix safe-check re-check when
// findings transition to fix_applied, so a "fixed" claim is verified rather
// than trusted. Bounded + best-effort; non-network findings are skipped.
s.FindingActions.SetAutoValidator(s.ValidationRun)

// B3 wire: when a Jira "Done" webhook arrives and the
// finding transitions to fix_applied, automatically trigger a
// verification scan via FindingActions. Per-finding 24h cooldown
Expand Down
74 changes: 68 additions & 6 deletions internal/app/finding/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ package finding
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/openctemio/api/internal/app/activity"
"regexp"

"github.com/openctemio/api/internal/app/activity"
"github.com/openctemio/api/internal/app/validation"
"github.com/openctemio/api/pkg/domain/accesscontrol"
"github.com/openctemio/api/pkg/domain/asset"
"github.com/openctemio/api/pkg/domain/group"
Expand All @@ -26,6 +28,14 @@ type VerificationScanTrigger interface {
TriggerVerificationScan(ctx context.Context, tenantID, createdBy, scannerName, workflowID string, targets []string) (pipelineRunID, scanID string, err error)
}

// AutoValidator dispatches a CTEM Stage-4 safe-check re-check for a finding and
// returns the command ID it was queued under. Implemented by
// *validation.RunService. When wired, marking findings fix_applied auto-queues a
// proof-of-fix re-check so a "fixed" claim is verified rather than trusted.
type AutoValidator interface {
ValidateFinding(ctx context.Context, tenantID, findingID shared.ID) (shared.ID, error)
}

// FindingActionsService handles the closed-loop finding lifecycle:
// in_progress → fix_applied → resolved (verified by scan or security).
type FindingActionsService struct {
Expand All @@ -35,6 +45,7 @@ type FindingActionsService struct {
assetRepo asset.Repository
activityService *activity.FindingActivityService
scanTrigger VerificationScanTrigger // optional; set via SetVerificationScanTrigger
autoValidator AutoValidator // optional; set via SetAutoValidator
db *sql.DB
logger *logger.Logger
}
Expand Down Expand Up @@ -131,6 +142,48 @@ func (s *FindingActionsService) SetVerificationScanTrigger(trigger VerificationS
s.scanTrigger = trigger
}

// SetAutoValidator wires the proof-of-fix auto-validator. When set, a successful
// fix_applied transition auto-queues a safe-check re-check per finding (bounded,
// best-effort). Optional: nil → no auto-validation (prior behavior).
func (s *FindingActionsService) SetAutoValidator(v AutoValidator) {
s.autoValidator = v
}

// maxAutoValidations bounds how many proof-of-fix re-checks a single
// fix_applied batch may auto-queue, so a large bulk remediation cannot flood
// the platform-job queue. Findings beyond the cap are left for manual
// validation (POST /findings/{id}/validate).
const maxAutoValidations = 100

// autoQueueValidations best-effort dispatches a safe-check re-check for each
// freshly fix_applied finding. Non-network assets (code/cloud/container) are
// skipped silently via ErrNotNetworkAddressable; any other error is logged and
// never affects the caller's result. Returns the number of jobs queued.
func (s *FindingActionsService) autoQueueValidations(ctx context.Context, tenantID shared.ID, findingIDs []shared.ID) int {
if s.autoValidator == nil || len(findingIDs) == 0 {
return 0
}
queued := 0
for _, fid := range findingIDs {
if queued >= maxAutoValidations {
s.logger.Info("proof-of-fix auto-validation capped",
"tenant_id", tenantID.String(), "cap", maxAutoValidations,
"remaining", len(findingIDs)-maxAutoValidations)
break
}
if _, err := s.autoValidator.ValidateFinding(ctx, tenantID, fid); err != nil {
// Non-network assets are the common, expected case — don't log noise.
if !errors.Is(err, validation.ErrNotNetworkAddressable) {
s.logger.Warn("proof-of-fix auto-validation failed",
"tenant_id", tenantID.String(), "finding_id", fid.String(), "error", err)
}
continue
}
queued++
}
return queued
}

// --- Group View ---

// ListFindingGroups returns findings grouped by a dimension.
Expand Down Expand Up @@ -184,11 +237,12 @@ type BulkFixAppliedInput struct {

// BulkFixAppliedResult is the result of bulk fix-applied operation.
type BulkFixAppliedResult struct {
Updated int `json:"updated"`
Skipped int `json:"skipped"` // not permitted / invalid transition (expected)
Failed int `json:"failed"` // persistence error — retry-worthy, distinct from Skipped
ByCVE map[string]int `json:"by_cve,omitempty"`
AssetsAffected int `json:"assets_affected"`
Updated int `json:"updated"`
Skipped int `json:"skipped"` // not permitted / invalid transition (expected)
Failed int `json:"failed"` // persistence error — retry-worthy, distinct from Skipped
ByCVE map[string]int `json:"by_cve,omitempty"`
AssetsAffected int `json:"assets_affected"`
ValidationsQueued int `json:"validations_queued"` // proof-of-fix safe-check re-checks auto-dispatched
}

// BulkFixApplied marks findings as fix_applied.
Expand Down Expand Up @@ -259,6 +313,7 @@ func (s *FindingActionsService) BulkFixApplied(
// Fetch all findings first to preload related data
result := &BulkFixAppliedResult{ByCVE: make(map[string]int)}
assetSet := make(map[shared.ID]bool)
fixedIDs := make([]shared.ID, 0, int(count)) // findings that reached fix_applied → auto-validate

// Collect all findings (cap already checked at 1000)
allFindings := make([]*vulnerability.Finding, 0, int(count))
Expand Down Expand Up @@ -326,9 +381,16 @@ func (s *FindingActionsService) BulkFixApplied(
result.Updated++
result.ByCVE[f.CVEID()]++
assetSet[f.AssetID()] = true
fixedIDs = append(fixedIDs, f.ID())
}

result.AssetsAffected = len(assetSet)

// Closed-loop CTEM: auto-queue a proof-of-fix safe-check re-check per fixed
// finding so a "fix applied" claim is verified, not trusted. Bounded and
// best-effort — never affects the fix_applied result above.
result.ValidationsQueued = s.autoQueueValidations(ctx, tid, fixedIDs)

return result, nil
}

Expand Down
86 changes: 86 additions & 0 deletions internal/app/finding/actions_autovalidate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package finding

import (
"context"
"testing"

"github.com/openctemio/api/internal/app/validation"
"github.com/openctemio/api/pkg/domain/shared"
"github.com/openctemio/api/pkg/logger"
)

// recordingAutoValidator records each ValidateFinding call and returns a
// per-call error keyed by call index (nil = success).
type recordingAutoValidator struct {
calls []shared.ID
errAt map[int]error
}

func (v *recordingAutoValidator) ValidateFinding(_ context.Context, _, findingID shared.ID) (shared.ID, error) {
idx := len(v.calls)
v.calls = append(v.calls, findingID)
if v.errAt != nil {
if err, ok := v.errAt[idx]; ok {
return shared.ID{}, err
}
}
return shared.NewID(), nil
}

func newFindingIDs(n int) []shared.ID {
ids := make([]shared.ID, n)
for i := range ids {
ids[i] = shared.NewID()
}
return ids
}

func TestAutoQueueValidations_NilValidator(t *testing.T) {
s := &FindingActionsService{logger: logger.NewNop()}
if got := s.autoQueueValidations(context.Background(), shared.NewID(), newFindingIDs(3)); got != 0 {
t.Fatalf("queued = %d, want 0 when no validator wired", got)
}
}

func TestAutoQueueValidations_QueuesEachFinding(t *testing.T) {
av := &recordingAutoValidator{}
s := &FindingActionsService{logger: logger.NewNop(), autoValidator: av}

ids := newFindingIDs(5)
got := s.autoQueueValidations(context.Background(), shared.NewID(), ids)
if got != 5 {
t.Fatalf("queued = %d, want 5", got)
}
if len(av.calls) != 5 {
t.Fatalf("validator called %d times, want 5", len(av.calls))
}
}

func TestAutoQueueValidations_SkipsNonNetworkAndCountsRest(t *testing.T) {
av := &recordingAutoValidator{errAt: map[int]error{
0: validation.ErrNotNetworkAddressable, // code finding — expected skip
2: validation.ErrNotNetworkAddressable,
}}
s := &FindingActionsService{logger: logger.NewNop(), autoValidator: av}

got := s.autoQueueValidations(context.Background(), shared.NewID(), newFindingIDs(4))
if got != 2 {
t.Fatalf("queued = %d, want 2 (4 findings − 2 non-network)", got)
}
if len(av.calls) != 4 {
t.Fatalf("validator should still be attempted for all 4, got %d", len(av.calls))
}
}

func TestAutoQueueValidations_CapsAtMax(t *testing.T) {
av := &recordingAutoValidator{}
s := &FindingActionsService{logger: logger.NewNop(), autoValidator: av}

got := s.autoQueueValidations(context.Background(), shared.NewID(), newFindingIDs(maxAutoValidations+50))
if got != maxAutoValidations {
t.Fatalf("queued = %d, want cap %d", got, maxAutoValidations)
}
if len(av.calls) != maxAutoValidations {
t.Fatalf("validator called %d times, want cap %d (must stop, not flood)", len(av.calls), maxAutoValidations)
}
}
31 changes: 31 additions & 0 deletions internal/app/validation/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,33 @@ const defaultTimeoutSeconds = 120
// safe-check kind is allowed to run (see kindSupportsTechnique).
const safeCheckTechnique TechniqueID = "T1046"

// ErrNotNetworkAddressable is returned when a finding's asset has no network
// address a safe-check reachability probe can dial (e.g. a code repository,
// container image, or cloud-account finding). Callers that auto-dispatch
// validation (e.g. proof-of-fix on fix_applied) treat it as an expected skip,
// not a failure.
var ErrNotNetworkAddressable = fmt.Errorf("%w: asset is not network-addressable for a safe-check re-check", shared.ErrValidation)

// networkAddressableTypes is the set of asset types whose Name() is a host,
// IP, or URL a safe-check probe can reach over the network. Types outside this
// set (repository, container, cloud_account, …) cannot be reachability-probed.
var networkAddressableTypes = map[asset.AssetType]bool{
asset.AssetTypeDomain: true,
asset.AssetTypeSubdomain: true,
asset.AssetTypeIPAddress: true,
asset.AssetTypeWebsite: true,
asset.AssetTypeWebApplication: true,
asset.AssetTypeAPI: true,
asset.AssetTypeService: true,
asset.AssetTypeHost: true,
}

// isNetworkAddressable reports whether a safe-check reachability probe can
// meaningfully target an asset of the given type.
func isNetworkAddressable(t asset.AssetType) bool {
return networkAddressableTypes[t]
}

// RunService turns "validate this finding" into a dispatched validation job.
// It resolves the finding's asset into a Target, picks an executor kind via the
// Selector against the fleet's available kinds, and hands the job to the
Expand Down Expand Up @@ -85,6 +112,10 @@ func (s *RunService) ValidateFinding(ctx context.Context, tenantID, findingID sh
return shared.ID{}, fmt.Errorf("asset lookup: %w", err)
}

if !isNetworkAddressable(a.Type()) {
return shared.ID{}, ErrNotNetworkAddressable
}

address := strings.TrimSpace(a.Name())
if address == "" {
return shared.ID{}, fmt.Errorf("%w: asset has no address to validate against", shared.ErrValidation)
Expand Down
28 changes: 28 additions & 0 deletions internal/app/validation/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,34 @@ func TestRunService_ValidateFinding_NoExecutorAvailable(t *testing.T) {
}
}

func TestRunService_ValidateFinding_RejectsNonNetworkAsset(t *testing.T) {
assetID := shared.NewID()
f := newTestFinding(t, assetID)
// A code repository has no host/IP a safe-check probe can dial.
repo, err := asset.NewAsset("github.com/acme/app", asset.AssetTypeRepository, asset.CriticalityHigh)
if err != nil {
t.Fatalf("new asset: %v", err)
}
disp := &fakeJobDispatcher{id: shared.NewID()}

svc := NewRunService(
fakeFindingLookup{f: f},
fakeAssetLookup{a: repo},
disp,
DefaultSelector{},
[]ExecutorKind{KindSafeCheck},
logger.NewNop(),
)

_, err = svc.ValidateFinding(context.Background(), shared.NewID(), f.ID())
if !errors.Is(err, ErrNotNetworkAddressable) {
t.Fatalf("error = %v, want ErrNotNetworkAddressable", err)
}
if disp.got.FindingID != (shared.ID{}) {
t.Error("dispatcher should not be called for a non-network asset")
}
}

func TestRunService_ValidateFinding_PropagatesFindingLookupError(t *testing.T) {
disp := &fakeJobDispatcher{}
svc := NewRunService(
Expand Down