diff --git a/cmd/server/services.go b/cmd/server/services.go index 823cf49d..2628b715 100644 --- a/cmd/server/services.go +++ b/cmd/server/services.go @@ -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 diff --git a/internal/app/finding/actions.go b/internal/app/finding/actions.go index abc2184b..d8350cbc 100644 --- a/internal/app/finding/actions.go +++ b/internal/app/finding/actions.go @@ -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" @@ -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 { @@ -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 } @@ -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. @@ -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. @@ -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)) @@ -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 } diff --git a/internal/app/finding/actions_autovalidate_test.go b/internal/app/finding/actions_autovalidate_test.go new file mode 100644 index 00000000..0e1346eb --- /dev/null +++ b/internal/app/finding/actions_autovalidate_test.go @@ -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) + } +} diff --git a/internal/app/validation/run.go b/internal/app/validation/run.go index 19b25896..e1d780d5 100644 --- a/internal/app/validation/run.go +++ b/internal/app/validation/run.go @@ -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 @@ -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) diff --git a/internal/app/validation/run_test.go b/internal/app/validation/run_test.go index 72d1aace..d7c2f3b5 100644 --- a/internal/app/validation/run_test.go +++ b/internal/app/validation/run_test.go @@ -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(