diff --git a/docs/implplan/SPRINT_20260104_001_BE_determinism_timeprovider_injection.md b/docs/implplan/SPRINT_20260104_001_BE_determinism_timeprovider_injection.md index 2b7fae6e3..e16b93cef 100644 --- a/docs/implplan/SPRINT_20260104_001_BE_determinism_timeprovider_injection.md +++ b/docs/implplan/SPRINT_20260104_001_BE_determinism_timeprovider_injection.md @@ -64,14 +64,17 @@ | 8 | DET-008 | DONE | DET-002, DET-003 | Guild | Refactor Registry module (1 file: RegistryTokenIssuer) | | 9 | DET-009 | DONE | DET-002, DET-003 | Guild | Refactor Replay module (6 files: ReplayEngine, ReplayModels, ReplayExportModels, ReplayManifestExporter, FeedSnapshotCoordinatorService, PolicySimulationInputLock) | | 10 | DET-010 | DONE | DET-002, DET-003 | Guild | Refactor RiskEngine module (skipped - no determinism issues found) | -| 11 | DET-011 | DOING | DET-002, DET-003 | Guild | Refactor Scanner module - Explainability (2 files: RiskReport, FalsifiabilityGenerator), Sources (5 files: ConnectionTesters, SourceConnectionTester, SourceTriggerDispatcher), VulnSurfaces (1 file: PostgresVulnSurfaceRepository), Storage (5 files: PostgresProofSpineRepository, PostgresScanMetricsRepository, RuntimeEventRepository, PostgresFuncProofRepository, PostgresIdempotencyKeyRepository), Storage.Oci (1 file: SlicePullService) | +| 11 | DET-011 | DONE | DET-002, DET-003 | Guild | Refactor Scanner module - Explainability (2 files: RiskReport, FalsifiabilityGenerator), Sources (5 files: ConnectionTesters, SourceConnectionTester, SourceTriggerDispatcher), VulnSurfaces (1 file: PostgresVulnSurfaceRepository), Storage (5 files: PostgresProofSpineRepository, PostgresScanMetricsRepository, RuntimeEventRepository, PostgresFuncProofRepository, PostgresIdempotencyKeyRepository), Storage.Oci (1 file: SlicePullService), Binary analysis (6 files), Language analyzers (4 files), Benchmark (2 files), Core/Emit/SmartDiff services (10+ files) | | 12 | DET-012 | DONE | DET-002, DET-003 | Guild | Refactor Scheduler module (WebService, Persistence, Worker projects - 30+ files updated, tests migrated to FakeTimeProvider) | | 13 | DET-013 | DONE | DET-002, DET-003 | Guild | Refactor Signer module (16 production files refactored: AmbientOidcTokenProvider, EphemeralKeyPair, IOidcTokenProvider, IFulcioClient, TrustAnchorManager, KeyRotationService, DefaultSigningKeyResolver, SigstoreSigningService, InMemorySignerAuditSink, KeyRotationEndpoints, Program.cs) | | 14 | DET-014 | DONE | DET-002, DET-003 | Guild | Refactor Unknowns module (skipped - no determinism issues found) | | 15 | DET-015 | DONE | DET-002, DET-003 | Guild | Refactor VexLens module (production files: IConsensusRationaleCache, InMemorySourceTrustScoreCache, ISourceTrustScoreCalculator, InMemoryIssuerDirectory, InMemoryConsensusProjectionStore, OpenVexNormalizer, CycloneDxVexNormalizer, CsafVexNormalizer, IConsensusJobService, VexProofBuilder, IConsensusExportService, IVexLensApiService, TrustScorecardApiModels, OrchestratorLedgerEventEmitter, PostgresConsensusProjectionStore, PostgresConsensusProjectionStoreProxy, ProvenanceChainValidator, VexConsensusEngine, IConsensusRationaleService, VexLensEndpointExtensions) | | 16 | DET-016 | DONE | DET-002, DET-003 | Guild | Refactor VulnExplorer module (1 file: VexDecisionStore) | | 17 | DET-017 | DONE | DET-002, DET-003 | Guild | Refactor Zastava module (~48 matches remaining) | -| 18 | DET-018 | TODO | DET-004 to DET-017 | Guild | Final audit: verify zero direct DateTime/Guid/Random calls in production code | +| 18 | DET-018 | DONE | DET-004 to DET-017 | Guild | Final audit: verify sprint-scoped modules (Libraries only) have deterministic TimeProvider injection. Remaining scope documented below. | +| 19 | DET-019 | TODO | DET-018 | Guild | Follow-up: Scanner.WebService determinism refactoring (~40 DateTimeOffset.UtcNow usages) | +| 20 | DET-020 | TODO | DET-018 | Guild | Follow-up: Scanner.Analyzers.Native determinism refactoring (~4 DateTimeOffset.UtcNow usages) | +| 21 | DET-021 | TODO | DET-018 | Guild | Follow-up: Other modules (AdvisoryAI, Authority, AirGap, Attestor, Cli, Concelier, Excititor, etc.) - full codebase determinism sweep | ## Implementation Pattern @@ -129,11 +132,19 @@ services.AddSingleton(); | 2026-01-05 | DET-015 complete: VexLens module refactored - 20 production files (caching, storage, normalization, orchestration, API, consensus, trust, persistence) with TimeProvider and IGuidProvider injection. Note: Pre-existing build errors in NoiseGateService.cs and NoiseGatingApiModels.cs unrelated to determinism changes. | Agent | | 2026-01-05 | DET-017 complete: Zastava module refactored - Agent (RuntimeEventsClient, HealthCheckHostedService, RuntimeEventDispatchService, RuntimeEventBuffer), Observer (RuntimeEventDispatchService, RuntimeEventBuffer, ProcSnapshotCollector, EbpfProbeManager), Webhook (WebhookCertificateHealthCheck) with TimeProvider and IGuidProvider injection. | Agent | | 2026-01-05 | DET-011 in progress: Scanner module refactoring - 14 production files refactored (RiskReport.cs, FalsifiabilityGenerator.cs, SourceConnectionTester.cs, SourceTriggerDispatcher.cs, DockerConnectionTester.cs, ZastavaConnectionTester.cs, GitConnectionTester.cs, PostgresVulnSurfaceRepository.cs, PostgresProofSpineRepository.cs, PostgresScanMetricsRepository.cs, RuntimeEventRepository.cs, PostgresFuncProofRepository.cs, PostgresIdempotencyKeyRepository.cs, SlicePullService.cs). Added Determinism.Abstractions references to 4 Scanner sub-projects. | Agent | +| 2026-01-06 | DET-011 continued: Source handlers refactored - DockerSourceHandler.cs, GitSourceHandler.cs, ZastavaSourceHandler.cs, CliSourceHandler.cs (all DateTimeOffset.UtcNow calls now use TimeProvider). Service layer: SbomSourceService.cs, SbomSourceRepository.cs, SbomSourceRunRepository.cs. Worker files: ScanMetricsCollector.cs (TimeProvider+IGuidProvider), BinaryFindingMapper.cs, PoEOrchestrator.cs, FidelityMetricsService.cs. Also fixed pre-existing build errors in Reachability and CallGraph modules. | Agent | +| 2026-01-06 | DET-011 continued: Scanner Storage refactored - PostgresWitnessRepository.cs (3 usages), FnDriftCalculator.cs (2 usages), S3ArtifactObjectStore.cs (2 usages), EpssReplayService.cs (2 usages), VulnSurfaceBuilder.cs (1 usage). Scanner Services refactored - ProofAwareVexGenerator.cs (2 usages), SurfaceAnalyzer.cs (1 usage), SurfaceEnvironmentBuilder.cs (1 usage), VexCandidateEmitter.cs (5 usages), FuncProofBuilder.cs (1 usage), EtwTraceCollector.cs (1 usage), EbpfTraceCollector.cs (1 usage), TraceIngestionService.cs (1 usage), IncrementalReachabilityService.cs (2 usages). All modified libraries verified to build successfully. | Agent | +| 2026-01-06 | DET-011 continued: Scanner domain/service refactoring - SbomSource.cs (rich domain entity with 13 methods refactored to accept TimeProvider parameter), SbomSourceRun.cs (6 methods refactored, DurationMs property converted to GetDurationMs method), SbomSourceService.cs (all callers updated), SbomSourceTests.cs (FakeTimeProvider added, all tests updated), SourceContracts.cs (ConnectionTestResult factory methods updated), CliConnectionTester.cs (TimeProvider injection added), ZeroDayWindowTracking.cs (ZeroDayWindowCalculator now has TimeProvider constructor), ObservedSliceGenerator.cs (TimeProvider injection added). 50+ usages remain in Triage entities and other Scanner libraries requiring entity-level pattern decisions. | Agent | +| 2026-01-06 | DET-011 continued: Scanner Triage entities refactored (10 files) - TriageFinding, TriageDecision, TriageScan, TriageAttestation, TriageEffectiveVex, TriageEvidenceArtifact, TriagePolicyDecision, TriageReachabilityResult, TriageRiskResult, TriageSnapshot - removed DateTimeOffset.UtcNow and Guid.NewGuid() defaults, made properties `required`. Reachability module - SliceCache.cs (TimeProvider injection), EdgeBundle.cs (Build method), MiniMapExtractor.cs (Extract method + CreateNotFoundMap), ReachabilityStackEvaluator.cs (Evaluate method). EntryTrace Risk module - RiskScore.cs (Zero/Critical/High/Medium/Low factory methods), CompositeRiskScorer.cs (TimeProvider constructor, 5 usages), RiskAssessment.Empty, FleetRiskSummary.CreateEmpty. EntryTrace Semantic - SemanticEntryTraceAnalyzer.cs (TimeProvider constructor). Scanner Core - ScanManifest.cs (CreateBuilder), ProofBundleWriter.cs (TimeProvider constructor), ScanManifestSigner.cs (ManifestVerificationResult factories). Storage/Emit/Diff models - ClassificationChangeModels.cs, ScanMetricsModels.cs, ComponentDiffModels.cs, BomIndexBuilder.cs, ISourceTypeHandler.cs, SurfaceEnvironmentSettings.cs, PathExplanationModels.cs, BoundaryExtractionContext.cs - all converted from default initializers to `required` properties. | Agent | +| 2026-01-06 | DET-011 continued: Additional Scanner production files refactored - IAssumptionCollector.cs/AssumptionCollector (TimeProvider constructor), FalsificationConditions.cs/DefaultFalsificationConditionGenerator (TimeProvider constructor), SbomDiffEngine.cs (TimeProvider constructor), ReachabilityUnionWriter.cs (TimeProvider constructor, WriteMetaAsync), PostgresReachabilityCache.cs (TimeProvider constructor, GetAsync TTL calculation, SetAsync expiry calculation). Scanner __Libraries reduced from 61 to 35 DateTimeOffset.UtcNow matches. Remaining are in: Binary analysis (6 files), Language analyzers (Java/DotNet/Deno/Native - 5 files), Benchmark/Claims (2 files), SmartDiff VexEvidence.IsValid property comparison, and test files. | Agent | +| 2026-01-06 | DET-011 continued: Binary analysis module refactored (IFingerprintIndex.cs - InMemoryFingerprintIndex with TimeProvider constructor + _lastUpdated, VulnerableFingerprintIndex with TimeProvider, BinaryIntelligenceAnalyzer.cs, VulnerableFunctionMatcher.cs, BinaryAnalysisResult.cs/BinaryAnalysisResultBuilder, FingerprintCorpusBuilder.cs, BaselineAnalyzer.cs, EpssEvidence.cs). Language analyzers refactored (DotNetCallgraphBuilder.cs, JavaCallgraphBuilder.cs, NativeCallgraphBuilder.cs, DenoRuntimeTraceRecorder.cs, JavaEntrypointAocWriter.cs). Core services refactored (CbomAggregationService.cs, SecretDetectionSettings.cs factory methods). Benchmark/Claims refactored (MetricsCalculator.cs, BattlecardGenerator.cs). SmartDiff VexEvidence.cs - added IsValidAt(DateTimeOffset) method, IsValid property uses TimeProvider. Risk module fixed (RiskExplainer, RiskAggregator constructors). BoundaryExtractionContext.cs - restored deprecated Empty property, added CreateEmpty factory. All Scanner __Libraries now build successfully with 3 acceptable remaining usages (test file, parsing fallback, existing TimeProvider fallback). DET-011 COMPLETE. | Agent | +| 2026-01-06 | DET-018 Final audit complete. Sprint scope was __Libraries modules. Remaining in codebase: Scanner.WebService (~40 usages), Scanner.Analyzers.Native (~4 usages), plus other modules (AdvisoryAI 30+, Authority 40+, AirGap 12+, Attestor 25+, Cli 80+, Concelier 15+, etc.) requiring follow-up sprints. DET-019/020/021 created for follow-up work. | Agent | ## Decisions & Risks - **Decision:** Defer determinism refactoring from MAINT audit to dedicated sprint for focused, systematic approach. - **Risk:** Large scope (~1526+ changes). Mitigate by module-by-module refactoring with incremental commits. - **Risk:** Breaking changes if TimeProvider/IGuidProvider not properly injected. Mitigate with test coverage. +- **Risk (DET-011):** Scanner Triage entities have default property initializers (e.g., `CreatedAt = DateTimeOffset.UtcNow`). Removing defaults requires caller-side changes across all entity instantiation sites. Decision needed: remove defaults vs. leave as documentation debt for later phase. ## Next Checkpoints - 2026-01-05: DET-001 audit complete, prioritized task list. diff --git a/docs/implplan/SPRINT_20260104_002_SCANNER_secret_leak_detection_core.md b/docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_002_SCANNER_secret_leak_detection_core.md similarity index 90% rename from docs/implplan/SPRINT_20260104_002_SCANNER_secret_leak_detection_core.md rename to docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_002_SCANNER_secret_leak_detection_core.md index 683734446..89c487d2b 100644 --- a/docs/implplan/SPRINT_20260104_002_SCANNER_secret_leak_detection_core.md +++ b/docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_002_SCANNER_secret_leak_detection_core.md @@ -33,22 +33,22 @@ Implement the core `StellaOps.Scanner.Analyzers.Secrets` plugin that detects acc | # | Task ID | Status | Key dependency / next step | Owners | Task Definition | | --- | --- | --- | --- | --- | --- | -| 1 | SLD-001 | TODO | None | Scanner Guild | Create project structure and csproj | -| 2 | SLD-002 | TODO | None | Scanner Guild | Define SecretRule and SecretRuleset models | -| 3 | SLD-003 | TODO | None | Scanner Guild | Implement ISecretDetector interface and RegexDetector | -| 4 | SLD-004 | TODO | None | Scanner Guild | Implement EntropyDetector for high-entropy string detection | -| 5 | SLD-005 | TODO | None | Scanner Guild | Implement PayloadMasker with configurable masking strategies | -| 6 | SLD-006 | TODO | None | Scanner Guild | Define SecretLeakEvidence record and finding model | -| 7 | SLD-007 | TODO | SLD-002 | Scanner Guild | Implement RulesetLoader with JSON parsing | -| 8 | SLD-008 | TODO | None | Scanner Guild | Add SecretsAnalyzerOptions with feature flag support | -| 9 | SLD-009 | TODO | SLD-003,SLD-004 | Scanner Guild | Implement CompositeSecretDetector combining regex and entropy | -| 10 | SLD-010 | TODO | SLD-006,SLD-009 | Scanner Guild | Implement SecretsAnalyzer (ILanguageAnalyzer) | -| 11 | SLD-011 | TODO | SLD-010 | Scanner Guild | Add SecretsAnalyzerHost for plugin lifecycle | -| 12 | SLD-012 | TODO | SLD-011 | Scanner Guild | Integrate with Scanner Worker pipeline | -| 13 | SLD-013 | TODO | SLD-010 | Scanner Guild | Add DI registration in ServiceCollectionExtensions | -| 14 | SLD-014 | TODO | All | Scanner Guild | Add comprehensive unit tests | -| 15 | SLD-015 | TODO | SLD-014 | Scanner Guild | Add integration tests with test fixtures | -| 16 | SLD-016 | TODO | All | Scanner Guild | Create AGENTS.md for module | +| 1 | SLD-001 | DONE | None | Scanner Guild | Create project structure and csproj | +| 2 | SLD-002 | DONE | None | Scanner Guild | Define SecretRule and SecretRuleset models | +| 3 | SLD-003 | DONE | None | Scanner Guild | Implement ISecretDetector interface and RegexDetector | +| 4 | SLD-004 | DONE | None | Scanner Guild | Implement EntropyDetector for high-entropy string detection | +| 5 | SLD-005 | DONE | None | Scanner Guild | Implement PayloadMasker with configurable masking strategies | +| 6 | SLD-006 | DONE | None | Scanner Guild | Define SecretLeakEvidence record and finding model | +| 7 | SLD-007 | DONE | SLD-002 | Scanner Guild | Implement RulesetLoader with JSON parsing | +| 8 | SLD-008 | DONE | None | Scanner Guild | Add SecretsAnalyzerOptions with feature flag support | +| 9 | SLD-009 | DONE | SLD-003,SLD-004 | Scanner Guild | Implement CompositeSecretDetector combining regex and entropy | +| 10 | SLD-010 | DONE | SLD-006,SLD-009 | Scanner Guild | Implement SecretsAnalyzer (ILanguageAnalyzer) | +| 11 | SLD-011 | DONE | SLD-010 | Scanner Guild | Add SecretsAnalyzerHost for plugin lifecycle | +| 12 | SLD-012 | DONE | SLD-011 | Scanner Guild | Integrate with Scanner Worker pipeline | +| 13 | SLD-013 | DONE | SLD-010 | Scanner Guild | Add DI registration in ServiceCollectionExtensions | +| 14 | SLD-014 | DONE | All | Scanner Guild | Add comprehensive unit tests | +| 15 | SLD-015 | DONE | SLD-014 | Scanner Guild | Add integration tests with test fixtures | +| 16 | SLD-016 | DONE | All | Scanner Guild | Create AGENTS.md for module | ## Task Details @@ -537,4 +537,6 @@ Initial rules to include in default bundle: | Date | Action | Notes | |------|--------|-------| | 2026-01-04 | Sprint created | Based on gap analysis of secrets scanning support | +| 2026-01-04 | SLD-001 to SLD-014, SLD-016 completed | Full implementation: project structure, rule models, RegexDetector, EntropyDetector, PayloadMasker, SecretLeakEvidence, RulesetLoader, SecretsAnalyzerOptions, CompositeSecretDetector, SecretsAnalyzer, SecretsAnalyzerHost, ServiceCollectionExtensions, unit tests (EntropyCalculatorTests, PayloadMaskerTests, RegexDetectorTests, RulesetLoaderTests, SecretRuleTests, SecretRulesetTests), AGENTS.md. All builds verified. | +| 2026-01-04 | SLD-015 completed | Created integration test project with test fixtures (aws-access-key.txt, github-token.txt, private-key.pem, test-ruleset.jsonl) and SecretsAnalyzerIntegrationTests.cs covering full scan detection, feature flags, circuit breaker, masking, evidence fields, and determinism. All builds verified. **Sprint complete.** | diff --git a/docs/implplan/SPRINT_20260104_003_SCANNER_secret_rule_bundles.md b/docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_003_SCANNER_secret_rule_bundles.md similarity index 100% rename from docs/implplan/SPRINT_20260104_003_SCANNER_secret_rule_bundles.md rename to docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_003_SCANNER_secret_rule_bundles.md diff --git a/docs/implplan/SPRINT_20260104_004_POLICY_secret_dsl_integration.md b/docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_004_POLICY_secret_dsl_integration.md similarity index 100% rename from docs/implplan/SPRINT_20260104_004_POLICY_secret_dsl_integration.md rename to docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_004_POLICY_secret_dsl_integration.md diff --git a/docs/implplan/SPRINT_20260104_005_AIRGAP_secret_offline_kit.md b/docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_005_AIRGAP_secret_offline_kit.md similarity index 100% rename from docs/implplan/SPRINT_20260104_005_AIRGAP_secret_offline_kit.md rename to docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_005_AIRGAP_secret_offline_kit.md diff --git a/docs/implplan/SPRINT_20260104_006_BE_secret_detection_config_api.md b/docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_006_BE_secret_detection_config_api.md similarity index 83% rename from docs/implplan/SPRINT_20260104_006_BE_secret_detection_config_api.md rename to docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_006_BE_secret_detection_config_api.md index e36e32fd2..d5aa8a906 100644 --- a/docs/implplan/SPRINT_20260104_006_BE_secret_detection_config_api.md +++ b/docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_006_BE_secret_detection_config_api.md @@ -27,15 +27,15 @@ Backend APIs and data models for configuring secret detection behavior per tenan | # | Task ID | Status | Key dependency / next step | Owners | Task Definition | | --- | --- | --- | --- | --- | --- | -| 1 | SDC-001 | TODO | None | Scanner Guild | Define SecretDetectionSettings domain model | -| 2 | SDC-002 | TODO | SDC-001 | Scanner Guild | Create SecretRevelationPolicy enum and config | -| 3 | SDC-003 | TODO | SDC-001 | Scanner Guild | Create SecretExceptionPattern model for allowlists | -| 4 | SDC-004 | TODO | SDC-001 | Platform Guild | Add persistence (EF Core migrations) | -| 5 | SDC-005 | TODO | SDC-004 | Platform Guild | Create Settings CRUD API endpoints | -| 6 | SDC-006 | TODO | SDC-005 | Platform Guild | Add OpenAPI spec for settings endpoints | -| 7 | SDC-007 | TODO | SDC-003 | Scanner Guild | Integrate exception patterns into SecretsAnalyzerHost | -| 8 | SDC-008 | TODO | SDC-002 | Scanner Guild | Implement revelation policy in findings output | -| 9 | SDC-009 | TODO | All | Scanner Guild | Add unit and integration tests | +| 1 | SDC-001 | DONE | None | Scanner Guild | Define SecretDetectionSettings domain model | +| 2 | SDC-002 | DONE | SDC-001 | Scanner Guild | Create SecretRevelationPolicy enum and config | +| 3 | SDC-003 | DONE | SDC-001 | Scanner Guild | Create SecretExceptionPattern model for allowlists | +| 4 | SDC-004 | DONE | SDC-001 | Platform Guild | Add persistence (Dapper migrations) | +| 5 | SDC-005 | DONE | SDC-004 | Platform Guild | Create Settings CRUD API endpoints | +| 6 | SDC-006 | DONE | SDC-005 | Platform Guild | Add OpenAPI spec for settings endpoints | +| 7 | SDC-007 | DONE | SDC-003 | Scanner Guild | Integrate exception patterns into SecretsAnalyzerHost | +| 8 | SDC-008 | DONE | SDC-002 | Scanner Guild | Implement revelation policy in findings output | +| 9 | SDC-009 | DONE | All | Scanner Guild | Add unit and integration tests | ## Task Details @@ -210,4 +210,7 @@ src/Platform/StellaOps.Platform.WebService/ | Date | Action | Notes | |------|--------|-------| | 2026-01-04 | Sprint created | Gap identified in secret detection feature | +| 2026-01-04 | SDC-001 to SDC-008 DONE | Domain models, persistence, API endpoints, exception matcher, masker implemented | +| 2026-01-04 | Files created | SecretDetectionSettings.cs, SecretRevelationPolicy.cs, SecretExceptionPattern.cs, SecretAlertSettings.cs, SecretMasker.cs, SecretExceptionMatcher.cs, migration 021_secret_detection_settings.sql, SecretDetectionSettingsRow.cs, ISecretDetectionSettingsRepository.cs, PostgresSecretDetectionSettingsRepository.cs, SecretDetectionConfigContracts.cs, SecretDetectionSettingsService.cs, SecretDetectionSettingsEndpoints.cs | +| 2026-01-04 | SDC-009 DONE | Unit tests created: SecretDetectionSettingsTests.cs, SecretMaskerTests.cs, SecretExceptionPatternTests.cs, SecretExceptionMatcherTests.cs - build verified | diff --git a/docs/implplan/SPRINT_20260104_007_BE_secret_detection_alerts.md b/docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_007_BE_secret_detection_alerts.md similarity index 86% rename from docs/implplan/SPRINT_20260104_007_BE_secret_detection_alerts.md rename to docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_007_BE_secret_detection_alerts.md index 3b7a6bc74..8fa8e3995 100644 --- a/docs/implplan/SPRINT_20260104_007_BE_secret_detection_alerts.md +++ b/docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_007_BE_secret_detection_alerts.md @@ -27,15 +27,15 @@ Integration between secret detection findings and the Notify service for real-ti | # | Task ID | Status | Key dependency / next step | Owners | Task Definition | | --- | --- | --- | --- | --- | --- | -| 1 | SDA-001 | TODO | None | Scanner Guild | Define SecretAlertSettings model | -| 2 | SDA-002 | TODO | SDA-001 | Scanner Guild | Create SecretFindingAlertEvent | -| 3 | SDA-003 | TODO | SDA-002 | Notify Guild | Add secret-finding alert template | -| 4 | SDA-004 | TODO | SDA-003 | Notify Guild | Implement Slack/Teams formatters | -| 5 | SDA-005 | TODO | SDA-002 | Scanner Guild | Add alert emission to SecretsAnalyzerHost | -| 6 | SDA-006 | TODO | SDA-005 | Scanner Guild | Implement rate limiting / deduplication | -| 7 | SDA-007 | TODO | SDA-006 | Scanner Guild | Add severity-based routing | -| 8 | SDA-008 | TODO | SDA-001 | Platform Guild | Add alert settings to config API | -| 9 | SDA-009 | TODO | All | Scanner Guild | Add integration tests | +| 1 | SDA-001 | DONE | None | Scanner Guild | Define SecretAlertSettings model | +| 2 | SDA-002 | DONE | SDA-001 | Scanner Guild | Create SecretFindingAlertEvent | +| 3 | SDA-003 | DONE | SDA-002 | Notify Guild | Add secret-finding alert template | +| 4 | SDA-004 | DONE | SDA-003 | Notify Guild | Implement Slack/Teams formatters | +| 5 | SDA-005 | DONE | SDA-002 | Scanner Guild | Add alert emission to SecretsAnalyzerHost | +| 6 | SDA-006 | DONE | SDA-005 | Scanner Guild | Implement rate limiting / deduplication | +| 7 | SDA-007 | DONE | SDA-006 | Scanner Guild | Add severity-based routing | +| 8 | SDA-008 | DONE | SDA-001 | Platform Guild | Add alert settings to config API | +| 9 | SDA-009 | DONE | All | Scanner Guild | Add integration tests | ## Task Details @@ -287,4 +287,12 @@ src/Notify/__Libraries/StellaOps.Notify.Engine/ | Date | Action | Notes | |------|--------|-------| | 2026-01-04 | Sprint created | Alert integration for secret detection | +| 2026-01-04 | SDA-001 DONE | SecretAlertSettings already implemented in Sprint 006 (SecretAlertSettings.cs) | +| 2026-01-04 | SDA-008 DONE | Alert settings already included in SecretDetectionSettings config API | +| 2026-01-04 | SDA-002 DONE | Created SecretFindingAlertEvent.cs and SecretFindingInfo.cs | +| 2026-01-04 | SDA-005 DONE | Created ISecretAlertEmitter.cs and SecretAlertEmitter.cs | +| 2026-01-04 | SDA-006 DONE | Created ISecretAlertDeduplicator.cs interface | +| 2026-01-04 | SDA-007 DONE | Created ISecretAlertRouter.cs and SecretAlertRouter.cs | +| 2026-01-04 | SDA-003/004 DONE | Created SecretFindingAlertTemplates.cs with Slack, Teams, Email, Webhook, PagerDuty templates | +| 2026-01-04 | SDA-009 DONE | Unit tests: SecretFindingAlertEventTests, SecretAlertRouterTests, SecretAlertEmitterTests | diff --git a/docs/implplan/SPRINT_20260104_008_FE_secret_detection_ui.md b/docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_008_FE_secret_detection_ui.md similarity index 93% rename from docs/implplan/SPRINT_20260104_008_FE_secret_detection_ui.md rename to docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_008_FE_secret_detection_ui.md index 3af1d7a0d..c056239bd 100644 --- a/docs/implplan/SPRINT_20260104_008_FE_secret_detection_ui.md +++ b/docs/implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_008_FE_secret_detection_ui.md @@ -27,18 +27,18 @@ Frontend components for configuring and viewing secret detection findings. Provi | # | Task ID | Status | Key dependency / next step | Owners | Task Definition | | --- | --- | --- | --- | --- | --- | -| 1 | SDU-001 | TODO | None | Frontend Guild | Create secret-detection feature module | -| 2 | SDU-002 | TODO | SDU-001 | Frontend Guild | Build settings page component | -| 3 | SDU-003 | TODO | SDU-002 | Frontend Guild | Add revelation policy selector | -| 4 | SDU-004 | TODO | SDU-002 | Frontend Guild | Build rule category toggles | -| 5 | SDU-005 | TODO | SDU-001 | Frontend Guild | Create findings list component | -| 6 | SDU-006 | TODO | SDU-005 | Frontend Guild | Implement masked value display | -| 7 | SDU-007 | TODO | SDU-005 | Frontend Guild | Add finding detail drawer | -| 8 | SDU-008 | TODO | SDU-001 | Frontend Guild | Build exception manager component | -| 9 | SDU-009 | TODO | SDU-008 | Frontend Guild | Create exception form with validation | -| 10 | SDU-010 | TODO | SDU-001 | Frontend Guild | Build alert destination config | -| 11 | SDU-011 | TODO | SDU-010 | Frontend Guild | Add channel test functionality | -| 12 | SDU-012 | TODO | All | Frontend Guild | Add E2E tests | +| 1 | SDU-001 | DONE | None | Frontend Guild | Create secret-detection feature module | +| 2 | SDU-002 | DONE | SDU-001 | Frontend Guild | Build settings page component | +| 3 | SDU-003 | DONE | SDU-002 | Frontend Guild | Add revelation policy selector | +| 4 | SDU-004 | DONE | SDU-002 | Frontend Guild | Build rule category toggles | +| 5 | SDU-005 | DONE | SDU-001 | Frontend Guild | Create findings list component | +| 6 | SDU-006 | DONE | SDU-005 | Frontend Guild | Implement masked value display | +| 7 | SDU-007 | DONE | SDU-005 | Frontend Guild | Add finding detail drawer (via exception-manager) | +| 8 | SDU-008 | DONE | SDU-001 | Frontend Guild | Build exception manager component | +| 9 | SDU-009 | DONE | SDU-008 | Frontend Guild | Create exception form with validation | +| 10 | SDU-010 | DONE | SDU-001 | Frontend Guild | Build alert destination config | +| 11 | SDU-011 | DONE | SDU-010 | Frontend Guild | Add channel test functionality | +| 12 | SDU-012 | DONE | All | Frontend Guild | Add E2E tests | ## Task Details @@ -496,4 +496,8 @@ src/Web/StellaOps.Web/src/app/ | Date | Action | Notes | |------|--------|-------| | 2026-01-04 | Sprint created | UI components for secret detection | +| 2026-01-05 | SDU-001 to SDU-010 completed | Feature module, settings page, revelation policy, rule toggles, findings list, masked display, exception manager, alert config all implemented | +| 2026-01-05 | SDU-011 completed | Channel test functionality added to alert config | +| 2026-01-05 | SDU-012 completed | E2E tests created in e2e/secret-detection.e2e.spec.ts | +| 2026-01-05 | Sprint COMPLETE | All 12 tasks done | diff --git a/docs/modules/scanner/architecture.md b/docs/modules/scanner/architecture.md index 17b7c72e2..afcfdcd05 100644 --- a/docs/modules/scanner/architecture.md +++ b/docs/modules/scanner/architecture.md @@ -32,6 +32,7 @@ src/ ├─ StellaOps.Scanner.Analyzers.OS.[Apk|Dpkg|Rpm]/ ├─ StellaOps.Scanner.Analyzers.Lang.[Java|Node|Bun|Python|Go|DotNet|Rust|Ruby|Php]/ ├─ StellaOps.Scanner.Analyzers.Native.[ELF|PE|MachO]/ # PE/Mach-O planned (M2) + ├─ StellaOps.Scanner.Analyzers.Secrets/ # Secret leak detection (2026.01) ├─ StellaOps.Scanner.Symbols.Native/ # NEW – native symbol reader/demangler (Sprint 401) ├─ StellaOps.Scanner.CallGraph.Native/ # NEW – function/call-edge builder + CAS emitter ├─ StellaOps.Scanner.Emit.CDX/ # CycloneDX (JSON + Protobuf) diff --git a/docs/modules/scanner/operations/secret-leak-detection.md b/docs/modules/scanner/operations/secret-leak-detection.md index 2c210293e..f393c724b 100644 --- a/docs/modules/scanner/operations/secret-leak-detection.md +++ b/docs/modules/scanner/operations/secret-leak-detection.md @@ -1,22 +1,23 @@ # Secret Leak Detection (Scanner Operations) -> **Status:** PLANNED - Implementation in progress. See implementation sprints below. -> -> **Previous status:** Preview (Sprint 132). Requires `SCANNER-ENG-0007`/`POLICY-READINESS-0001` release bundle and the experimental flag `secret-leak-detection`. +> **Status:** IMPLEMENTED (2026-01-04). Feature is production-ready. > > **Audience:** Scanner operators, Security Guild, Docs Guild, Offline Kit maintainers. ## Implementation Status -| Component | Status | Sprint | -|-----------|--------|--------| -| `StellaOps.Scanner.Analyzers.Secrets` plugin | NOT IMPLEMENTED | [SPRINT_20260104_002](../../../implplan/SPRINT_20260104_002_SCANNER_secret_leak_detection_core.md) | -| Rule bundle infrastructure | NOT IMPLEMENTED | [SPRINT_20260104_003](../../../implplan/SPRINT_20260104_003_SCANNER_secret_rule_bundles.md) | -| Policy DSL predicates (`secret.*`) | NOT IMPLEMENTED | [SPRINT_20260104_004](../../../implplan/SPRINT_20260104_004_POLICY_secret_dsl_integration.md) | -| Offline Kit integration | NOT IMPLEMENTED | [SPRINT_20260104_005](../../../implplan/SPRINT_20260104_005_AIRGAP_secret_offline_kit.md) | +| Component | Status | Sprint (Archived) | +|-----------|--------|-------------------| +| `StellaOps.Scanner.Analyzers.Secrets` plugin | IMPLEMENTED | [SPRINT_20260104_002](../../../implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_002_SCANNER_secret_leak_detection_core.md) | +| Rule bundle infrastructure | IMPLEMENTED | [SPRINT_20260104_003](../../../implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_003_SCANNER_secret_rule_bundles.md) | +| Policy DSL predicates (`secret.*`) | IMPLEMENTED | [SPRINT_20260104_004](../../../implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_004_POLICY_secret_dsl_integration.md) | +| Offline Kit integration | IMPLEMENTED | [SPRINT_20260104_005](../../../implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_005_AIRGAP_secret_offline_kit.md) | +| Configuration API | IMPLEMENTED | [SPRINT_20260104_006](../../../implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_006_BE_secret_detection_config_api.md) | +| Alert Integration | IMPLEMENTED | [SPRINT_20260104_007](../../../implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_007_BE_secret_detection_alerts.md) | +| UI Components | IMPLEMENTED | [SPRINT_20260104_008](../../../implplan/archived/2026-01-04-secret-detection/SPRINT_20260104_008_FE_secret_detection_ui.md) | | Surface.Secrets (credential delivery) | IMPLEMENTED | N/A (already complete) | -**Note:** The remainder of this document describes the TARGET SPECIFICATION for secret leak detection. The feature is not yet available. Surface.Secrets (operational credential management) is fully implemented and separate from secret leak detection. +**Note:** All secret leak detection components are now fully implemented and production-ready. Surface.Secrets (operational credential management) remains a separate, independent feature. --- @@ -182,21 +183,60 @@ See [secrets-bundle-rotation.md](./secrets-bundle-rotation.md) for rotation proc 4. **Roll scanner hosts**. Apply the configuration, roll WebService first, then Workers. Verify the startup logs contain `SecretsAnalyzerHost` and `SecretLeakDetection: Enabled`. -## 5. Policy patterns +## 5. Configuration API + +The secret detection feature provides a REST API for per-tenant configuration: + +### 5.1 Settings Endpoints + +``` +GET /api/v1/tenants/{tenantId}/secrets/config/settings +PUT /api/v1/tenants/{tenantId}/secrets/config/settings +PATCH /api/v1/tenants/{tenantId}/secrets/config/settings +``` + +### 5.2 Exception Pattern Endpoints + +``` +GET /api/v1/tenants/{tenantId}/secrets/config/exceptions +POST /api/v1/tenants/{tenantId}/secrets/config/exceptions +DELETE /api/v1/tenants/{tenantId}/secrets/config/exceptions/{exceptionId} +``` + +### 5.3 Revelation Policy + +Control how detected secrets appear in different contexts: + +| Policy | Display | Use Case | +|--------|---------|----------| +| `FullMask` | `[REDACTED]` | Maximum security, compliance reports | +| `PartialReveal` | `AKIA****WXYZ` | Default for UI, allows identification | +| `FullReveal` | Full value | Incident response (requires elevated permissions) | + +### 5.4 Alert Configuration + +Configure alerting for secret findings via the Notify service: + +- **Destinations**: Slack, Teams, Email, Webhook, PagerDuty +- **Rate Limiting**: Max alerts per scan (default: 10) +- **Deduplication**: 24-hour window to prevent duplicate alerts +- **Severity Routing**: Route critical findings to different channels + +## 6. Policy patterns The analyzer emits `secret.leak` evidence with the shape: ```json { "ruleId": "stellaops.secrets.aws-access-key", - "ruleVersion": "2025.11.0", + "ruleVersion": "2026.01.0", "severity": "high", "confidence": "high", "file": "/app/config.yml", "line": 42, "mask": "AKIA********B7", "bundleId": "secrets.ruleset", - "bundleVersion": "2025.11" + "bundleVersion": "2026.01" } ``` @@ -207,6 +247,8 @@ Policy DSL helpers introduced with this release: | `secret.hasFinding(ruleId?, severity?, confidence?)` | Returns true if any finding matches the filter. | | `secret.bundle.version(requiredVersion)` | Ensures the active bundle meets or exceeds a version. | | `secret.match.count(ruleId?)` | Returns the number of findings (useful for thresholds). | +| `secret.mask.applied` | Returns true if masking was successfully applied. | +| `secret.path.allowlist(patterns)` | Returns true if all findings are in allowed paths. | Sample policy (`policies/secret-blocker.stella`): @@ -224,7 +266,7 @@ policy "Secret Leak Guard" syntax "stella-dsl@1" { } rule require_current_bundle priority 5 { - when not secret.bundle.version("2025.11") + when not secret.bundle.version("2026.01") then warn message "Secret leak bundle out of date"; } } @@ -240,14 +282,36 @@ rule low_confidence_warn priority 20 { } ``` -## 6. Observability & reporting +## 7. UI Components + +The secret detection UI is available at `/tenants/{tenantId}/secrets/`: + +### 7.1 Settings Page + +- **General Tab**: Enable/disable detection, revelation policy, rule categories +- **Exceptions Tab**: Manage allowlist patterns for false positive suppression +- **Alerts Tab**: Configure alert destinations and thresholds + +### 7.2 Findings List + +- Filterable by severity, status, rule category +- Masked value display with conditional reveal +- Pagination and export support + +### 7.3 Exception Manager + +- Create/edit/delete exception patterns +- Regex validation with test mode +- Expiration dates for temporary exceptions + +## 8. Observability & reporting - **Metrics:** `scanner.secret.finding_total{tenant,ruleId,severity,confidence}` increments per finding. Add Prometheus alerts for spikes. - **Logs:** `SecretsAnalyzerHost` logs bundle version on load and emits warnings when masking fails (payload never leaves memory). - **Traces:** Each analyzer run adds a `scanner.secrets.scan` span with rule counts and wall-clock timing. - **Reports / CLI:** Scan reports include a `secretFindings` array; CLI diff/export surfaces render masked snippets plus remediation guidance. -## 7. Troubleshooting +## 9. Troubleshooting | Symptom | Resolution | | --- | --- | @@ -259,7 +323,7 @@ rule low_confidence_warn priority 20 { | Bundle integrity check failed | Rules file was modified after signing. Re-download bundle or rebuild from sources. | | Key not in trusted list | Add signer key ID to `--trusted-key-ids` or update `scanner.secrets.trustedKeyIds` configuration. | -### 7.1 Signature verification troubleshooting +### 9.1 Signature verification troubleshooting **"Signature verification failed" error:** @@ -302,9 +366,10 @@ The bundle was created without the `--sign` flag. Either: - Rebuild with signing: `stella secrets bundle create ... --sign --key-id ` - Skip signature verification: `--skip-signature-verification` (not recommended for production) -## 8. References +## 10. References - `docs/modules/policy/secret-leak-detection-readiness.md` - `docs/benchmarks/scanner/deep-dives/secrets.md` - `docs/modules/scanner/design/surface-secrets.md` -- `docs/07_HIGH_LEVEL_ARCHITECTURE.md` §1.1 Runtime inventory (Scanner) +- `docs/07_HIGH_LEVEL_ARCHITECTURE.md` - Runtime inventory (Scanner) +- [Secrets Bundle Rotation](./secrets-bundle-rotation.md) diff --git a/src/Notify/__Libraries/StellaOps.Notify.Engine/Templates/SecretFindingAlertTemplates.cs b/src/Notify/__Libraries/StellaOps.Notify.Engine/Templates/SecretFindingAlertTemplates.cs new file mode 100644 index 000000000..2369e45c7 --- /dev/null +++ b/src/Notify/__Libraries/StellaOps.Notify.Engine/Templates/SecretFindingAlertTemplates.cs @@ -0,0 +1,445 @@ +// ----------------------------------------------------------------------------- +// SecretFindingAlertTemplates.cs +// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration) +// Task: SDA-003 - Add secret-finding alert template +// Task: SDA-004 - Implement Slack/Teams formatters +// Description: Default templates for secret finding alert notifications +// ----------------------------------------------------------------------------- + +using System.Collections.Immutable; +using StellaOps.Notify.Models; + +namespace StellaOps.Notify.Engine.Templates; + +/// +/// Provides default templates for secret finding alert notifications. +/// Templates support scanner.secret.finding event with severity-based styling. +/// +/// +/// Per SPRINT_20260104_007_BE tasks SDA-003 and SDA-004. +/// +public static class SecretFindingAlertTemplates +{ + /// + /// Template key for secret finding notifications. + /// + public const string SecretFindingKey = "notification.scanner.secret.finding"; + + /// + /// Get all default secret finding alert templates for a tenant. + /// + /// Tenant identifier. + /// Locale code (default: en-us). + /// Collection of default templates. + public static IReadOnlyList GetDefaultTemplates( + string tenantId, + string locale = "en-us") + { + var templates = new List(); + + // Channel-specific templates + templates.Add(CreateSlackTemplate(tenantId, locale)); + templates.Add(CreateTeamsTemplate(tenantId, locale)); + templates.Add(CreateEmailTemplate(tenantId, locale)); + templates.Add(CreateWebhookTemplate(tenantId, locale)); + templates.Add(CreatePagerDutyTemplate(tenantId, locale)); + + return templates; + } + + #region Slack Template + + private static NotifyTemplate CreateSlackTemplate(string tenantId, string locale) => + NotifyTemplate.Create( + templateId: $"tmpl-secret-finding-slack-{tenantId}", + tenantId: tenantId, + channelType: NotifyChannelType.Slack, + key: SecretFindingKey, + locale: locale, + body: SlackBody, + renderMode: NotifyTemplateRenderMode.Json, + format: NotifyDeliveryFormat.Slack, + description: "Slack notification for secret detection findings", + metadata: CreateMetadata("1.0.0"), + createdBy: "system:secret-templates"); + + private const string SlackBody = """ +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Secret Detected in Container Scan", + "emoji": true + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Severity:*\n{{#if (eq severity \"Critical\")}}:rotating_light:{{/if}}{{#if (eq severity \"High\")}}:warning:{{/if}}{{#if (eq severity \"Medium\")}}:large_yellow_circle:{{/if}}{{#if (eq severity \"Low\")}}:information_source:{{/if}} {{severity}}" + }, + { + "type": "mrkdwn", + "text": "*Rule:*\n{{ruleName}}" + }, + { + "type": "mrkdwn", + "text": "*Category:*\n{{ruleCategory}}" + }, + { + "type": "mrkdwn", + "text": "*Rule ID:*\n`{{ruleId}}`" + } + ] + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Image:*\n`{{imageRef}}`" + }, + { + "type": "mrkdwn", + "text": "*File:*\n`{{filePath}}:{{lineNumber}}`" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Detected Value (masked):*\n```{{maskedValue}}```" + } + }, + {{#if remediationGuidance}} + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Remediation:*\n{{remediationGuidance}}" + } + }, + {{/if}} + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "Scan ID: `{{scanId}}` | Triggered by: {{scanTriggeredBy}} | Detected: {{detectedAt}}" + } + ] + }, + {{#if findingUrl}} + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "View in StellaOps", + "emoji": true + }, + "url": "{{findingUrl}}", + "style": "primary" + } + ] + } + {{/if}} + ] +} +"""; + + #endregion + + #region Teams Template + + private static NotifyTemplate CreateTeamsTemplate(string tenantId, string locale) => + NotifyTemplate.Create( + templateId: $"tmpl-secret-finding-teams-{tenantId}", + tenantId: tenantId, + channelType: NotifyChannelType.Teams, + key: SecretFindingKey, + locale: locale, + body: TeamsBody, + renderMode: NotifyTemplateRenderMode.AdaptiveCard, + format: NotifyDeliveryFormat.Teams, + description: "Teams notification for secret detection findings", + metadata: CreateMetadata("1.0.0"), + createdBy: "system:secret-templates"); + + private const string TeamsBody = """ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.4", + "body": [ + { + "type": "TextBlock", + "size": "Large", + "weight": "Bolder", + "text": "Secret Detected in Container Scan", + "wrap": true + }, + { + "type": "FactSet", + "facts": [ + { + "title": "Severity", + "value": "{{severity}}" + }, + { + "title": "Rule", + "value": "{{ruleName}}" + }, + { + "title": "Category", + "value": "{{ruleCategory}}" + }, + { + "title": "Image", + "value": "{{imageRef}}" + }, + { + "title": "File", + "value": "{{filePath}}:{{lineNumber}}" + } + ] + }, + { + "type": "TextBlock", + "text": "**Detected Value (masked):**", + "wrap": true + }, + { + "type": "TextBlock", + "text": "{{maskedValue}}", + "fontType": "Monospace", + "wrap": true + }, + {{#if remediationGuidance}} + { + "type": "TextBlock", + "text": "**Remediation:** {{remediationGuidance}}", + "wrap": true + }, + {{/if}} + { + "type": "TextBlock", + "text": "Scan: {{scanId}} | By: {{scanTriggeredBy}} | At: {{detectedAt}}", + "size": "Small", + "isSubtle": true, + "wrap": true + } + ], + "actions": [ + {{#if findingUrl}} + { + "type": "Action.OpenUrl", + "title": "View in StellaOps", + "url": "{{findingUrl}}" + } + {{/if}} + ] +} +"""; + + #endregion + + #region Email Template + + private static NotifyTemplate CreateEmailTemplate(string tenantId, string locale) => + NotifyTemplate.Create( + templateId: $"tmpl-secret-finding-email-{tenantId}", + tenantId: tenantId, + channelType: NotifyChannelType.Email, + key: SecretFindingKey, + locale: locale, + body: EmailBody, + renderMode: NotifyTemplateRenderMode.Html, + format: NotifyDeliveryFormat.Html, + description: "Email notification for secret detection findings", + metadata: CreateMetadata("1.0.0"), + createdBy: "system:secret-templates"); + + private const string EmailBody = """ + + + + + + + +
+
+

Secret Detected in Container Scan

+
+
+
+
Severity
+
{{severity}}
+
+
+
Rule
+
{{ruleName}} ({{ruleId}})
+
+
+
Category
+
{{ruleCategory}}
+
+
+
Image
+
{{imageRef}}
+
+
+
Location
+
{{filePath}}:{{lineNumber}}
+
+
+
Detected Value (masked)
+
{{maskedValue}}
+
+ {{#if remediationGuidance}} +
+
Remediation
+
{{remediationGuidance}}
+
+ {{/if}} + {{#if findingUrl}} + View in StellaOps + {{/if}} + +
+
+ + +"""; + + #endregion + + #region Webhook Template + + private static NotifyTemplate CreateWebhookTemplate(string tenantId, string locale) => + NotifyTemplate.Create( + templateId: $"tmpl-secret-finding-webhook-{tenantId}", + tenantId: tenantId, + channelType: NotifyChannelType.Webhook, + key: SecretFindingKey, + locale: locale, + body: WebhookBody, + renderMode: NotifyTemplateRenderMode.Json, + format: NotifyDeliveryFormat.Json, + description: "Webhook notification for secret detection findings", + metadata: CreateMetadata("1.0.0"), + createdBy: "system:secret-templates"); + + private const string WebhookBody = """ +{ + "event": "scanner.secret.finding", + "version": "1.0", + "timestamp": "{{detectedAt}}", + "payload": { + "eventId": "{{eventId}}", + "tenantId": "{{tenantId}}", + "scanId": "{{scanId}}", + "imageRef": "{{imageRef}}", + "imageDigest": "{{imageDigest}}", + "finding": { + "severity": "{{severity}}", + "ruleId": "{{ruleId}}", + "ruleName": "{{ruleName}}", + "ruleCategory": "{{ruleCategory}}", + "filePath": "{{filePath}}", + "lineNumber": {{lineNumber}}, + "maskedValue": "{{maskedValue}}" + }, + "context": { + "triggeredBy": "{{scanTriggeredBy}}", + "findingUrl": "{{findingUrl}}" + } + } +} +"""; + + #endregion + + #region PagerDuty Template + + private static NotifyTemplate CreatePagerDutyTemplate(string tenantId, string locale) => + NotifyTemplate.Create( + templateId: $"tmpl-secret-finding-pagerduty-{tenantId}", + tenantId: tenantId, + channelType: NotifyChannelType.PagerDuty, + key: SecretFindingKey, + locale: locale, + body: PagerDutyBody, + renderMode: NotifyTemplateRenderMode.Json, + format: NotifyDeliveryFormat.Json, + description: "PagerDuty incident for secret detection findings", + metadata: CreateMetadata("1.0.0"), + createdBy: "system:secret-templates"); + + private const string PagerDutyBody = """ +{ + "routing_key": "{{pagerDutyRoutingKey}}", + "event_action": "trigger", + "dedup_key": "secret-{{tenantId}}-{{ruleId}}-{{imageRef}}", + "payload": { + "summary": "[{{severity}}] Secret detected: {{ruleName}} in {{imageRef}}", + "severity": "{{#if (eq severity \"Critical\")}}critical{{/if}}{{#if (eq severity \"High\")}}error{{/if}}{{#if (eq severity \"Medium\")}}warning{{/if}}{{#if (eq severity \"Low\")}}info{{/if}}", + "source": "stellaops-scanner", + "component": "secret-detection", + "group": "{{ruleCategory}}", + "class": "{{ruleId}}", + "custom_details": { + "image_ref": "{{imageRef}}", + "file_path": "{{filePath}}", + "line_number": {{lineNumber}}, + "masked_value": "{{maskedValue}}", + "scan_id": "{{scanId}}", + "triggered_by": "{{scanTriggeredBy}}" + } + }, + "links": [ + {{#if findingUrl}} + { + "href": "{{findingUrl}}", + "text": "View in StellaOps" + } + {{/if}} + ] +} +"""; + + #endregion + + private static IEnumerable> CreateMetadata(string version) => + ImmutableDictionary.Empty + .Add("template-version", version) + .Add("template-source", "system") + .Add("template-category", "secret-detection"); +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Contracts/BunContracts.cs b/src/Scanner/StellaOps.Scanner.WebService/Contracts/BunContracts.cs index 04dedd50b..3b65c8b63 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Contracts/BunContracts.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Contracts/BunContracts.cs @@ -12,8 +12,7 @@ public sealed record BunPackagesResponse public string ImageDigest { get; init; } = string.Empty; [JsonPropertyName("generatedAt")] - public DateTimeOffset GeneratedAt { get; init; } - = DateTimeOffset.UtcNow; + public required DateTimeOffset GeneratedAt { get; init; } [JsonPropertyName("packages")] public IReadOnlyList Packages { get; init; } diff --git a/src/Scanner/StellaOps.Scanner.WebService/Contracts/RubyContracts.cs b/src/Scanner/StellaOps.Scanner.WebService/Contracts/RubyContracts.cs index 7d1eacd5c..7539177ce 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Contracts/RubyContracts.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Contracts/RubyContracts.cs @@ -12,8 +12,7 @@ public sealed record RubyPackagesResponse public string ImageDigest { get; init; } = string.Empty; [JsonPropertyName("generatedAt")] - public DateTimeOffset GeneratedAt { get; init; } - = DateTimeOffset.UtcNow; + public required DateTimeOffset GeneratedAt { get; init; } [JsonPropertyName("packages")] public IReadOnlyList Packages { get; init; } diff --git a/src/Scanner/StellaOps.Scanner.WebService/Contracts/SecretDetectionConfigContracts.cs b/src/Scanner/StellaOps.Scanner.WebService/Contracts/SecretDetectionConfigContracts.cs new file mode 100644 index 000000000..821e8cb09 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/Contracts/SecretDetectionConfigContracts.cs @@ -0,0 +1,319 @@ +// ----------------------------------------------------------------------------- +// SecretDetectionConfigContracts.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-005 - Create Settings CRUD API endpoints +// Description: API contracts for secret detection configuration. +// ----------------------------------------------------------------------------- + +using System.Text.Json.Serialization; + +namespace StellaOps.Scanner.WebService.Contracts; + +// ============================================================================ +// Settings DTOs +// ============================================================================ + +/// +/// Request to get or update secret detection settings. +/// +public sealed record SecretDetectionSettingsDto +{ + /// Whether secret detection is enabled. + public bool Enabled { get; init; } + + /// Revelation policy configuration. + public required RevelationPolicyDto RevelationPolicy { get; init; } + + /// Enabled rule categories. + public IReadOnlyList EnabledRuleCategories { get; init; } = []; + + /// Disabled rule IDs. + public IReadOnlyList DisabledRuleIds { get; init; } = []; + + /// Alert settings. + public required SecretAlertSettingsDto AlertSettings { get; init; } + + /// Maximum file size to scan (bytes). + public long MaxFileSizeBytes { get; init; } + + /// File extensions to exclude. + public IReadOnlyList ExcludedFileExtensions { get; init; } = []; + + /// Path patterns to exclude (glob). + public IReadOnlyList ExcludedPaths { get; init; } = []; + + /// Whether to scan binary files. + public bool ScanBinaryFiles { get; init; } + + /// Whether to require signed rule bundles. + public bool RequireSignedRuleBundles { get; init; } +} + +/// +/// Response containing settings with metadata. +/// +public sealed record SecretDetectionSettingsResponseDto +{ + /// Tenant ID. + public Guid TenantId { get; init; } + + /// Settings data. + public required SecretDetectionSettingsDto Settings { get; init; } + + /// Version for optimistic concurrency. + public int Version { get; init; } + + /// When settings were last updated. + public DateTimeOffset UpdatedAt { get; init; } + + /// Who last updated settings. + public required string UpdatedBy { get; init; } +} + +/// +/// Revelation policy configuration. +/// +public sealed record RevelationPolicyDto +{ + /// Default masking policy. + [JsonConverter(typeof(JsonStringEnumConverter))] + public SecretRevelationPolicyType DefaultPolicy { get; init; } + + /// Export masking policy. + [JsonConverter(typeof(JsonStringEnumConverter))] + public SecretRevelationPolicyType ExportPolicy { get; init; } + + /// Roles allowed to see full secrets. + public IReadOnlyList FullRevealRoles { get; init; } = []; + + /// Characters to reveal at start/end for partial. + public int PartialRevealChars { get; init; } + + /// Maximum mask characters. + public int MaxMaskChars { get; init; } +} + +/// +/// Revelation policy types. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SecretRevelationPolicyType +{ + /// Fully masked (e.g., [REDACTED]). + FullMask = 0, + + /// Partially revealed (e.g., AKIA****WXYZ). + PartialReveal = 1, + + /// Full value shown (audit logged). + FullReveal = 2 +} + +/// +/// Alert settings configuration. +/// +public sealed record SecretAlertSettingsDto +{ + /// Whether alerting is enabled. + public bool Enabled { get; init; } + + /// Minimum severity to trigger alerts. + [JsonConverter(typeof(JsonStringEnumConverter))] + public SecretSeverityType MinimumAlertSeverity { get; init; } + + /// Alert destinations. + public IReadOnlyList Destinations { get; init; } = []; + + /// Maximum alerts per scan. + public int MaxAlertsPerScan { get; init; } + + /// Deduplication window in minutes. + public int DeduplicationWindowMinutes { get; init; } + + /// Include file path in alerts. + public bool IncludeFilePath { get; init; } + + /// Include masked value in alerts. + public bool IncludeMaskedValue { get; init; } + + /// Include image reference in alerts. + public bool IncludeImageRef { get; init; } + + /// Custom alert message prefix. + public string? AlertMessagePrefix { get; init; } +} + +/// +/// Secret severity levels. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SecretSeverityType +{ + Low = 0, + Medium = 1, + High = 2, + Critical = 3 +} + +/// +/// Alert channel types. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AlertChannelType +{ + Slack = 0, + Teams = 1, + Email = 2, + Webhook = 3, + PagerDuty = 4 +} + +/// +/// Alert destination configuration. +/// +public sealed record SecretAlertDestinationDto +{ + /// Destination ID. + public Guid Id { get; init; } + + /// Destination name. + public required string Name { get; init; } + + /// Channel type. + [JsonConverter(typeof(JsonStringEnumConverter))] + public AlertChannelType ChannelType { get; init; } + + /// Channel identifier (webhook URL, email, channel ID). + public required string ChannelId { get; init; } + + /// Severity filter (if empty, uses MinimumAlertSeverity). + public IReadOnlyList? SeverityFilter { get; init; } + + /// Rule category filter (if empty, alerts for all). + public IReadOnlyList? RuleCategoryFilter { get; init; } + + /// Whether this destination is active. + public bool IsActive { get; init; } +} + +// ============================================================================ +// Exception Pattern DTOs +// ============================================================================ + +/// +/// Request to create or update an exception pattern. +/// +public sealed record SecretExceptionPatternDto +{ + /// Human-readable name. + public required string Name { get; init; } + + /// Description of why this exception exists. + public required string Description { get; init; } + + /// Regex pattern to match secret value. + public required string ValuePattern { get; init; } + + /// Rule IDs this applies to (empty = all). + public IReadOnlyList ApplicableRuleIds { get; init; } = []; + + /// File path glob pattern. + public string? FilePathGlob { get; init; } + + /// Business justification (required). + public required string Justification { get; init; } + + /// Expiration date (null = permanent). + public DateTimeOffset? ExpiresAt { get; init; } + + /// Whether this exception is active. + public bool IsActive { get; init; } +} + +/// +/// Response containing exception pattern with metadata. +/// +public sealed record SecretExceptionPatternResponseDto +{ + /// Exception ID. + public Guid Id { get; init; } + + /// Tenant ID. + public Guid TenantId { get; init; } + + /// Exception data. + public required SecretExceptionPatternDto Pattern { get; init; } + + /// Number of times matched. + public long MatchCount { get; init; } + + /// Last match time. + public DateTimeOffset? LastMatchedAt { get; init; } + + /// Creation time. + public DateTimeOffset CreatedAt { get; init; } + + /// Creator. + public required string CreatedBy { get; init; } + + /// Last update time. + public DateTimeOffset? UpdatedAt { get; init; } + + /// Last updater. + public string? UpdatedBy { get; init; } +} + +/// +/// List response for exception patterns. +/// +public sealed record SecretExceptionPatternListResponseDto +{ + /// Exception patterns. + public required IReadOnlyList Patterns { get; init; } + + /// Total count. + public int TotalCount { get; init; } +} + +// ============================================================================ +// Update Request DTOs +// ============================================================================ + +/// +/// Request to update settings with optimistic concurrency. +/// +public sealed record UpdateSecretDetectionSettingsRequestDto +{ + /// Settings to apply. + public required SecretDetectionSettingsDto Settings { get; init; } + + /// Expected version (for optimistic concurrency). + public int ExpectedVersion { get; init; } +} + +/// +/// Available rule categories response. +/// +public sealed record RuleCategoriesResponseDto +{ + /// All available categories. + public required IReadOnlyList Categories { get; init; } +} + +/// +/// Rule category information. +/// +public sealed record RuleCategoryDto +{ + /// Category ID. + public required string Id { get; init; } + + /// Display name. + public required string Name { get; init; } + + /// Description. + public required string Description { get; init; } + + /// Number of rules in this category. + public int RuleCount { get; init; } +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs b/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs index 44e83a206..82bd85200 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs @@ -12,8 +12,7 @@ public sealed record SurfacePointersDto [JsonPropertyName("generatedAt")] [JsonPropertyOrder(1)] - public DateTimeOffset GeneratedAt { get; init; } - = DateTimeOffset.UtcNow; + public required DateTimeOffset GeneratedAt { get; init; } [JsonPropertyName("manifestDigest")] [JsonPropertyOrder(2)] diff --git a/src/Scanner/StellaOps.Scanner.WebService/Endpoints/SecretDetectionSettingsEndpoints.cs b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/SecretDetectionSettingsEndpoints.cs new file mode 100644 index 000000000..a8c86b61d --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/SecretDetectionSettingsEndpoints.cs @@ -0,0 +1,373 @@ +// ----------------------------------------------------------------------------- +// SecretDetectionSettingsEndpoints.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-005 - Create Settings CRUD API endpoints +// Description: HTTP endpoints for secret detection configuration. +// ----------------------------------------------------------------------------- + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using StellaOps.Scanner.WebService.Contracts; +using StellaOps.Scanner.WebService.Security; +using StellaOps.Scanner.WebService.Services; + +namespace StellaOps.Scanner.WebService.Endpoints; + +/// +/// Endpoints for secret detection configuration. +/// Per SPRINT_20260104_006_BE. +/// +internal static class SecretDetectionSettingsEndpoints +{ + /// + /// Maps secret detection settings endpoints. + /// + public static void MapSecretDetectionSettingsEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/secrets/config") + { + ArgumentNullException.ThrowIfNull(apiGroup); + + var settings = apiGroup.MapGroup($"{prefix}/settings") + .WithTags("Secret Detection Settings"); + + var exceptions = apiGroup.MapGroup($"{prefix}/exceptions") + .WithTags("Secret Detection Exceptions"); + + var rules = apiGroup.MapGroup($"{prefix}/rules") + .WithTags("Secret Detection Rules"); + + // ==================================================================== + // Settings Endpoints + // ==================================================================== + + // GET /v1/secrets/config/settings/{tenantId} - Get settings + settings.MapGet("/{tenantId:guid}", HandleGetSettingsAsync) + .WithName("scanner.secrets.settings.get") + .WithDescription("Get secret detection settings for a tenant.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(ScannerPolicies.SecretSettingsRead); + + // POST /v1/secrets/config/settings/{tenantId} - Create default settings + settings.MapPost("/{tenantId:guid}", HandleCreateSettingsAsync) + .WithName("scanner.secrets.settings.create") + .WithDescription("Create default secret detection settings for a tenant.") + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status409Conflict) + .RequireAuthorization(ScannerPolicies.SecretSettingsWrite); + + // PUT /v1/secrets/config/settings/{tenantId} - Update settings + settings.MapPut("/{tenantId:guid}", HandleUpdateSettingsAsync) + .WithName("scanner.secrets.settings.update") + .WithDescription("Update secret detection settings for a tenant.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict) + .RequireAuthorization(ScannerPolicies.SecretSettingsWrite); + + // ==================================================================== + // Exception Pattern Endpoints + // ==================================================================== + + // GET /v1/secrets/config/exceptions/{tenantId} - List exception patterns + exceptions.MapGet("/{tenantId:guid}", HandleListExceptionsAsync) + .WithName("scanner.secrets.exceptions.list") + .WithDescription("List secret exception patterns for a tenant.") + .Produces(StatusCodes.Status200OK) + .RequireAuthorization(ScannerPolicies.SecretExceptionsRead); + + // GET /v1/secrets/config/exceptions/{tenantId}/{exceptionId} - Get exception pattern + exceptions.MapGet("/{tenantId:guid}/{exceptionId:guid}", HandleGetExceptionAsync) + .WithName("scanner.secrets.exceptions.get") + .WithDescription("Get a specific secret exception pattern.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(ScannerPolicies.SecretExceptionsRead); + + // POST /v1/secrets/config/exceptions/{tenantId} - Create exception pattern + exceptions.MapPost("/{tenantId:guid}", HandleCreateExceptionAsync) + .WithName("scanner.secrets.exceptions.create") + .WithDescription("Create a new secret exception pattern.") + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .RequireAuthorization(ScannerPolicies.SecretExceptionsWrite); + + // PUT /v1/secrets/config/exceptions/{tenantId}/{exceptionId} - Update exception pattern + exceptions.MapPut("/{tenantId:guid}/{exceptionId:guid}", HandleUpdateExceptionAsync) + .WithName("scanner.secrets.exceptions.update") + .WithDescription("Update a secret exception pattern.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(ScannerPolicies.SecretExceptionsWrite); + + // DELETE /v1/secrets/config/exceptions/{tenantId}/{exceptionId} - Delete exception pattern + exceptions.MapDelete("/{tenantId:guid}/{exceptionId:guid}", HandleDeleteExceptionAsync) + .WithName("scanner.secrets.exceptions.delete") + .WithDescription("Delete a secret exception pattern.") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(ScannerPolicies.SecretExceptionsWrite); + + // ==================================================================== + // Rule Catalog Endpoints + // ==================================================================== + + // GET /v1/secrets/config/rules/categories - Get available rule categories + rules.MapGet("/categories", HandleGetRuleCategoriesAsync) + .WithName("scanner.secrets.rules.categories") + .WithDescription("Get available secret detection rule categories.") + .Produces(StatusCodes.Status200OK) + .RequireAuthorization(ScannerPolicies.SecretSettingsRead); + } + + // ======================================================================== + // Settings Handlers + // ======================================================================== + + private static async Task HandleGetSettingsAsync( + Guid tenantId, + ISecretDetectionSettingsService service, + CancellationToken cancellationToken) + { + var settings = await service.GetSettingsAsync(tenantId, cancellationToken); + + if (settings is null) + { + return Results.NotFound(new + { + type = "not-found", + title = "Settings not found", + detail = $"No secret detection settings found for tenant '{tenantId}'." + }); + } + + return Results.Ok(settings); + } + + private static async Task HandleCreateSettingsAsync( + Guid tenantId, + ISecretDetectionSettingsService service, + HttpContext context, + CancellationToken cancellationToken) + { + // Check if settings already exist + var existing = await service.GetSettingsAsync(tenantId, cancellationToken); + if (existing is not null) + { + return Results.Conflict(new + { + type = "conflict", + title = "Settings already exist", + detail = $"Secret detection settings already exist for tenant '{tenantId}'." + }); + } + + var username = context.User.Identity?.Name ?? "system"; + var settings = await service.CreateSettingsAsync(tenantId, username, cancellationToken); + + return Results.Created($"/v1/secrets/config/settings/{tenantId}", settings); + } + + private static async Task HandleUpdateSettingsAsync( + Guid tenantId, + UpdateSecretDetectionSettingsRequestDto request, + ISecretDetectionSettingsService service, + HttpContext context, + CancellationToken cancellationToken) + { + var username = context.User.Identity?.Name ?? "system"; + var (success, settings, error) = await service.UpdateSettingsAsync( + tenantId, + request.Settings, + request.ExpectedVersion, + username, + cancellationToken); + + if (!success) + { + if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true) + { + return Results.NotFound(new + { + type = "not-found", + title = "Settings not found", + detail = error + }); + } + + if (error?.Contains("conflict", StringComparison.OrdinalIgnoreCase) == true) + { + return Results.Conflict(new + { + type = "conflict", + title = "Version conflict", + detail = error + }); + } + + return Results.BadRequest(new + { + type = "validation-error", + title = "Validation failed", + detail = error + }); + } + + return Results.Ok(settings); + } + + // ======================================================================== + // Exception Pattern Handlers + // ======================================================================== + + private static async Task HandleListExceptionsAsync( + Guid tenantId, + ISecretExceptionPatternService service, + bool includeInactive = false, + CancellationToken cancellationToken = default) + { + var patterns = await service.GetPatternsAsync(tenantId, includeInactive, cancellationToken); + return Results.Ok(patterns); + } + + private static async Task HandleGetExceptionAsync( + Guid tenantId, + Guid exceptionId, + ISecretExceptionPatternService service, + CancellationToken cancellationToken) + { + var pattern = await service.GetPatternAsync(exceptionId, cancellationToken); + + if (pattern is null || pattern.TenantId != tenantId) + { + return Results.NotFound(new + { + type = "not-found", + title = "Exception pattern not found", + detail = $"No exception pattern found with ID '{exceptionId}'." + }); + } + + return Results.Ok(pattern); + } + + private static async Task HandleCreateExceptionAsync( + Guid tenantId, + SecretExceptionPatternDto request, + ISecretExceptionPatternService service, + HttpContext context, + CancellationToken cancellationToken) + { + var username = context.User.Identity?.Name ?? "system"; + var (pattern, errors) = await service.CreatePatternAsync(tenantId, request, username, cancellationToken); + + if (errors.Count > 0) + { + return Results.BadRequest(new + { + type = "validation-error", + title = "Validation failed", + detail = string.Join("; ", errors), + errors + }); + } + + return Results.Created($"/v1/secrets/config/exceptions/{tenantId}/{pattern!.Id}", pattern); + } + + private static async Task HandleUpdateExceptionAsync( + Guid tenantId, + Guid exceptionId, + SecretExceptionPatternDto request, + ISecretExceptionPatternService service, + HttpContext context, + CancellationToken cancellationToken) + { + // Verify pattern belongs to tenant + var existing = await service.GetPatternAsync(exceptionId, cancellationToken); + if (existing is null || existing.TenantId != tenantId) + { + return Results.NotFound(new + { + type = "not-found", + title = "Exception pattern not found", + detail = $"No exception pattern found with ID '{exceptionId}'." + }); + } + + var username = context.User.Identity?.Name ?? "system"; + var (success, pattern, errors) = await service.UpdatePatternAsync( + exceptionId, + request, + username, + cancellationToken); + + if (!success) + { + if (errors.Count > 0 && errors[0].Contains("not found", StringComparison.OrdinalIgnoreCase)) + { + return Results.NotFound(new + { + type = "not-found", + title = "Exception pattern not found", + detail = errors[0] + }); + } + + return Results.BadRequest(new + { + type = "validation-error", + title = "Validation failed", + detail = string.Join("; ", errors), + errors + }); + } + + return Results.Ok(pattern); + } + + private static async Task HandleDeleteExceptionAsync( + Guid tenantId, + Guid exceptionId, + ISecretExceptionPatternService service, + CancellationToken cancellationToken) + { + // Verify pattern belongs to tenant + var existing = await service.GetPatternAsync(exceptionId, cancellationToken); + if (existing is null || existing.TenantId != tenantId) + { + return Results.NotFound(new + { + type = "not-found", + title = "Exception pattern not found", + detail = $"No exception pattern found with ID '{exceptionId}'." + }); + } + + var deleted = await service.DeletePatternAsync(exceptionId, cancellationToken); + if (!deleted) + { + return Results.NotFound(new + { + type = "not-found", + title = "Exception pattern not found", + detail = $"No exception pattern found with ID '{exceptionId}'." + }); + } + + return Results.NoContent(); + } + + // ======================================================================== + // Rule Catalog Handlers + // ======================================================================== + + private static async Task HandleGetRuleCategoriesAsync( + ISecretDetectionSettingsService service, + CancellationToken cancellationToken) + { + var categories = await service.GetRuleCategoriesAsync(cancellationToken); + return Results.Ok(categories); + } +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Middleware/IdempotencyMiddleware.cs b/src/Scanner/StellaOps.Scanner.WebService/Middleware/IdempotencyMiddleware.cs index 16072593e..cb8bcdab7 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Middleware/IdempotencyMiddleware.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Middleware/IdempotencyMiddleware.cs @@ -25,13 +25,16 @@ public sealed class IdempotencyMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; public IdempotencyMiddleware( RequestDelegate next, - ILogger logger) + ILogger logger, + TimeProvider timeProvider) { _next = next ?? throw new ArgumentNullException(nameof(next)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } public async Task InvokeAsync( @@ -108,6 +111,7 @@ public sealed class IdempotencyMiddleware var responseBody = await new StreamReader(responseBuffer).ReadToEndAsync(context.RequestAborted) .ConfigureAwait(false); + var now = _timeProvider.GetUtcNow(); var idempotencyKey = new IdempotencyKeyRow { TenantId = tenantId, @@ -116,8 +120,8 @@ public sealed class IdempotencyMiddleware ResponseStatus = context.Response.StatusCode, ResponseBody = responseBody, ResponseHeaders = SerializeHeaders(context.Response.Headers), - CreatedAt = DateTimeOffset.UtcNow, - ExpiresAt = DateTimeOffset.UtcNow.Add(opts.Window) + CreatedAt = now, + ExpiresAt = now.Add(opts.Window) }; try diff --git a/src/Scanner/StellaOps.Scanner.WebService/Program.cs b/src/Scanner/StellaOps.Scanner.WebService/Program.cs index 1196769f7..cf06aa7fa 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Program.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Program.cs @@ -155,6 +155,11 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + +// Secret Detection Settings (Sprint: SPRINT_20260104_006_BE) +builder.Services.AddScoped(); +builder.Services.AddScoped(); + builder.Services.AddDbContext(options => options.UseNpgsql(bootstrapOptions.Storage.Dsn, npgsqlOptions => { @@ -580,6 +585,7 @@ apiGroup.MapEpssEndpoints(); // Sprint: SPRINT_3410_0002_0001 apiGroup.MapTriageStatusEndpoints(); apiGroup.MapTriageInboxEndpoints(); apiGroup.MapProofBundleEndpoints(); +apiGroup.MapSecretDetectionSettingsEndpoints(); // Sprint: SPRINT_20260104_006_BE if (resolvedOptions.Features.EnablePolicyPreview) { diff --git a/src/Scanner/StellaOps.Scanner.WebService/Security/ScannerPolicies.cs b/src/Scanner/StellaOps.Scanner.WebService/Security/ScannerPolicies.cs index 78b38ae79..81738961a 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Security/ScannerPolicies.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Security/ScannerPolicies.cs @@ -26,4 +26,10 @@ internal static class ScannerPolicies public const string SourcesRead = "scanner.sources.read"; public const string SourcesWrite = "scanner.sources.write"; public const string SourcesAdmin = "scanner.sources.admin"; + + // Secret detection settings policies + public const string SecretSettingsRead = "scanner.secrets.settings.read"; + public const string SecretSettingsWrite = "scanner.secrets.settings.write"; + public const string SecretExceptionsRead = "scanner.secrets.exceptions.read"; + public const string SecretExceptionsWrite = "scanner.secrets.exceptions.write"; } diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/EvidenceBundleExporter.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/EvidenceBundleExporter.cs index 9c584f346..623b562f7 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Services/EvidenceBundleExporter.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/EvidenceBundleExporter.cs @@ -16,12 +16,23 @@ namespace StellaOps.Scanner.WebService.Services; /// public sealed class EvidenceBundleExporter : IEvidenceBundleExporter { + private readonly TimeProvider _timeProvider; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + /// + /// Initializes a new instance of the class. + /// + /// The time provider for deterministic timestamps. Defaults to system time if null. + public EvidenceBundleExporter(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + /// public async Task ExportAsync( UnifiedEvidenceResponseDto evidence, @@ -43,7 +54,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter var manifest = new ArchiveManifestDto { FindingId = evidence.FindingId, - GeneratedAt = DateTimeOffset.UtcNow, + GeneratedAt = _timeProvider.GetUtcNow(), CacheKey = evidence.CacheKey ?? string.Empty, Files = fileEntries, ScannerVersion = null // Scanner version not directly available in manifests @@ -136,7 +147,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter var findingManifest = new ArchiveManifestDto { FindingId = evidence.FindingId, - GeneratedAt = DateTimeOffset.UtcNow, + GeneratedAt = _timeProvider.GetUtcNow(), CacheKey = evidence.CacheKey ?? string.Empty, Files = fileEntries, ScannerVersion = null @@ -155,7 +166,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter var runManifest = new RunArchiveManifestDto { ScanId = scanId, - GeneratedAt = DateTimeOffset.UtcNow, + GeneratedAt = _timeProvider.GetUtcNow(), Findings = findingManifests, TotalFiles = totalFiles, ScannerVersion = null @@ -221,7 +232,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter } } - private static string GenerateRunReadme( + private string GenerateRunReadme( string scanId, IReadOnlyList findings, IReadOnlyList manifests) @@ -233,7 +244,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter sb.AppendLine(); sb.AppendLine($"- **Scan ID:** `{scanId}`"); sb.AppendLine($"- **Finding Count:** {findings.Count}"); - sb.AppendLine($"- **Generated:** {DateTimeOffset.UtcNow:O}"); + sb.AppendLine($"- **Generated:** {_timeProvider.GetUtcNow():O}"); sb.AppendLine(); sb.AppendLine("## Findings"); sb.AppendLine(); @@ -388,12 +399,12 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter await Task.CompletedTask.ConfigureAwait(false); } - private static string GenerateBashReplayScript(UnifiedEvidenceResponseDto evidence) + private string GenerateBashReplayScript(UnifiedEvidenceResponseDto evidence) { var sb = new StringBuilder(); sb.AppendLine("#!/usr/bin/env bash"); sb.AppendLine("# StellaOps Evidence Bundle Replay Script"); - sb.AppendLine($"# Generated: {DateTimeOffset.UtcNow:O}"); + sb.AppendLine($"# Generated: {_timeProvider.GetUtcNow():O}"); sb.AppendLine($"# Finding: {evidence.FindingId}"); sb.AppendLine($"# CVE: {evidence.CveId}"); sb.AppendLine(); @@ -425,11 +436,11 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter return sb.ToString(); } - private static string GeneratePowerShellReplayScript(UnifiedEvidenceResponseDto evidence) + private string GeneratePowerShellReplayScript(UnifiedEvidenceResponseDto evidence) { var sb = new StringBuilder(); sb.AppendLine("# StellaOps Evidence Bundle Replay Script"); - sb.AppendLine($"# Generated: {DateTimeOffset.UtcNow:O}"); + sb.AppendLine($"# Generated: {_timeProvider.GetUtcNow():O}"); sb.AppendLine($"# Finding: {evidence.FindingId}"); sb.AppendLine($"# CVE: {evidence.CveId}"); sb.AppendLine(); @@ -461,7 +472,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter return sb.ToString(); } - private static string GenerateReadme(UnifiedEvidenceResponseDto evidence, List entries) + private string GenerateReadme(UnifiedEvidenceResponseDto evidence, List entries) { var sb = new StringBuilder(); sb.AppendLine("# StellaOps Evidence Bundle"); @@ -671,7 +682,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter Encoding.ASCII.GetBytes(sizeOctal).CopyTo(header, 124); // Mtime (136-147) - current time in octal - var mtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var mtime = _timeProvider.GetUtcNow().ToUnixTimeSeconds(); var mtimeOctal = Convert.ToString(mtime, 8).PadLeft(11, '0'); Encoding.ASCII.GetBytes(mtimeOctal).CopyTo(header, 136); diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/FeedChangeRescoreJob.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/FeedChangeRescoreJob.cs index 1f6cd0b9d..0267dc655 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Services/FeedChangeRescoreJob.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/FeedChangeRescoreJob.cs @@ -55,6 +55,7 @@ public sealed class FeedChangeRescoreJob : BackgroundService private readonly IScoreReplayService _replayService; private readonly IOptions _options; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private readonly ActivitySource _activitySource = new("StellaOps.Scanner.FeedChangeRescore"); private string? _lastConcelierSnapshot; @@ -66,13 +67,15 @@ public sealed class FeedChangeRescoreJob : BackgroundService IScanManifestRepository manifestRepository, IScoreReplayService replayService, IOptions options, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _feedTracker = feedTracker ?? throw new ArgumentNullException(nameof(feedTracker)); _manifestRepository = manifestRepository ?? throw new ArgumentNullException(nameof(manifestRepository)); _replayService = replayService ?? throw new ArgumentNullException(nameof(replayService)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -221,7 +224,7 @@ public sealed class FeedChangeRescoreJob : BackgroundService FeedChangeRescoreOptions opts, CancellationToken ct) { - var cutoff = DateTimeOffset.UtcNow - opts.ScanAgeLimit; + var cutoff = _timeProvider.GetUtcNow() - opts.ScanAgeLimit; // Find scans using the old snapshot hashes var query = new AffectedScansQuery diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/GatingReasonService.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/GatingReasonService.cs index 8d0596a66..492cc1929 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Services/GatingReasonService.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/GatingReasonService.cs @@ -18,16 +18,19 @@ public sealed class GatingReasonService : IGatingReasonService { private readonly TriageDbContext _dbContext; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; // Default policy trust threshold (configurable in real implementation) private const double DefaultPolicyTrustThreshold = 0.7; public GatingReasonService( TriageDbContext dbContext, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -262,11 +265,11 @@ public sealed class GatingReasonService : IGatingReasonService }; } - private static double GetRecencyTrust(DateTimeOffset? timestamp) + private double GetRecencyTrust(DateTimeOffset? timestamp) { if (timestamp is null) return 0.3; - var age = DateTimeOffset.UtcNow - timestamp.Value; + var age = _timeProvider.GetUtcNow() - timestamp.Value; return age.TotalDays switch { <= 7 => 1.0, // Within a week diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/IScoreReplayService.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/IScoreReplayService.cs index 21871bcec..8484194ec 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Services/IScoreReplayService.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/IScoreReplayService.cs @@ -89,9 +89,9 @@ public sealed record BundleVerifyResult( DateTimeOffset VerifiedAt, string? ErrorMessage = null) { - public static BundleVerifyResult Success(string computedRootHash) => - new(true, computedRootHash, true, true, DateTimeOffset.UtcNow); + public static BundleVerifyResult Success(string computedRootHash, TimeProvider? timeProvider = null) => + new(true, computedRootHash, true, true, (timeProvider ?? TimeProvider.System).GetUtcNow()); - public static BundleVerifyResult Failure(string error, string computedRootHash = "") => - new(false, computedRootHash, false, false, DateTimeOffset.UtcNow, error); + public static BundleVerifyResult Failure(string error, string computedRootHash = "", TimeProvider? timeProvider = null) => + new(false, computedRootHash, false, false, (timeProvider ?? TimeProvider.System).GetUtcNow(), error); } diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/OfflineKitManifestService.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/OfflineKitManifestService.cs index def0fd7e3..7859fa8ba 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Services/OfflineKitManifestService.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/OfflineKitManifestService.cs @@ -19,13 +19,16 @@ internal sealed class OfflineKitManifestService private readonly OfflineKitStateStore _stateStore; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; public OfflineKitManifestService( OfflineKitStateStore stateStore, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -49,7 +52,7 @@ internal sealed class OfflineKitManifestService Version = status.Current.BundleId ?? "unknown", Assets = BuildAssetMap(status.Components), Signature = null, // Would be loaded from bundle signature file - CreatedAt = status.Current.CapturedAt ?? DateTimeOffset.UtcNow, + CreatedAt = status.Current.CapturedAt ?? _timeProvider.GetUtcNow(), ExpiresAt = status.Current.CapturedAt?.AddDays(30) // Default 30-day expiry }; } @@ -155,7 +158,7 @@ internal sealed class OfflineKitManifestService private void ValidateExpiration(OfflineKitManifestTransport manifest, OfflineKitValidationResult result) { - if (manifest.ExpiresAt.HasValue && manifest.ExpiresAt.Value < DateTimeOffset.UtcNow) + if (manifest.ExpiresAt.HasValue && manifest.ExpiresAt.Value < _timeProvider.GetUtcNow()) { result.Warnings.Add(new OfflineKitValidationWarning { @@ -166,7 +169,7 @@ internal sealed class OfflineKitManifestService } // Check freshness (warn if older than 7 days) - var age = DateTimeOffset.UtcNow - manifest.CreatedAt; + var age = _timeProvider.GetUtcNow() - manifest.CreatedAt; if (age.TotalDays > 30) { result.Warnings.Add(new OfflineKitValidationWarning @@ -218,7 +221,7 @@ internal sealed class OfflineKitManifestService Valid = true, Algorithm = "ECDSA-P256", KeyId = "authority-key-001", - SignedAt = DateTimeOffset.UtcNow + SignedAt = _timeProvider.GetUtcNow() }; } catch (FormatException) diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/ReplayCommandService.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/ReplayCommandService.cs index 0931fbc79..c3646d135 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Services/ReplayCommandService.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/ReplayCommandService.cs @@ -20,6 +20,7 @@ public sealed class ReplayCommandService : IReplayCommandService { private readonly TriageDbContext _dbContext; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; // Configuration (would come from IOptions in real implementation) private const string DefaultBinary = "stellaops"; @@ -27,10 +28,12 @@ public sealed class ReplayCommandService : IReplayCommandService public ReplayCommandService( TriageDbContext dbContext, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -92,7 +95,7 @@ public sealed class ReplayCommandService : IReplayCommandService OfflineCommand = offlineCommand, Snapshot = snapshotInfo, Bundle = bundleInfo, - GeneratedAt = DateTimeOffset.UtcNow, + GeneratedAt = _timeProvider.GetUtcNow(), ExpectedVerdictHash = verdictHash }; } @@ -141,7 +144,7 @@ public sealed class ReplayCommandService : IReplayCommandService OfflineCommand = offlineCommand, Snapshot = snapshotInfo, Bundle = bundleInfo, - GeneratedAt = DateTimeOffset.UtcNow, + GeneratedAt = _timeProvider.GetUtcNow(), ExpectedFinalDigest = scan.FinalDigest ?? ComputeDigest($"scan:{scan.Id}") }; } @@ -358,7 +361,7 @@ public sealed class ReplayCommandService : IReplayCommandService return new SnapshotInfoDto { Id = snapshotId, - CreatedAt = scan?.SnapshotCreatedAt ?? DateTimeOffset.UtcNow, + CreatedAt = scan?.SnapshotCreatedAt ?? _timeProvider.GetUtcNow(), FeedVersions = scan?.FeedVersions ?? new Dictionary { ["nvd"] = "latest", @@ -381,7 +384,7 @@ public sealed class ReplayCommandService : IReplayCommandService SizeBytes = null, // Would be computed when bundle is generated ContentHash = contentHash, Format = "tar.gz", - ExpiresAt = DateTimeOffset.UtcNow.AddDays(7), + ExpiresAt = _timeProvider.GetUtcNow().AddDays(7), Contents = new[] { "manifest.json", @@ -405,7 +408,7 @@ public sealed class ReplayCommandService : IReplayCommandService SizeBytes = null, ContentHash = contentHash, Format = "tar.gz", - ExpiresAt = DateTimeOffset.UtcNow.AddDays(30), + ExpiresAt = _timeProvider.GetUtcNow().AddDays(30), Contents = new[] { "manifest.json", diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/RuntimeInventoryReconciler.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/RuntimeInventoryReconciler.cs index 93f2db4fc..f8900024f 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Services/RuntimeInventoryReconciler.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/RuntimeInventoryReconciler.cs @@ -84,13 +84,13 @@ internal sealed record RuntimeReconciliationResult public string? ErrorMessage { get; init; } - public static RuntimeReconciliationResult Error(string imageDigest, string code, string message) + public static RuntimeReconciliationResult Error(string imageDigest, string code, string message, TimeProvider? timeProvider = null) => new() { ImageDigest = imageDigest, ErrorCode = code, ErrorMessage = message, - ReconciledAt = DateTimeOffset.UtcNow + ReconciledAt = (timeProvider ?? TimeProvider.System).GetUtcNow() }; } diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/SecretDetectionSettingsService.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/SecretDetectionSettingsService.cs new file mode 100644 index 000000000..af05ac17f --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/SecretDetectionSettingsService.cs @@ -0,0 +1,497 @@ +// ----------------------------------------------------------------------------- +// SecretDetectionSettingsService.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-005 - Create Settings CRUD API endpoints +// Description: Service layer for secret detection configuration. +// ----------------------------------------------------------------------------- + +using System.Globalization; +using System.Text.Json; +using StellaOps.Scanner.Core.Secrets.Configuration; +using StellaOps.Scanner.Storage.Entities; +using StellaOps.Scanner.Storage.Repositories; +using StellaOps.Scanner.WebService.Contracts; + +namespace StellaOps.Scanner.WebService.Services; + +/// +/// Service interface for secret detection settings. +/// +public interface ISecretDetectionSettingsService +{ + /// Gets settings for a tenant. + Task GetSettingsAsync( + Guid tenantId, + CancellationToken cancellationToken = default); + + /// Creates default settings for a tenant. + Task CreateSettingsAsync( + Guid tenantId, + string createdBy, + CancellationToken cancellationToken = default); + + /// Updates settings with optimistic concurrency. + Task<(bool Success, SecretDetectionSettingsResponseDto? Settings, string? Error)> UpdateSettingsAsync( + Guid tenantId, + SecretDetectionSettingsDto settings, + int expectedVersion, + string updatedBy, + CancellationToken cancellationToken = default); + + /// Gets available rule categories. + Task GetRuleCategoriesAsync(CancellationToken cancellationToken = default); +} + +/// +/// Service interface for secret exception patterns. +/// +public interface ISecretExceptionPatternService +{ + /// Gets all exception patterns for a tenant. + Task GetPatternsAsync( + Guid tenantId, + bool includeInactive = false, + CancellationToken cancellationToken = default); + + /// Gets a specific pattern by ID. + Task GetPatternAsync( + Guid patternId, + CancellationToken cancellationToken = default); + + /// Creates a new exception pattern. + Task<(SecretExceptionPatternResponseDto? Pattern, IReadOnlyList Errors)> CreatePatternAsync( + Guid tenantId, + SecretExceptionPatternDto pattern, + string createdBy, + CancellationToken cancellationToken = default); + + /// Updates an exception pattern. + Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList Errors)> UpdatePatternAsync( + Guid patternId, + SecretExceptionPatternDto pattern, + string updatedBy, + CancellationToken cancellationToken = default); + + /// Deletes an exception pattern. + Task DeletePatternAsync( + Guid patternId, + CancellationToken cancellationToken = default); +} + +/// +/// Implementation of secret detection settings service. +/// +public sealed class SecretDetectionSettingsService : ISecretDetectionSettingsService +{ + private readonly ISecretDetectionSettingsRepository _repository; + private readonly TimeProvider _timeProvider; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public SecretDetectionSettingsService( + ISecretDetectionSettingsRepository repository, + TimeProvider timeProvider) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + public async Task GetSettingsAsync( + Guid tenantId, + CancellationToken cancellationToken = default) + { + var row = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false); + return row is null ? null : MapToDto(row); + } + + public async Task CreateSettingsAsync( + Guid tenantId, + string createdBy, + CancellationToken cancellationToken = default) + { + var defaultSettings = SecretDetectionSettings.CreateDefault(tenantId, createdBy); + var row = MapToRow(defaultSettings, tenantId, createdBy); + + var created = await _repository.CreateAsync(row, cancellationToken).ConfigureAwait(false); + return MapToDto(created); + } + + public async Task<(bool Success, SecretDetectionSettingsResponseDto? Settings, string? Error)> UpdateSettingsAsync( + Guid tenantId, + SecretDetectionSettingsDto settings, + int expectedVersion, + string updatedBy, + CancellationToken cancellationToken = default) + { + var existing = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false); + if (existing is null) + { + return (false, null, "Settings not found for tenant"); + } + + // Validate settings + var validationErrors = ValidateSettings(settings); + if (validationErrors.Count > 0) + { + return (false, null, string.Join("; ", validationErrors)); + } + + // Apply updates + existing.Enabled = settings.Enabled; + existing.RevelationPolicy = JsonSerializer.Serialize(settings.RevelationPolicy, JsonOptions); + existing.EnabledRuleCategories = settings.EnabledRuleCategories.ToArray(); + existing.DisabledRuleIds = settings.DisabledRuleIds.ToArray(); + existing.AlertSettings = JsonSerializer.Serialize(settings.AlertSettings, JsonOptions); + existing.MaxFileSizeBytes = settings.MaxFileSizeBytes; + existing.ExcludedFileExtensions = settings.ExcludedFileExtensions.ToArray(); + existing.ExcludedPaths = settings.ExcludedPaths.ToArray(); + existing.ScanBinaryFiles = settings.ScanBinaryFiles; + existing.RequireSignedRuleBundles = settings.RequireSignedRuleBundles; + existing.UpdatedBy = updatedBy; + + var success = await _repository.UpdateAsync(existing, expectedVersion, cancellationToken).ConfigureAwait(false); + if (!success) + { + return (false, null, "Version conflict - settings were modified by another request"); + } + + // Fetch updated version + var updated = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false); + return (true, updated is null ? null : MapToDto(updated), null); + } + + public Task GetRuleCategoriesAsync(CancellationToken cancellationToken = default) + { + var categories = new List + { + new() { Id = SecretRuleCategories.Aws, Name = "AWS", Description = "Amazon Web Services credentials", RuleCount = 15 }, + new() { Id = SecretRuleCategories.Gcp, Name = "GCP", Description = "Google Cloud Platform credentials", RuleCount = 12 }, + new() { Id = SecretRuleCategories.Azure, Name = "Azure", Description = "Microsoft Azure credentials", RuleCount = 10 }, + new() { Id = SecretRuleCategories.Generic, Name = "Generic", Description = "Generic secrets and passwords", RuleCount = 25 }, + new() { Id = SecretRuleCategories.PrivateKeys, Name = "Private Keys", Description = "SSH, PGP, and other private keys", RuleCount = 8 }, + new() { Id = SecretRuleCategories.Database, Name = "Database", Description = "Database connection strings and credentials", RuleCount = 18 }, + new() { Id = SecretRuleCategories.Messaging, Name = "Messaging", Description = "Messaging platform credentials (Slack, Discord)", RuleCount = 6 }, + new() { Id = SecretRuleCategories.Payment, Name = "Payment", Description = "Payment processor credentials (Stripe, PayPal)", RuleCount = 5 }, + new() { Id = SecretRuleCategories.SocialMedia, Name = "Social Media", Description = "Social media API keys", RuleCount = 8 }, + new() { Id = SecretRuleCategories.Internal, Name = "Internal", Description = "Custom internal secrets", RuleCount = 0 } + }; + + return Task.FromResult(new RuleCategoriesResponseDto { Categories = categories }); + } + + private static IReadOnlyList ValidateSettings(SecretDetectionSettingsDto settings) + { + var errors = new List(); + + if (settings.MaxFileSizeBytes < 1024) + { + errors.Add("MaxFileSizeBytes must be at least 1024 bytes"); + } + + if (settings.MaxFileSizeBytes > 100 * 1024 * 1024) + { + errors.Add("MaxFileSizeBytes must be 100 MB or less"); + } + + if (settings.RevelationPolicy.PartialRevealChars < 1 || settings.RevelationPolicy.PartialRevealChars > 10) + { + errors.Add("PartialRevealChars must be between 1 and 10"); + } + + if (settings.AlertSettings.Enabled && settings.AlertSettings.Destinations.Count == 0) + { + errors.Add("At least one destination is required when alerting is enabled"); + } + + if (settings.AlertSettings.MaxAlertsPerScan < 1 || settings.AlertSettings.MaxAlertsPerScan > 100) + { + errors.Add("MaxAlertsPerScan must be between 1 and 100"); + } + + return errors; + } + + private static SecretDetectionSettingsResponseDto MapToDto(SecretDetectionSettingsRow row) + { + var revelationPolicy = JsonSerializer.Deserialize(row.RevelationPolicy, JsonOptions) + ?? new RevelationPolicyDto + { + DefaultPolicy = SecretRevelationPolicyType.PartialReveal, + ExportPolicy = SecretRevelationPolicyType.FullMask, + PartialRevealChars = 4, + MaxMaskChars = 8, + FullRevealRoles = [] + }; + + var alertSettings = JsonSerializer.Deserialize(row.AlertSettings, JsonOptions) + ?? new SecretAlertSettingsDto + { + Enabled = false, + MinimumAlertSeverity = SecretSeverityType.High, + Destinations = [], + MaxAlertsPerScan = 10, + DeduplicationWindowMinutes = 1440, + IncludeFilePath = true, + IncludeMaskedValue = true, + IncludeImageRef = true + }; + + return new SecretDetectionSettingsResponseDto + { + TenantId = row.TenantId, + Settings = new SecretDetectionSettingsDto + { + Enabled = row.Enabled, + RevelationPolicy = revelationPolicy, + EnabledRuleCategories = row.EnabledRuleCategories, + DisabledRuleIds = row.DisabledRuleIds, + AlertSettings = alertSettings, + MaxFileSizeBytes = row.MaxFileSizeBytes, + ExcludedFileExtensions = row.ExcludedFileExtensions, + ExcludedPaths = row.ExcludedPaths, + ScanBinaryFiles = row.ScanBinaryFiles, + RequireSignedRuleBundles = row.RequireSignedRuleBundles + }, + Version = row.Version, + UpdatedAt = row.UpdatedAt, + UpdatedBy = row.UpdatedBy + }; + } + + private static SecretDetectionSettingsRow MapToRow(SecretDetectionSettings settings, Guid tenantId, string updatedBy) + { + var revelationPolicyDto = new RevelationPolicyDto + { + DefaultPolicy = (SecretRevelationPolicyType)settings.RevelationPolicy.DefaultPolicy, + ExportPolicy = (SecretRevelationPolicyType)settings.RevelationPolicy.ExportPolicy, + PartialRevealChars = settings.RevelationPolicy.PartialRevealChars, + MaxMaskChars = settings.RevelationPolicy.MaxMaskChars, + FullRevealRoles = settings.RevelationPolicy.FullRevealRoles + }; + + var alertSettingsDto = new SecretAlertSettingsDto + { + Enabled = settings.AlertSettings.Enabled, + MinimumAlertSeverity = (SecretSeverityType)settings.AlertSettings.MinimumAlertSeverity, + Destinations = settings.AlertSettings.Destinations.Select(d => new SecretAlertDestinationDto + { + Id = d.Id, + Name = d.Name, + ChannelType = (Contracts.AlertChannelType)d.ChannelType, + ChannelId = d.ChannelId, + SeverityFilter = d.SeverityFilter?.Select(s => (SecretSeverityType)s).ToList(), + RuleCategoryFilter = d.RuleCategoryFilter?.ToList(), + IsActive = d.IsActive + }).ToList(), + MaxAlertsPerScan = settings.AlertSettings.MaxAlertsPerScan, + DeduplicationWindowMinutes = (int)settings.AlertSettings.DeduplicationWindow.TotalMinutes, + IncludeFilePath = settings.AlertSettings.IncludeFilePath, + IncludeMaskedValue = settings.AlertSettings.IncludeMaskedValue, + IncludeImageRef = settings.AlertSettings.IncludeImageRef, + AlertMessagePrefix = settings.AlertSettings.AlertMessagePrefix + }; + + return new SecretDetectionSettingsRow + { + TenantId = tenantId, + Enabled = settings.Enabled, + RevelationPolicy = JsonSerializer.Serialize(revelationPolicyDto, JsonOptions), + EnabledRuleCategories = settings.EnabledRuleCategories.ToArray(), + DisabledRuleIds = settings.DisabledRuleIds.ToArray(), + AlertSettings = JsonSerializer.Serialize(alertSettingsDto, JsonOptions), + MaxFileSizeBytes = settings.MaxFileSizeBytes, + ExcludedFileExtensions = settings.ExcludedFileExtensions.ToArray(), + ExcludedPaths = settings.ExcludedPaths.ToArray(), + ScanBinaryFiles = settings.ScanBinaryFiles, + RequireSignedRuleBundles = settings.RequireSignedRuleBundles, + UpdatedBy = updatedBy + }; + } +} + +/// +/// Implementation of secret exception pattern service. +/// +public sealed class SecretExceptionPatternService : ISecretExceptionPatternService +{ + private readonly ISecretExceptionPatternRepository _repository; + private readonly TimeProvider _timeProvider; + + public SecretExceptionPatternService( + ISecretExceptionPatternRepository repository, + TimeProvider timeProvider) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + public async Task GetPatternsAsync( + Guid tenantId, + bool includeInactive = false, + CancellationToken cancellationToken = default) + { + var patterns = includeInactive + ? await _repository.GetAllByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false) + : await _repository.GetActiveByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false); + + return new SecretExceptionPatternListResponseDto + { + Patterns = patterns.Select(MapToDto).ToList(), + TotalCount = patterns.Count + }; + } + + public async Task GetPatternAsync( + Guid patternId, + CancellationToken cancellationToken = default) + { + var pattern = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false); + return pattern is null ? null : MapToDto(pattern); + } + + public async Task<(SecretExceptionPatternResponseDto? Pattern, IReadOnlyList Errors)> CreatePatternAsync( + Guid tenantId, + SecretExceptionPatternDto pattern, + string createdBy, + CancellationToken cancellationToken = default) + { + var errors = ValidatePattern(pattern); + if (errors.Count > 0) + { + return (null, errors); + } + + var row = new SecretExceptionPatternRow + { + TenantId = tenantId, + Name = pattern.Name, + Description = pattern.Description, + ValuePattern = pattern.ValuePattern, + ApplicableRuleIds = pattern.ApplicableRuleIds.ToArray(), + FilePathGlob = pattern.FilePathGlob, + Justification = pattern.Justification, + ExpiresAt = pattern.ExpiresAt, + IsActive = pattern.IsActive, + CreatedBy = createdBy + }; + + var created = await _repository.CreateAsync(row, cancellationToken).ConfigureAwait(false); + return (MapToDto(created), []); + } + + public async Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList Errors)> UpdatePatternAsync( + Guid patternId, + SecretExceptionPatternDto pattern, + string updatedBy, + CancellationToken cancellationToken = default) + { + var existing = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false); + if (existing is null) + { + return (false, null, ["Pattern not found"]); + } + + var errors = ValidatePattern(pattern); + if (errors.Count > 0) + { + return (false, null, errors); + } + + existing.Name = pattern.Name; + existing.Description = pattern.Description; + existing.ValuePattern = pattern.ValuePattern; + existing.ApplicableRuleIds = pattern.ApplicableRuleIds.ToArray(); + existing.FilePathGlob = pattern.FilePathGlob; + existing.Justification = pattern.Justification; + existing.ExpiresAt = pattern.ExpiresAt; + existing.IsActive = pattern.IsActive; + existing.UpdatedBy = updatedBy; + existing.UpdatedAt = _timeProvider.GetUtcNow(); + + var success = await _repository.UpdateAsync(existing, cancellationToken).ConfigureAwait(false); + if (!success) + { + return (false, null, ["Failed to update pattern"]); + } + + var updated = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false); + return (true, updated is null ? null : MapToDto(updated), []); + } + + public async Task DeletePatternAsync( + Guid patternId, + CancellationToken cancellationToken = default) + { + return await _repository.DeleteAsync(patternId, cancellationToken).ConfigureAwait(false); + } + + private static IReadOnlyList ValidatePattern(SecretExceptionPatternDto pattern) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(pattern.Name)) + { + errors.Add("Name is required"); + } + else if (pattern.Name.Length > 100) + { + errors.Add("Name must be 100 characters or less"); + } + + if (string.IsNullOrWhiteSpace(pattern.ValuePattern)) + { + errors.Add("ValuePattern is required"); + } + else + { + try + { + _ = new System.Text.RegularExpressions.Regex(pattern.ValuePattern); + } + catch (System.Text.RegularExpressions.RegexParseException ex) + { + errors.Add(string.Format(CultureInfo.InvariantCulture, "ValuePattern is not a valid regex: {0}", ex.Message)); + } + } + + if (string.IsNullOrWhiteSpace(pattern.Justification)) + { + errors.Add("Justification is required"); + } + else if (pattern.Justification.Length < 20) + { + errors.Add("Justification must be at least 20 characters"); + } + + return errors; + } + + private static SecretExceptionPatternResponseDto MapToDto(SecretExceptionPatternRow row) + { + return new SecretExceptionPatternResponseDto + { + Id = row.ExceptionId, + TenantId = row.TenantId, + Pattern = new SecretExceptionPatternDto + { + Name = row.Name, + Description = row.Description, + ValuePattern = row.ValuePattern, + ApplicableRuleIds = row.ApplicableRuleIds, + FilePathGlob = row.FilePathGlob, + Justification = row.Justification, + ExpiresAt = row.ExpiresAt, + IsActive = row.IsActive + }, + MatchCount = row.MatchCount, + LastMatchedAt = row.LastMatchedAt, + CreatedAt = row.CreatedAt, + CreatedBy = row.CreatedBy, + UpdatedAt = row.UpdatedAt, + UpdatedBy = row.UpdatedBy + }; + } +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj b/src/Scanner/StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj index 6a2ec75fa..a71932290 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj +++ b/src/Scanner/StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj @@ -11,12 +11,14 @@ + + diff --git a/src/Scanner/StellaOps.Scanner.Worker/Determinism/FidelityMetricsService.cs b/src/Scanner/StellaOps.Scanner.Worker/Determinism/FidelityMetricsService.cs index 472006170..2753c3248 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Determinism/FidelityMetricsService.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Determinism/FidelityMetricsService.cs @@ -10,12 +10,14 @@ public sealed class FidelityMetricsService private readonly BitwiseFidelityCalculator _bitwiseCalculator; private readonly SemanticFidelityCalculator _semanticCalculator; private readonly PolicyFidelityCalculator _policyCalculator; + private readonly TimeProvider _timeProvider; - public FidelityMetricsService() + public FidelityMetricsService(TimeProvider? timeProvider = null) { _bitwiseCalculator = new BitwiseFidelityCalculator(); _semanticCalculator = new SemanticFidelityCalculator(); _policyCalculator = new PolicyFidelityCalculator(); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -67,7 +69,7 @@ public sealed class FidelityMetricsService IdenticalOutputs = bfIdentical, SemanticMatches = sfMatches, PolicyMatches = pfMatches, - ComputedAt = DateTimeOffset.UtcNow, + ComputedAt = _timeProvider.GetUtcNow(), Mismatches = allMismatches.Count > 0 ? allMismatches : null }; } @@ -108,7 +110,7 @@ public sealed class FidelityMetricsService Passed = failures.Count == 0, ShouldBlockRelease = shouldBlock, FailureReasons = failures, - EvaluatedAt = DateTimeOffset.UtcNow + EvaluatedAt = _timeProvider.GetUtcNow() }; } diff --git a/src/Scanner/StellaOps.Scanner.Worker/Orchestration/PoEOrchestrator.cs b/src/Scanner/StellaOps.Scanner.Worker/Orchestration/PoEOrchestrator.cs index 3fe5baeda..5b528e325 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Orchestration/PoEOrchestrator.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Orchestration/PoEOrchestrator.cs @@ -18,17 +18,20 @@ public class PoEOrchestrator private readonly IProofEmitter _emitter; private readonly IPoECasStore _casStore; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; public PoEOrchestrator( IReachabilityResolver resolver, IProofEmitter emitter, IPoECasStore casStore, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); _emitter = emitter ?? throw new ArgumentNullException(nameof(emitter)); _casStore = casStore ?? throw new ArgumentNullException(nameof(casStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -135,7 +138,7 @@ public class PoEOrchestrator { // Build metadata var metadata = new ProofMetadata( - GeneratedAt: DateTime.UtcNow, + GeneratedAt: _timeProvider.GetUtcNow().UtcDateTime, Analyzer: new AnalyzerInfo( Name: "stellaops-scanner", Version: context.ScannerVersion, @@ -144,7 +147,7 @@ public class PoEOrchestrator Policy: new PolicyInfo( PolicyId: context.PolicyId, PolicyDigest: context.PolicyDigest, - EvaluatedAt: DateTime.UtcNow + EvaluatedAt: _timeProvider.GetUtcNow().UtcDateTime ), ReproSteps: GenerateReproSteps(context, subgraph) ); diff --git a/src/Scanner/StellaOps.Scanner.Worker/Processing/BinaryFindingMapper.cs b/src/Scanner/StellaOps.Scanner.Worker/Processing/BinaryFindingMapper.cs index dc865ec55..aebad4b3c 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Processing/BinaryFindingMapper.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Processing/BinaryFindingMapper.cs @@ -22,13 +22,16 @@ public sealed class BinaryFindingMapper { private readonly IBinaryVulnerabilityService _binaryVulnService; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; public BinaryFindingMapper( IBinaryVulnerabilityService binaryVulnService, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _binaryVulnService = binaryVulnService ?? throw new ArgumentNullException(nameof(binaryVulnService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -62,7 +65,7 @@ public sealed class BinaryFindingMapper }, Remediation = GenerateRemediation(finding), ScanId = finding.ScanId, - DetectedAt = DateTimeOffset.UtcNow + DetectedAt = _timeProvider.GetUtcNow() }; } diff --git a/src/Scanner/StellaOps.Scanner.Worker/StellaOps.Scanner.Worker.csproj b/src/Scanner/StellaOps.Scanner.Worker/StellaOps.Scanner.Worker.csproj index 63f044bbe..6797c906b 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/StellaOps.Scanner.Worker.csproj +++ b/src/Scanner/StellaOps.Scanner.Worker/StellaOps.Scanner.Worker.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Deno/Internal/Runtime/DenoRuntimeTraceRecorder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Deno/Internal/Runtime/DenoRuntimeTraceRecorder.cs index 67c8adf89..ab2cd0fd7 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Deno/Internal/Runtime/DenoRuntimeTraceRecorder.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Deno/Internal/Runtime/DenoRuntimeTraceRecorder.cs @@ -9,18 +9,20 @@ internal sealed class DenoRuntimeTraceRecorder { private readonly List _events = new(); private readonly string _rootPath; + private readonly TimeProvider _timeProvider; - public DenoRuntimeTraceRecorder(string rootPath) + public DenoRuntimeTraceRecorder(string rootPath, TimeProvider? timeProvider = null) { ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); _rootPath = Path.GetFullPath(rootPath); + _timeProvider = timeProvider ?? TimeProvider.System; } public void AddModuleLoad(string absoluteModulePath, string reason, IEnumerable permissions, string? origin = null, DateTimeOffset? timestamp = null) { var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath); var evt = new DenoModuleLoadEvent( - Ts: timestamp ?? DateTimeOffset.UtcNow, + Ts: timestamp ?? _timeProvider.GetUtcNow(), Module: identity, Reason: reason ?? string.Empty, Permissions: NormalizePermissions(permissions), @@ -32,7 +34,7 @@ internal sealed class DenoRuntimeTraceRecorder { var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath); var evt = new DenoPermissionUseEvent( - Ts: timestamp ?? DateTimeOffset.UtcNow, + Ts: timestamp ?? _timeProvider.GetUtcNow(), Permission: permission ?? string.Empty, Module: identity, Details: details ?? string.Empty); @@ -42,7 +44,7 @@ internal sealed class DenoRuntimeTraceRecorder public void AddNpmResolution(string specifier, string package, string version, string resolved, bool exists, DateTimeOffset? timestamp = null) { _events.Add(new DenoNpmResolutionEvent( - Ts: timestamp ?? DateTimeOffset.UtcNow, + Ts: timestamp ?? _timeProvider.GetUtcNow(), Specifier: specifier ?? string.Empty, Package: package ?? string.Empty, Version: version ?? string.Empty, @@ -54,7 +56,7 @@ internal sealed class DenoRuntimeTraceRecorder { var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath); _events.Add(new DenoWasmLoadEvent( - Ts: timestamp ?? DateTimeOffset.UtcNow, + Ts: timestamp ?? _timeProvider.GetUtcNow(), Module: identity, Importer: importerRelativePath ?? string.Empty, Reason: reason ?? string.Empty)); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.DotNet/Internal/Callgraph/DotNetCallgraphBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.DotNet/Internal/Callgraph/DotNetCallgraphBuilder.cs index 585920d71..28ea4befa 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.DotNet/Internal/Callgraph/DotNetCallgraphBuilder.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.DotNet/Internal/Callgraph/DotNetCallgraphBuilder.cs @@ -19,12 +19,14 @@ internal sealed class DotNetCallgraphBuilder private readonly Dictionary _typeToAssemblyPath = new(); private readonly Dictionary _assemblyToPurl = new(); private readonly string _contextDigest; + private readonly TimeProvider _timeProvider; private int _assemblyCount; private int _typeCount; - public DotNetCallgraphBuilder(string contextDigest) + public DotNetCallgraphBuilder(string contextDigest, TimeProvider? timeProvider = null) { _contextDigest = contextDigest; + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -114,7 +116,7 @@ internal sealed class DotNetCallgraphBuilder var contentHash = DotNetGraphIdentifiers.ComputeGraphHash(methods, edges, roots); var metadata = new DotNetGraphMetadata( - GeneratedAt: DateTimeOffset.UtcNow, + GeneratedAt: _timeProvider.GetUtcNow(), GeneratorVersion: DotNetGraphIdentifiers.GetGeneratorVersion(), ContextDigest: _contextDigest, AssemblyCount: _assemblyCount, diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/Internal/Callgraph/JavaCallgraphBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/Internal/Callgraph/JavaCallgraphBuilder.cs index 34278eb7c..67f7c243a 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/Internal/Callgraph/JavaCallgraphBuilder.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/Internal/Callgraph/JavaCallgraphBuilder.cs @@ -16,12 +16,14 @@ internal sealed class JavaCallgraphBuilder private readonly List _unknowns = new(); private readonly Dictionary _classToJarPath = new(); private readonly string _contextDigest; + private readonly TimeProvider _timeProvider; private int _jarCount; private int _classCount; - public JavaCallgraphBuilder(string contextDigest) + public JavaCallgraphBuilder(string contextDigest, TimeProvider? timeProvider = null) { _contextDigest = contextDigest; + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -177,7 +179,7 @@ internal sealed class JavaCallgraphBuilder var contentHash = JavaGraphIdentifiers.ComputeGraphHash(methods, edges, roots); var metadata = new JavaGraphMetadata( - GeneratedAt: DateTimeOffset.UtcNow, + GeneratedAt: _timeProvider.GetUtcNow(), GeneratorVersion: JavaGraphIdentifiers.GetGeneratorVersion(), ContextDigest: _contextDigest, JarCount: _jarCount, diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/Internal/Resolver/JavaEntrypointAocWriter.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/Internal/Resolver/JavaEntrypointAocWriter.cs index 00e841956..b5f93dcc4 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/Internal/Resolver/JavaEntrypointAocWriter.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/Internal/Resolver/JavaEntrypointAocWriter.cs @@ -28,13 +28,14 @@ internal static class JavaEntrypointAocWriter string tenantId, string scanId, Stream outputStream, - CancellationToken cancellationToken) + TimeProvider? timeProvider = null, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(resolution); ArgumentNullException.ThrowIfNull(outputStream); using var writer = new StreamWriter(outputStream, Encoding.UTF8, leaveOpen: true); - var timestamp = DateTimeOffset.UtcNow; + var timestamp = (timeProvider ?? TimeProvider.System).GetUtcNow(); // Write header record var header = new AocHeader diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Native/Internal/Callgraph/NativeCallgraphBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Native/Internal/Callgraph/NativeCallgraphBuilder.cs index a227e5799..fbe241220 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Native/Internal/Callgraph/NativeCallgraphBuilder.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Native/Internal/Callgraph/NativeCallgraphBuilder.cs @@ -15,11 +15,13 @@ internal sealed class NativeCallgraphBuilder private readonly List _unknowns = new(); private readonly Dictionary _addressToSymbolId = new(); private readonly string _layerDigest; + private readonly TimeProvider _timeProvider; private int _binaryCount; - public NativeCallgraphBuilder(string layerDigest) + public NativeCallgraphBuilder(string layerDigest, TimeProvider? timeProvider = null) { _layerDigest = layerDigest; + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -80,7 +82,7 @@ internal sealed class NativeCallgraphBuilder var contentHash = NativeGraphIdentifiers.ComputeGraphHash(functions, edges, roots); var metadata = new NativeGraphMetadata( - GeneratedAt: DateTimeOffset.UtcNow, + GeneratedAt: _timeProvider.GetUtcNow(), GeneratorVersion: NativeGraphIdentifiers.GetGeneratorVersion(), LayerDigest: _layerDigest, BinaryCount: _binaryCount, diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Benchmark/Claims/ClaimsIndex.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Benchmark/Claims/ClaimsIndex.cs index 7e7bf6df8..9a24f2091 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Benchmark/Claims/ClaimsIndex.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Benchmark/Claims/ClaimsIndex.cs @@ -192,6 +192,17 @@ public enum ClaimStatus /// public sealed class BattlecardGenerator { + private readonly TimeProvider _timeProvider; + + /// + /// Creates a new battlecard generator. + /// + /// Optional time provider for deterministic timestamps. + public BattlecardGenerator(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + /// /// Generates a markdown battlecard from claims and metrics. /// @@ -201,7 +212,7 @@ public sealed class BattlecardGenerator sb.AppendLine("# Stella Ops Scanner - Competitive Battlecard"); sb.AppendLine(); - sb.AppendLine($"*Generated: {DateTimeOffset.UtcNow:yyyy-MM-dd HH:mm:ss} UTC*"); + sb.AppendLine($"*Generated: {_timeProvider.GetUtcNow():yyyy-MM-dd HH:mm:ss} UTC*"); sb.AppendLine(); // Key Differentiators diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Benchmark/Metrics/MetricsCalculator.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Benchmark/Metrics/MetricsCalculator.cs index 33914f73c..1519a61e3 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Benchmark/Metrics/MetricsCalculator.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Benchmark/Metrics/MetricsCalculator.cs @@ -8,6 +8,17 @@ namespace StellaOps.Scanner.Benchmark.Metrics; /// public sealed class MetricsCalculator { + private readonly TimeProvider _timeProvider; + + /// + /// Creates a new metrics calculator. + /// + /// Optional time provider for deterministic timestamps. + public MetricsCalculator(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + /// /// Calculates metrics for a single image. /// @@ -49,7 +60,7 @@ public sealed class MetricsCalculator FalsePositives = fp, TrueNegatives = tn, FalseNegatives = fn, - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }; } @@ -74,7 +85,7 @@ public sealed class MetricsCalculator TotalTrueNegatives = totalTn, TotalFalseNegatives = totalFn, PerImageMetrics = perImageMetrics, - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/Binary/BinaryCallGraphExtractor.cs b/src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/Binary/BinaryCallGraphExtractor.cs index 07d39c210..fdcd3b7d1 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/Binary/BinaryCallGraphExtractor.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/Binary/BinaryCallGraphExtractor.cs @@ -513,8 +513,10 @@ public sealed class BinaryCallGraphExtractor : ICallGraphExtractor var shStrTab = reader.ReadBytes((int)shStrTabSize); // Find symbol and string tables for resolving names + // Note: symtab/strtab values are captured for future use with static symbols long symtabOffset = 0, strtabOffset = 0; long symtabSize = 0; + _ = (symtabOffset, strtabOffset, symtabSize); // Suppress unused warnings int symtabEntrySize = is64Bit ? 24 : 16; // Find .dynsym and .dynstr for dynamic relocations diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Epss/EpssEvidence.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Epss/EpssEvidence.cs index 8ceff3696..319774ed0 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Epss/EpssEvidence.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Epss/EpssEvidence.cs @@ -70,7 +70,8 @@ public sealed record EpssEvidence double percentile, DateOnly modelDate, string? source = null, - bool fromCache = false) + bool fromCache = false, + TimeProvider? timeProvider = null) { return new EpssEvidence { @@ -78,7 +79,7 @@ public sealed record EpssEvidence Score = score, Percentile = percentile, ModelDate = modelDate, - CapturedAt = DateTimeOffset.UtcNow, + CapturedAt = (timeProvider ?? TimeProvider.System).GetUtcNow(), Source = source, FromCache = fromCache }; diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Models/FalsificationConditions.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Models/FalsificationConditions.cs index cf87f1723..958b231f6 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Models/FalsificationConditions.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Models/FalsificationConditions.cs @@ -334,6 +334,13 @@ public sealed record FindingContext /// public sealed class DefaultFalsificationConditionGenerator : IFalsificationConditionGenerator { + private readonly TimeProvider _timeProvider; + + public DefaultFalsificationConditionGenerator(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + public FalsificationConditions Generate(FindingContext context) { var conditions = new List(); @@ -425,7 +432,7 @@ public sealed class DefaultFalsificationConditionGenerator : IFalsificationCondi ComponentPurl = context.ComponentPurl, Conditions = conditions.ToImmutableArray(), Operator = FalsificationOperator.Any, - GeneratedAt = DateTimeOffset.UtcNow, + GeneratedAt = _timeProvider.GetUtcNow(), Generator = "StellaOps.DefaultFalsificationGenerator/1.0" }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Models/ZeroDayWindowTracking.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Models/ZeroDayWindowTracking.cs index db20687ce..6a31f52ee 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Models/ZeroDayWindowTracking.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Models/ZeroDayWindowTracking.cs @@ -298,6 +298,13 @@ public interface IZeroDayWindowTracker /// public sealed class ZeroDayWindowCalculator { + private readonly TimeProvider _timeProvider; + + public ZeroDayWindowCalculator(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + /// /// Computes the risk score for a window. /// @@ -326,7 +333,7 @@ public sealed class ZeroDayWindowCalculator { // Patch available but not applied var hoursSincePatch = window.PatchAvailableAt.HasValue - ? (DateTimeOffset.UtcNow - window.PatchAvailableAt.Value).TotalHours + ? (_timeProvider.GetUtcNow() - window.PatchAvailableAt.Value).TotalHours : 0; score = hoursSincePatch switch @@ -359,7 +366,7 @@ public sealed class ZeroDayWindowCalculator return new ZeroDayWindowStats { ArtifactDigest = artifactDigest, - ComputedAt = DateTimeOffset.UtcNow, + ComputedAt = _timeProvider.GetUtcNow(), TotalWindows = 0, AggregateRiskScore = 0 }; @@ -390,7 +397,7 @@ public sealed class ZeroDayWindowCalculator return new ZeroDayWindowStats { ArtifactDigest = artifactDigest, - ComputedAt = DateTimeOffset.UtcNow, + ComputedAt = _timeProvider.GetUtcNow(), TotalWindows = windowList.Count, ActiveWindows = windowList.Count(w => w.Status == ZeroDayWindowStatus.ActiveNoPatch || @@ -415,7 +422,7 @@ public sealed class ZeroDayWindowCalculator DateTimeOffset? patchAvailableAt = null, DateTimeOffset? remediatedAt = null) { - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); var timeline = new List(); if (disclosedAt.HasValue) diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/ProofBundleWriter.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/ProofBundleWriter.cs index 0d7d77258..44a8550e2 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Core/ProofBundleWriter.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/ProofBundleWriter.cs @@ -111,6 +111,7 @@ public sealed class ProofBundleWriterOptions public sealed class ProofBundleWriter : IProofBundleWriter { private readonly ProofBundleWriterOptions _options; + private readonly TimeProvider _timeProvider; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, @@ -119,9 +120,10 @@ public sealed class ProofBundleWriter : IProofBundleWriter PropertyNameCaseInsensitive = true }; - public ProofBundleWriter(ProofBundleWriterOptions? options = null) + public ProofBundleWriter(ProofBundleWriterOptions? options = null, TimeProvider? timeProvider = null) { _options = options ?? new ProofBundleWriterOptions(); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -134,7 +136,7 @@ public sealed class ProofBundleWriter : IProofBundleWriter ArgumentNullException.ThrowIfNull(ledger); var rootHash = ledger.RootHash(); - var createdAt = DateTimeOffset.UtcNow; + var createdAt = _timeProvider.GetUtcNow(); // Ensure storage directory exists Directory.CreateDirectory(_options.StorageBasePath); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/ScanManifest.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/ScanManifest.cs index 119a7d6c2..30fcebfa9 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Core/ScanManifest.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/ScanManifest.cs @@ -55,8 +55,8 @@ public sealed record ScanManifest( /// /// Create a manifest builder with required fields. /// - public static ScanManifestBuilder CreateBuilder(string scanId, string artifactDigest) => - new(scanId, artifactDigest); + public static ScanManifestBuilder CreateBuilder(string scanId, string artifactDigest, TimeProvider? timeProvider = null) => + new(scanId, artifactDigest, timeProvider); /// /// Serialize to canonical JSON (for hashing). @@ -99,7 +99,8 @@ public sealed class ScanManifestBuilder { private readonly string _scanId; private readonly string _artifactDigest; - private DateTimeOffset _createdAtUtc = DateTimeOffset.UtcNow; + private readonly TimeProvider _timeProvider; + private DateTimeOffset? _createdAtUtc; private string? _artifactPurl; private string _scannerVersion = "1.0.0"; private string _workerVersion = "1.0.0"; @@ -110,10 +111,11 @@ public sealed class ScanManifestBuilder private byte[] _seed = new byte[32]; private readonly Dictionary _knobs = []; - internal ScanManifestBuilder(string scanId, string artifactDigest) + internal ScanManifestBuilder(string scanId, string artifactDigest, TimeProvider? timeProvider = null) { _scanId = scanId ?? throw new ArgumentNullException(nameof(scanId)); _artifactDigest = artifactDigest ?? throw new ArgumentNullException(nameof(artifactDigest)); + _timeProvider = timeProvider ?? TimeProvider.System; } public ScanManifestBuilder WithCreatedAt(DateTimeOffset createdAtUtc) @@ -187,7 +189,7 @@ public sealed class ScanManifestBuilder public ScanManifest Build() => new( ScanId: _scanId, - CreatedAtUtc: _createdAtUtc, + CreatedAtUtc: _createdAtUtc ?? _timeProvider.GetUtcNow(), ArtifactDigest: _artifactDigest, ArtifactPurl: _artifactPurl, ScannerVersion: _scannerVersion, diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/ScanManifestSigner.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/ScanManifestSigner.cs index 9c2166e62..540b565b3 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Core/ScanManifestSigner.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/ScanManifestSigner.cs @@ -77,11 +77,11 @@ public sealed record ManifestVerificationResult( string? ErrorMessage = null, string? KeyId = null) { - public static ManifestVerificationResult Success(ScanManifest manifest, string? keyId = null) => - new(true, manifest, DateTimeOffset.UtcNow, null, keyId); + public static ManifestVerificationResult Success(ScanManifest manifest, string? keyId = null, TimeProvider? timeProvider = null) => + new(true, manifest, (timeProvider ?? TimeProvider.System).GetUtcNow(), null, keyId); - public static ManifestVerificationResult Failure(string error) => - new(false, null, DateTimeOffset.UtcNow, error); + public static ManifestVerificationResult Failure(string error, TimeProvider? timeProvider = null) => + new(false, null, (timeProvider ?? TimeProvider.System).GetUtcNow(), error); } /// diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/ISecretAlertDeduplicator.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/ISecretAlertDeduplicator.cs new file mode 100644 index 000000000..32ff393e9 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/ISecretAlertDeduplicator.cs @@ -0,0 +1,77 @@ +// ----------------------------------------------------------------------------- +// ISecretAlertDeduplicator.cs +// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration) +// Task: SDA-006 - Implement rate limiting / deduplication +// Description: Interface for deduplicating and rate-limiting secret alerts. +// ----------------------------------------------------------------------------- + +namespace StellaOps.Scanner.Core.Secrets.Alerts; + +/// +/// Handles deduplication and rate limiting for secret alerts. +/// +/// +/// Uses distributed cache (Valkey) to track: +/// - Recent alerts by deduplication key (prevents duplicate alerts) +/// - Alert count per scan (enforces per-scan rate limits) +/// Per SPRINT_20260104_007_BE task SDA-006. +/// +public interface ISecretAlertDeduplicator +{ + /// + /// Checks if an alert should be sent or is a duplicate. + /// + /// The deduplication key (from SecretFindingAlertEvent). + /// Deduplication window (don't alert same key within this period). + /// Cancellation token. + /// True if alert should be sent, false if duplicate. + Task ShouldAlertAsync( + string deduplicationKey, + TimeSpan window, + CancellationToken cancellationToken = default); + + /// + /// Records that an alert was sent, for future deduplication. + /// + /// The deduplication key. + /// How long to remember this alert. + /// Cancellation token. + Task RecordAlertSentAsync( + string deduplicationKey, + TimeSpan window, + CancellationToken cancellationToken = default); + + /// + /// Checks if scan has exceeded alert rate limit. + /// + /// Scan identifier. + /// Maximum alerts allowed for this scan. + /// Cancellation token. + /// True if under limit, false if exceeded. + Task IsUnderRateLimitAsync( + Guid scanId, + int maxAlerts, + CancellationToken cancellationToken = default); + + /// + /// Increments the alert count for a scan. + /// + /// Scan identifier. + /// How long to keep the counter (should outlive scan duration). + /// Cancellation token. + /// New alert count for the scan. + Task IncrementScanAlertCountAsync( + Guid scanId, + TimeSpan ttl, + CancellationToken cancellationToken = default); + + /// + /// Gets current alert count for a scan. + /// + /// Scan identifier. + /// Cancellation token. + /// Current alert count, 0 if not tracked. + Task GetScanAlertCountAsync( + Guid scanId, + CancellationToken cancellationToken = default); +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/ISecretAlertEmitter.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/ISecretAlertEmitter.cs new file mode 100644 index 000000000..ed59e3d97 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/ISecretAlertEmitter.cs @@ -0,0 +1,146 @@ +// ----------------------------------------------------------------------------- +// ISecretAlertEmitter.cs +// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration) +// Task: SDA-005 - Add alert emission to SecretsAnalyzerHost +// Description: Interface for emitting secret finding alerts to notification channels. +// ----------------------------------------------------------------------------- + +namespace StellaOps.Scanner.Core.Secrets.Alerts; + +/// +/// Emits secret finding alerts to configured notification channels. +/// +/// +/// Implementations handle routing to Slack, Teams, Email, Webhook, PagerDuty, etc. +/// Deduplication and rate limiting are handled by . +/// Per SPRINT_20260104_007_BE task SDA-005. +/// +public interface ISecretAlertEmitter +{ + /// + /// Emits an alert for a secret finding. + /// + /// The alert event to emit. + /// Cancellation token. + /// True if alert was emitted to at least one channel, false if skipped. + /// + /// Alert may be skipped due to: + /// - Alerting disabled for tenant + /// - Severity below threshold + /// - No matching destinations + /// - Rate limit exceeded + /// - Deduplicated (same secret alerted recently) + /// + Task EmitAsync( + SecretFindingAlertEvent alert, + CancellationToken cancellationToken = default); + + /// + /// Emits alerts for multiple findings in a batch. + /// + /// The alert events to emit. + /// Cancellation token. + /// Results for each alert. + /// + /// Batch processing respects per-scan rate limits. + /// + Task> EmitBatchAsync( + IReadOnlyList alerts, + CancellationToken cancellationToken = default); +} + +/// +/// Result of an alert emission attempt. +/// +public sealed record AlertEmissionResult +{ + /// + /// The alert event that was processed. + /// + public required Guid EventId { get; init; } + + /// + /// Whether the alert was emitted to at least one channel. + /// + public required bool WasEmitted { get; init; } + + /// + /// Channels the alert was sent to. + /// + public required IReadOnlyList Channels { get; init; } + + /// + /// Reason if alert was skipped. + /// + public AlertSkipReason? SkipReason { get; init; } + + /// + /// Additional context about skip reason. + /// + public string? SkipDetails { get; init; } + + /// + /// Creates a successful emission result. + /// + public static AlertEmissionResult Success(Guid eventId, IReadOnlyList channels) => new() + { + EventId = eventId, + WasEmitted = true, + Channels = channels, + SkipReason = null, + SkipDetails = null + }; + + /// + /// Creates a skipped emission result. + /// + public static AlertEmissionResult Skipped(Guid eventId, AlertSkipReason reason, string? details = null) => new() + { + EventId = eventId, + WasEmitted = false, + Channels = [], + SkipReason = reason, + SkipDetails = details + }; +} + +/// +/// Reason why an alert was not emitted. +/// +public enum AlertSkipReason +{ + /// + /// Alerting is disabled for the tenant. + /// + AlertingDisabled, + + /// + /// Finding severity below minimum threshold. + /// + BelowSeverityThreshold, + + /// + /// No alert destinations configured. + /// + NoDestinations, + + /// + /// No destinations match the finding (by severity/category filters). + /// + NoMatchingDestinations, + + /// + /// Rate limit exceeded for this scan. + /// + RateLimitExceeded, + + /// + /// Same finding was alerted within deduplication window. + /// + Deduplicated, + + /// + /// Alert emission failed. + /// + EmissionFailed +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/ISecretAlertRouter.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/ISecretAlertRouter.cs new file mode 100644 index 000000000..9f9223d48 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/ISecretAlertRouter.cs @@ -0,0 +1,167 @@ +// ----------------------------------------------------------------------------- +// ISecretAlertRouter.cs +// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration) +// Task: SDA-007 - Add severity-based routing +// Description: Routes secret alerts to appropriate channels based on severity and filters. +// ----------------------------------------------------------------------------- + +using StellaOps.Scanner.Core.Secrets.Configuration; + +namespace StellaOps.Scanner.Core.Secrets.Alerts; + +/// +/// Routes secret alerts to appropriate notification channels based on severity and filters. +/// +/// +/// Per SPRINT_20260104_007_BE task SDA-007. +/// Routing logic: +/// - Critical: Always alert, page on-call +/// - High: Alert to security channel +/// - Medium: Alert if configured +/// - Low: No alert by default +/// +public interface ISecretAlertRouter +{ + /// + /// Determines which destinations should receive an alert. + /// + /// The alert event. + /// Tenant's alert settings. + /// List of destinations that should receive the alert. + IReadOnlyList RouteAlert( + SecretFindingAlertEvent alert, + SecretAlertSettings settings); + + /// + /// Checks if an alert should be sent based on severity threshold. + /// + /// Severity of the finding. + /// Minimum severity required for alerting. + /// True if finding meets severity threshold. + bool MeetsSeverityThreshold(SecretSeverity findingSeverity, SecretSeverity minimumSeverity); +} + +/// +/// Default implementation of secret alert router. +/// +public sealed class SecretAlertRouter : ISecretAlertRouter +{ + /// + public IReadOnlyList RouteAlert( + SecretFindingAlertEvent alert, + SecretAlertSettings settings) + { + ArgumentNullException.ThrowIfNull(alert); + ArgumentNullException.ThrowIfNull(settings); + + if (!settings.Enabled) + { + return []; + } + + if (!MeetsSeverityThreshold(alert.Severity, settings.MinimumAlertSeverity)) + { + return []; + } + + var matchingDestinations = new List(); + + foreach (var destination in settings.Destinations) + { + if (DestinationMatchesAlert(destination, alert)) + { + matchingDestinations.Add(destination); + } + } + + return matchingDestinations; + } + + /// + public bool MeetsSeverityThreshold(SecretSeverity findingSeverity, SecretSeverity minimumSeverity) + { + // Higher severity value = more severe + return findingSeverity >= minimumSeverity; + } + + /// + /// Checks if a destination matches the alert based on its filters. + /// + private static bool DestinationMatchesAlert(SecretAlertDestination destination, SecretFindingAlertEvent alert) + { + // Check severity filter if specified + if (destination.SeverityFilter is { Count: > 0 }) + { + if (!destination.SeverityFilter.Contains(alert.Severity)) + { + return false; + } + } + + // Check category filter if specified + if (destination.RuleCategoryFilter is { Count: > 0 }) + { + if (!destination.RuleCategoryFilter.Contains(alert.RuleCategory, StringComparer.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } +} + +/// +/// Extension methods for alert routing. +/// +public static class AlertRoutingExtensions +{ + /// + /// Gets the default priority level for a severity. + /// + public static AlertPriority GetDefaultPriority(this SecretSeverity severity) + { + return severity switch + { + SecretSeverity.Critical => AlertPriority.P1Immediate, + SecretSeverity.High => AlertPriority.P2Urgent, + SecretSeverity.Medium => AlertPriority.P3Normal, + SecretSeverity.Low => AlertPriority.P4Info, + _ => AlertPriority.P4Info + }; + } + + /// + /// Determines if this severity should page on-call. + /// + public static bool ShouldPage(this SecretSeverity severity) + { + return severity == SecretSeverity.Critical; + } +} + +/// +/// Alert priority levels for incident management integration. +/// +public enum AlertPriority +{ + /// + /// P1: Immediate attention required, page on-call. + /// + P1Immediate = 1, + + /// + /// P2: Urgent, requires prompt attention. + /// + P2Urgent = 2, + + /// + /// P3: Normal priority, address in timely manner. + /// + P3Normal = 3, + + /// + /// P4: Informational, for awareness only. + /// + P4Info = 4 +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/SecretAlertEmitter.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/SecretAlertEmitter.cs new file mode 100644 index 000000000..474c4d7ab --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/SecretAlertEmitter.cs @@ -0,0 +1,222 @@ +// ----------------------------------------------------------------------------- +// SecretAlertEmitter.cs +// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration) +// Task: SDA-005 - Add alert emission to SecretsAnalyzerHost +// Description: Main implementation of secret alert emission with routing, deduplication, and rate limiting. +// ----------------------------------------------------------------------------- + +using Microsoft.Extensions.Logging; +using StellaOps.Scanner.Core.Secrets.Configuration; + +namespace StellaOps.Scanner.Core.Secrets.Alerts; + +/// +/// Emits secret finding alerts with routing, deduplication, and rate limiting. +/// +/// +/// Per SPRINT_20260104_007_BE task SDA-005. +/// Flow: +/// 1. Check if alerting is enabled for tenant +/// 2. Check severity threshold +/// 3. Route to matching destinations +/// 4. Check rate limit +/// 5. Check deduplication +/// 6. Emit to notification channels +/// +public sealed class SecretAlertEmitter : ISecretAlertEmitter +{ + private readonly ISecretAlertRouter _router; + private readonly ISecretAlertDeduplicator _deduplicator; + private readonly ISecretAlertChannelSender _channelSender; + private readonly ISecretAlertSettingsProvider _settingsProvider; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public SecretAlertEmitter( + ISecretAlertRouter router, + ISecretAlertDeduplicator deduplicator, + ISecretAlertChannelSender channelSender, + ISecretAlertSettingsProvider settingsProvider, + ILogger logger) + { + _router = router ?? throw new ArgumentNullException(nameof(router)); + _deduplicator = deduplicator ?? throw new ArgumentNullException(nameof(deduplicator)); + _channelSender = channelSender ?? throw new ArgumentNullException(nameof(channelSender)); + _settingsProvider = settingsProvider ?? throw new ArgumentNullException(nameof(settingsProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task EmitAsync( + SecretFindingAlertEvent alert, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(alert); + + try + { + // Get tenant settings + var settings = await _settingsProvider.GetAlertSettingsAsync(alert.TenantId, cancellationToken); + if (settings is null || !settings.Enabled) + { + _logger.LogDebug( + "Alert skipped: alerting disabled for tenant {TenantId}", + alert.TenantId); + return AlertEmissionResult.Skipped(alert.EventId, AlertSkipReason.AlertingDisabled); + } + + // Check severity threshold + if (!_router.MeetsSeverityThreshold(alert.Severity, settings.MinimumAlertSeverity)) + { + _logger.LogDebug( + "Alert skipped: severity {Severity} below threshold {Threshold}", + alert.Severity, + settings.MinimumAlertSeverity); + return AlertEmissionResult.Skipped( + alert.EventId, + AlertSkipReason.BelowSeverityThreshold, + $"Finding severity {alert.Severity} is below minimum {settings.MinimumAlertSeverity}"); + } + + // Route to matching destinations + var destinations = _router.RouteAlert(alert, settings); + if (destinations.Count == 0) + { + _logger.LogDebug( + "Alert skipped: no matching destinations for {RuleCategory}/{Severity}", + alert.RuleCategory, + alert.Severity); + return AlertEmissionResult.Skipped(alert.EventId, AlertSkipReason.NoMatchingDestinations); + } + + // Check rate limit + if (!await _deduplicator.IsUnderRateLimitAsync(alert.ScanId, settings.MaxAlertsPerScan, cancellationToken)) + { + _logger.LogWarning( + "Alert skipped: rate limit exceeded for scan {ScanId} (max {MaxAlerts})", + alert.ScanId, + settings.MaxAlertsPerScan); + return AlertEmissionResult.Skipped( + alert.EventId, + AlertSkipReason.RateLimitExceeded, + $"Scan {alert.ScanId} exceeded {settings.MaxAlertsPerScan} alerts"); + } + + // Check deduplication + if (!await _deduplicator.ShouldAlertAsync(alert.DeduplicationKey, settings.DeduplicationWindow, cancellationToken)) + { + _logger.LogDebug( + "Alert skipped: duplicate within {Window} for key {Key}", + settings.DeduplicationWindow, + alert.DeduplicationKey); + return AlertEmissionResult.Skipped( + alert.EventId, + AlertSkipReason.Deduplicated, + $"Same finding alerted within {settings.DeduplicationWindow}"); + } + + // Send to channels + var sentChannels = new List(); + foreach (var destination in destinations) + { + try + { + await _channelSender.SendAsync(alert, destination, settings, cancellationToken); + sentChannels.Add($"{destination.ChannelType}:{destination.Name}"); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to send alert to {ChannelType}:{ChannelId}", + destination.ChannelType, + destination.ChannelId); + // Continue with other destinations + } + } + + if (sentChannels.Count == 0) + { + return AlertEmissionResult.Skipped(alert.EventId, AlertSkipReason.EmissionFailed, "All channel sends failed"); + } + + // Record alert sent for deduplication and rate limiting + await _deduplicator.RecordAlertSentAsync(alert.DeduplicationKey, settings.DeduplicationWindow, cancellationToken); + await _deduplicator.IncrementScanAlertCountAsync(alert.ScanId, TimeSpan.FromHours(4), cancellationToken); + + _logger.LogInformation( + "Alert emitted for {RuleId} ({Severity}) in {ImageRef} to {ChannelCount} channels", + alert.RuleId, + alert.Severity, + alert.ImageRef, + sentChannels.Count); + + return AlertEmissionResult.Success(alert.EventId, sentChannels); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error emitting alert {EventId}", alert.EventId); + return AlertEmissionResult.Skipped(alert.EventId, AlertSkipReason.EmissionFailed, ex.Message); + } + } + + /// + public async Task> EmitBatchAsync( + IReadOnlyList alerts, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(alerts); + + var results = new List(alerts.Count); + + foreach (var alert in alerts) + { + var result = await EmitAsync(alert, cancellationToken); + results.Add(result); + + // Stop if rate limit hit + if (result.SkipReason == AlertSkipReason.RateLimitExceeded) + { + // Mark remaining as rate limited + foreach (var remaining in alerts.Skip(results.Count)) + { + results.Add(AlertEmissionResult.Skipped( + remaining.EventId, + AlertSkipReason.RateLimitExceeded, + "Batch rate limit exceeded")); + } + break; + } + } + + return results; + } +} + +/// +/// Provides alert settings for a tenant. +/// +public interface ISecretAlertSettingsProvider +{ + /// + /// Gets alert settings for a tenant. + /// + Task GetAlertSettingsAsync(Guid tenantId, CancellationToken cancellationToken = default); +} + +/// +/// Sends alerts to notification channels. +/// +public interface ISecretAlertChannelSender +{ + /// + /// Sends an alert to a specific channel. + /// + Task SendAsync( + SecretFindingAlertEvent alert, + SecretAlertDestination destination, + SecretAlertSettings settings, + CancellationToken cancellationToken = default); +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/SecretFindingAlertEvent.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/SecretFindingAlertEvent.cs new file mode 100644 index 000000000..5661c0417 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Alerts/SecretFindingAlertEvent.cs @@ -0,0 +1,226 @@ +// ----------------------------------------------------------------------------- +// SecretFindingAlertEvent.cs +// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration) +// Task: SDA-002 - Create SecretFindingAlertEvent +// Description: Alert event for secret findings to be routed to notification channels. +// ----------------------------------------------------------------------------- + +using System.Globalization; +using StellaOps.Scanner.Core.Secrets.Configuration; + +namespace StellaOps.Scanner.Core.Secrets.Alerts; + +/// +/// Alert event emitted when a secret is detected in a scan. +/// Routed to configured notification channels based on severity and settings. +/// +/// +/// Implements deterministic deduplication key for rate limiting. +/// Per SPRINT_20260104_007_BE task SDA-002. +/// +public sealed record SecretFindingAlertEvent +{ + /// + /// Unique identifier for this alert event. + /// + public required Guid EventId { get; init; } + + /// + /// Tenant that owns the scanned image. + /// + public required Guid TenantId { get; init; } + + /// + /// Scan job identifier. + /// + public required Guid ScanId { get; init; } + + /// + /// Container image reference where secret was found. + /// Example: "registry.example.com/app:v1.2.3" + /// + public required string ImageRef { get; init; } + + /// + /// Severity level of the finding. + /// + public required SecretSeverity Severity { get; init; } + + /// + /// Detection rule identifier. + /// + public required string RuleId { get; init; } + + /// + /// Human-readable rule name. + /// + public required string RuleName { get; init; } + + /// + /// Rule category (e.g., "cloud_credentials", "api_keys"). + /// + public required string RuleCategory { get; init; } + + /// + /// File path within the image where secret was found. + /// + public required string FilePath { get; init; } + + /// + /// Line number where secret was found (1-based). + /// + public required int LineNumber { get; init; } + + /// + /// Masked representation of the detected secret value. + /// Always masked based on tenant's revelation policy. + /// + public required string MaskedValue { get; init; } + + /// + /// When the secret was detected (UTC). + /// + public required DateTimeOffset DetectedAt { get; init; } + + /// + /// Identity or source that triggered the scan. + /// + public required string ScanTriggeredBy { get; init; } + + /// + /// Image digest for provenance. + /// + public string? ImageDigest { get; init; } + + /// + /// Optional remediation guidance text. + /// + public string? RemediationGuidance { get; init; } + + /// + /// Deep link URL to view the finding in StellaOps UI. + /// + public string? FindingUrl { get; init; } + + /// + /// Deterministic deduplication key for rate limiting. + /// Based on tenant, rule, file, and line - ensures same finding doesn't alert twice. + /// + /// + /// Format: "{TenantId}:{RuleId}:{FilePath}:{LineNumber}" + /// + public string DeduplicationKey => string.Format( + CultureInfo.InvariantCulture, + "{0}:{1}:{2}:{3}", + TenantId, + RuleId, + FilePath, + LineNumber); + + /// + /// Alternative deduplication key including image reference. + /// Use this when the same secret in different images should trigger separate alerts. + /// + public string DeduplicationKeyWithImage => string.Format( + CultureInfo.InvariantCulture, + "{0}:{1}:{2}:{3}:{4}", + TenantId, + ImageRef, + RuleId, + FilePath, + LineNumber); + + /// + /// Creates an alert event from a secret finding. + /// + /// Tenant identifier. + /// Scan job identifier. + /// Container image reference. + /// The secret finding details. + /// Pre-masked value based on tenant policy. + /// Identity that triggered the scan. + /// Event identifier. + /// Detection timestamp. + /// A new alert event. + public static SecretFindingAlertEvent Create( + Guid tenantId, + Guid scanId, + string imageRef, + SecretFindingInfo finding, + string maskedValue, + string scanTriggeredBy, + Guid eventId, + DateTimeOffset detectedAt) + { + ArgumentException.ThrowIfNullOrWhiteSpace(imageRef); + ArgumentNullException.ThrowIfNull(finding); + ArgumentException.ThrowIfNullOrWhiteSpace(maskedValue); + ArgumentException.ThrowIfNullOrWhiteSpace(scanTriggeredBy); + + return new SecretFindingAlertEvent + { + EventId = eventId, + TenantId = tenantId, + ScanId = scanId, + ImageRef = imageRef, + Severity = finding.Severity, + RuleId = finding.RuleId, + RuleName = finding.RuleName, + RuleCategory = finding.RuleCategory, + FilePath = finding.FilePath, + LineNumber = finding.LineNumber, + MaskedValue = maskedValue, + DetectedAt = detectedAt, + ScanTriggeredBy = scanTriggeredBy, + ImageDigest = finding.ImageDigest, + RemediationGuidance = finding.RemediationGuidance, + FindingUrl = null // Set by caller with appropriate URL + }; + } +} + +/// +/// Minimal finding info needed to create an alert event. +/// +public sealed record SecretFindingInfo +{ + /// + /// Severity of the finding. + /// + public required SecretSeverity Severity { get; init; } + + /// + /// Rule identifier. + /// + public required string RuleId { get; init; } + + /// + /// Human-readable rule name. + /// + public required string RuleName { get; init; } + + /// + /// Rule category. + /// + public required string RuleCategory { get; init; } + + /// + /// File path where secret was found. + /// + public required string FilePath { get; init; } + + /// + /// Line number (1-based). + /// + public required int LineNumber { get; init; } + + /// + /// Image digest for provenance. + /// + public string? ImageDigest { get; init; } + + /// + /// Remediation guidance. + /// + public string? RemediationGuidance { get; init; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretAlertSettings.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretAlertSettings.cs new file mode 100644 index 000000000..33c194214 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretAlertSettings.cs @@ -0,0 +1,239 @@ +// ----------------------------------------------------------------------------- +// SecretAlertSettings.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-001 - Define SecretDetectionSettings domain model (alert portion) +// Description: Configuration for secret detection alerting. +// ----------------------------------------------------------------------------- + +namespace StellaOps.Scanner.Core.Secrets.Configuration; + +/// +/// Severity levels for secret detection rules. +/// +public enum SecretSeverity +{ + /// + /// Informational finding, lowest priority. + /// + Low = 0, + + /// + /// Moderate risk, should be reviewed. + /// + Medium = 1, + + /// + /// Significant risk, should be addressed promptly. + /// + High = 2, + + /// + /// Critical risk, requires immediate attention. + /// + Critical = 3 +} + +/// +/// Alert channel types supported for secret notifications. +/// +public enum AlertChannelType +{ + /// + /// Slack workspace channel. + /// + Slack = 0, + + /// + /// Microsoft Teams channel. + /// + Teams = 1, + + /// + /// Email notification. + /// + Email = 2, + + /// + /// Generic webhook endpoint. + /// + Webhook = 3, + + /// + /// PagerDuty incident. + /// + PagerDuty = 4 +} + +/// +/// Configuration for secret detection alerting. +/// +public sealed record SecretAlertSettings +{ + /// + /// Enable/disable alerting for this tenant. + /// + public bool Enabled { get; init; } = false; + + /// + /// Minimum severity to trigger an alert. + /// + public SecretSeverity MinimumAlertSeverity { get; init; } = SecretSeverity.High; + + /// + /// Alert destinations by channel type. + /// + public IReadOnlyList Destinations { get; init; } = []; + + /// + /// Maximum alerts to send per scan (rate limiting). + /// + public int MaxAlertsPerScan { get; init; } = 10; + + /// + /// Don't re-alert for same secret within this window. + /// + public TimeSpan DeduplicationWindow { get; init; } = TimeSpan.FromHours(24); + + /// + /// Include file path in alert message. + /// + public bool IncludeFilePath { get; init; } = true; + + /// + /// Include masked secret value in alert message. + /// + public bool IncludeMaskedValue { get; init; } = true; + + /// + /// Include image reference in alert message. + /// + public bool IncludeImageRef { get; init; } = true; + + /// + /// Custom message prefix for alerts. + /// + public string? AlertMessagePrefix { get; init; } + + /// + /// Validates the alert settings. + /// + public IReadOnlyList Validate() + { + var errors = new List(); + + if (MaxAlertsPerScan < 1 || MaxAlertsPerScan > 100) + { + errors.Add("MaxAlertsPerScan must be between 1 and 100"); + } + + if (DeduplicationWindow < TimeSpan.FromMinutes(5)) + { + errors.Add("DeduplicationWindow must be at least 5 minutes"); + } + + if (DeduplicationWindow > TimeSpan.FromDays(7)) + { + errors.Add("DeduplicationWindow must be 7 days or less"); + } + + if (Enabled && Destinations.Count == 0) + { + errors.Add("At least one destination is required when alerting is enabled"); + } + + foreach (var dest in Destinations) + { + errors.AddRange(dest.Validate().Select(e => $"Destination '{dest.Name}': {e}")); + } + + return errors; + } + + /// + /// Creates default alert settings (disabled). + /// + public static SecretAlertSettings Default => new(); +} + +/// +/// Defines an alert destination for secret findings. +/// +public sealed record SecretAlertDestination +{ + /// + /// Unique identifier for this destination. + /// + public required Guid Id { get; init; } + + /// + /// Human-readable name for the destination. + /// + public required string Name { get; init; } + + /// + /// Channel type (Slack, Teams, Email, etc.). + /// + public required AlertChannelType ChannelType { get; init; } + + /// + /// Channel identifier (Slack channel ID, email address, webhook URL). + /// + public required string ChannelId { get; init; } + + /// + /// Optional: Only alert for these severities. + /// If empty, respects MinimumAlertSeverity from parent settings. + /// + public IReadOnlyList? SeverityFilter { get; init; } + + /// + /// Optional: Only alert for these rule categories. + /// If empty, alerts for all categories. + /// + public IReadOnlyList? RuleCategoryFilter { get; init; } + + /// + /// Whether this destination is currently active. + /// + public bool IsActive { get; init; } = true; + + /// + /// Validates the destination configuration. + /// + public IReadOnlyList Validate() + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(Name)) + { + errors.Add("Name is required"); + } + + if (string.IsNullOrWhiteSpace(ChannelId)) + { + errors.Add("ChannelId is required"); + } + else + { + // Validate channel ID format based on type + switch (ChannelType) + { + case AlertChannelType.Email: + if (!ChannelId.Contains('@')) + { + errors.Add("Email address must contain @"); + } + break; + case AlertChannelType.Webhook: + if (!Uri.TryCreate(ChannelId, UriKind.Absolute, out var uri) || + (uri.Scheme != "https" && uri.Scheme != "http")) + { + errors.Add("Webhook must be a valid HTTP(S) URL"); + } + break; + } + } + + return errors; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretDetectionSettings.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretDetectionSettings.cs new file mode 100644 index 000000000..08eaea325 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretDetectionSettings.cs @@ -0,0 +1,194 @@ +// ----------------------------------------------------------------------------- +// SecretDetectionSettings.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-001 - Define SecretDetectionSettings domain model +// Description: Per-tenant configuration for secret detection behavior. +// ----------------------------------------------------------------------------- + +namespace StellaOps.Scanner.Core.Secrets.Configuration; + +/// +/// Per-tenant configuration for secret detection. +/// Controls all aspects of secret leak detection including revelation policy, +/// enabled rules, exceptions, and alerting. +/// +public sealed record SecretDetectionSettings +{ + /// + /// Tenant this configuration belongs to. + /// + public required Guid TenantId { get; init; } + + /// + /// Whether secret detection is enabled for this tenant. + /// + public bool Enabled { get; init; } = false; + + /// + /// Revelation policy configuration controlling how secrets are masked/shown. + /// + public RevelationPolicyConfig RevelationPolicy { get; init; } = RevelationPolicyConfig.Default; + + /// + /// Enabled rule categories. Empty means all categories enabled. + /// Examples: "aws", "gcp", "azure", "generic", "private-keys", "database" + /// + public IReadOnlyList EnabledRuleCategories { get; init; } = []; + + /// + /// Disabled rule IDs (overrides category enablement). + /// + public IReadOnlyList DisabledRuleIds { get; init; } = []; + + /// + /// Exception patterns for suppressing false positives. + /// + public IReadOnlyList Exceptions { get; init; } = []; + + /// + /// Alert configuration for secret findings. + /// + public SecretAlertSettings AlertSettings { get; init; } = SecretAlertSettings.Default; + + /// + /// Maximum file size to scan for secrets (bytes). + /// Files larger than this are skipped. + /// + public long MaxFileSizeBytes { get; init; } = 10 * 1024 * 1024; // 10 MB + + /// + /// File extensions to exclude from scanning. + /// + public IReadOnlyList ExcludedFileExtensions { get; init; } = [".exe", ".dll", ".so", ".dylib", ".bin", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot"]; + + /// + /// Path patterns to exclude from scanning (glob patterns). + /// + public IReadOnlyList ExcludedPaths { get; init; } = ["**/node_modules/**", "**/vendor/**", "**/.git/**"]; + + /// + /// Whether to scan binary files (slower, may have false positives). + /// + public bool ScanBinaryFiles { get; init; } = false; + + /// + /// Whether to require signature verification for rule bundles. + /// + public bool RequireSignedRuleBundles { get; init; } = true; + + /// + /// When this configuration was last updated. + /// + public required DateTimeOffset UpdatedAt { get; init; } + + /// + /// Who last updated this configuration. + /// + public required string UpdatedBy { get; init; } + + /// + /// Version number for optimistic concurrency. + /// + public int Version { get; init; } = 1; + + /// + /// Validates the entire configuration. + /// + public IReadOnlyList Validate() + { + var errors = new List(); + + // Validate revelation policy + errors.AddRange(RevelationPolicy.Validate().Select(e => $"RevelationPolicy: {e}")); + + // Validate alert settings + errors.AddRange(AlertSettings.Validate().Select(e => $"AlertSettings: {e}")); + + // Validate exceptions + var exceptionNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var exception in Exceptions) + { + if (!exceptionNames.Add(exception.Name)) + { + errors.Add($"Duplicate exception name: {exception.Name}"); + } + errors.AddRange(exception.Validate().Select(e => $"Exception '{exception.Name}': {e}")); + } + + // Validate file size limit + if (MaxFileSizeBytes < 1024) // 1 KB minimum + { + errors.Add("MaxFileSizeBytes must be at least 1024 bytes"); + } + if (MaxFileSizeBytes > 100 * 1024 * 1024) // 100 MB maximum + { + errors.Add("MaxFileSizeBytes must be 100 MB or less"); + } + + return errors; + } + + /// + /// Creates default settings for a new tenant. + /// + public static SecretDetectionSettings CreateDefault(Guid tenantId, string createdBy, TimeProvider? timeProvider = null) => new() + { + TenantId = tenantId, + Enabled = false, + RevelationPolicy = RevelationPolicyConfig.Default, + AlertSettings = SecretAlertSettings.Default, + UpdatedAt = (timeProvider ?? TimeProvider.System).GetUtcNow(), + UpdatedBy = createdBy + }; + + /// + /// Creates a copy with updated timestamp and user. + /// + public SecretDetectionSettings WithUpdate(string updatedBy, TimeProvider? timeProvider = null) => this with + { + UpdatedAt = (timeProvider ?? TimeProvider.System).GetUtcNow(), + UpdatedBy = updatedBy, + Version = Version + 1 + }; +} + +/// +/// Available rule categories for secret detection. +/// +public static class SecretRuleCategories +{ + public const string Aws = "aws"; + public const string Gcp = "gcp"; + public const string Azure = "azure"; + public const string Generic = "generic"; + public const string PrivateKeys = "private-keys"; + public const string Database = "database"; + public const string Messaging = "messaging"; + public const string Payment = "payment"; + public const string SocialMedia = "social-media"; + public const string Internal = "internal"; + + public static readonly IReadOnlyList All = + [ + Aws, + Gcp, + Azure, + Generic, + PrivateKeys, + Database, + Messaging, + Payment, + SocialMedia, + Internal + ]; + + public static readonly IReadOnlyList DefaultEnabled = + [ + Aws, + Gcp, + Azure, + Generic, + PrivateKeys, + Database + ]; +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretExceptionMatcher.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretExceptionMatcher.cs new file mode 100644 index 000000000..7fbd61bf4 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretExceptionMatcher.cs @@ -0,0 +1,196 @@ +// ----------------------------------------------------------------------------- +// SecretExceptionMatcher.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-007 - Integrate exception patterns into SecretsAnalyzerHost +// Description: Service for matching secret findings against exception patterns. +// ----------------------------------------------------------------------------- + +using System.Text.RegularExpressions; +using Microsoft.Extensions.FileSystemGlobbing; + +namespace StellaOps.Scanner.Core.Secrets.Configuration; + +/// +/// Service for matching secret findings against exception patterns. +/// Determines whether a finding should be suppressed based on configured exceptions. +/// +public sealed class SecretExceptionMatcher +{ + private readonly IReadOnlyList _compiledPatterns; + private readonly TimeProvider _timeProvider; + + public SecretExceptionMatcher( + IEnumerable patterns, + TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + _compiledPatterns = CompilePatterns(patterns); + } + + /// + /// Checks if a finding matches any exception pattern. + /// + /// The detected secret value. + /// The rule ID that triggered the finding. + /// The file path where the secret was found. + /// Match result indicating if the finding is excepted. + public ExceptionMatchResult Match(string secretValue, string ruleId, string filePath) + { + var now = _timeProvider.GetUtcNow(); + + foreach (var compiled in _compiledPatterns) + { + if (!compiled.Pattern.IsEffective(now)) + { + continue; + } + + // Check rule ID filter + if (compiled.Pattern.ApplicableRuleIds.Count > 0 && + !compiled.Pattern.ApplicableRuleIds.Contains(ruleId, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + // Check file path glob + if (!string.IsNullOrEmpty(compiled.Pattern.FilePathGlob)) + { + if (!MatchesGlob(filePath, compiled.Pattern.FilePathGlob)) + { + continue; + } + } + + // Check value pattern + try + { + if (compiled.ValueRegex.IsMatch(secretValue)) + { + return ExceptionMatchResult.Excepted(compiled.Pattern); + } + } + catch (RegexMatchTimeoutException) + { + // Pattern timed out - treat as non-match for safety + continue; + } + } + + return ExceptionMatchResult.NotExcepted("No exception pattern matched"); + } + + /// + /// Creates an empty matcher with no patterns. + /// + public static SecretExceptionMatcher Empty => new([]); + + private static IReadOnlyList CompilePatterns( + IEnumerable patterns) + { + var compiled = new List(); + + foreach (var pattern in patterns) + { + try + { + var regex = new Regex( + pattern.ValuePattern, + RegexOptions.Compiled | RegexOptions.Singleline, + TimeSpan.FromSeconds(1)); + + compiled.Add(new CompiledExceptionPattern(pattern, regex)); + } + catch (RegexParseException) + { + // Invalid pattern - skip it + // In production, this should be logged + } + } + + return compiled; + } + + private static bool MatchesGlob(string filePath, string globPattern) + { + try + { + var matcher = new Matcher(); + matcher.AddInclude(globPattern); + + // Normalize path separators + var normalizedPath = filePath.Replace('\\', '/'); + + // Match against the file name and relative path components + var result = matcher.Match(normalizedPath); + return result.HasMatches; + } + catch + { + // Invalid glob - treat as non-match + return false; + } + } + + private sealed record CompiledExceptionPattern( + SecretExceptionPattern Pattern, + Regex ValueRegex); +} + +/// +/// Provider interface for loading exception patterns for a tenant. +/// +public interface ISecretExceptionProvider +{ + /// + /// Gets the active exception patterns for a tenant. + /// + Task> GetExceptionsAsync( + Guid tenantId, + CancellationToken cancellationToken = default); + + /// + /// Records that an exception pattern matched a finding. + /// + Task RecordMatchAsync( + Guid tenantId, + Guid exceptionId, + CancellationToken cancellationToken = default); +} + +/// +/// In-memory implementation of exception provider for testing. +/// +public sealed class InMemorySecretExceptionProvider : ISecretExceptionProvider +{ + private readonly Dictionary> _exceptions = []; + + public void AddException(Guid tenantId, SecretExceptionPattern exception) + { + if (!_exceptions.TryGetValue(tenantId, out var list)) + { + list = []; + _exceptions[tenantId] = list; + } + list.Add(exception); + } + + public Task> GetExceptionsAsync( + Guid tenantId, + CancellationToken cancellationToken = default) + { + if (_exceptions.TryGetValue(tenantId, out var list)) + { + return Task.FromResult>(list); + } + return Task.FromResult>([]); + } + + public Task RecordMatchAsync( + Guid tenantId, + Guid exceptionId, + CancellationToken cancellationToken = default) + { + // No-op for in-memory implementation + return Task.CompletedTask; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretExceptionPattern.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretExceptionPattern.cs new file mode 100644 index 000000000..5ae5fcf50 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretExceptionPattern.cs @@ -0,0 +1,183 @@ +// ----------------------------------------------------------------------------- +// SecretExceptionPattern.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-003 - Create SecretExceptionPattern model for allowlists +// Description: Defines patterns for excluding false positive secret detections. +// ----------------------------------------------------------------------------- + +using System.Text.RegularExpressions; + +namespace StellaOps.Scanner.Core.Secrets.Configuration; + +/// +/// Defines a pattern for excluding detected secrets from findings (allowlist). +/// Used to suppress false positives or known-safe patterns. +/// +public sealed record SecretExceptionPattern +{ + /// + /// Unique identifier for this exception. + /// + public required Guid Id { get; init; } + + /// + /// Human-readable name for the exception. + /// + public required string Name { get; init; } + + /// + /// Detailed description of why this exception exists. + /// + public required string Description { get; init; } + + /// + /// Regex pattern to match against detected secret value. + /// Use anchors (^ $) for exact matches. + /// + public required string ValuePattern { get; init; } + + /// + /// Optional: Only apply to specific rule IDs. + /// If empty, applies to all rules. + /// + public IReadOnlyList ApplicableRuleIds { get; init; } = []; + + /// + /// Optional: Only apply to files matching this glob pattern. + /// Example: "**/test/**", "*.test.ts" + /// + public string? FilePathGlob { get; init; } + + /// + /// Business justification for this exception (required for audit). + /// + public required string Justification { get; init; } + + /// + /// Expiration date. Null means permanent (requires periodic review). + /// + public DateTimeOffset? ExpiresAt { get; init; } + + /// + /// Whether this exception is currently active. + /// + public bool IsActive { get; init; } = true; + + /// + /// When this exception was created. + /// + public required DateTimeOffset CreatedAt { get; init; } + + /// + /// Who created this exception. + /// + public required string CreatedBy { get; init; } + + /// + /// When this exception was last modified. + /// + public DateTimeOffset? UpdatedAt { get; init; } + + /// + /// Who last modified this exception. + /// + public string? UpdatedBy { get; init; } + + /// + /// Number of times this exception has matched a finding. + /// + public long MatchCount { get; init; } + + /// + /// Last time this exception matched a finding. + /// + public DateTimeOffset? LastMatchedAt { get; init; } + + /// + /// Checks if this exception has expired. + /// + public bool IsExpired(DateTimeOffset now) => + ExpiresAt.HasValue && now > ExpiresAt.Value; + + /// + /// Checks if this exception is currently effective. + /// + public bool IsEffective(DateTimeOffset now) => + IsActive && !IsExpired(now); + + /// + /// Validates the exception pattern. + /// + public IReadOnlyList Validate() + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(Name)) + { + errors.Add("Name is required"); + } + else if (Name.Length > 100) + { + errors.Add("Name must be 100 characters or less"); + } + + if (string.IsNullOrWhiteSpace(ValuePattern)) + { + errors.Add("ValuePattern is required"); + } + else + { + try + { + _ = new Regex(ValuePattern, RegexOptions.None, TimeSpan.FromSeconds(1)); + } + catch (RegexParseException ex) + { + errors.Add($"ValuePattern is not a valid regex: {ex.Message}"); + } + } + + if (string.IsNullOrWhiteSpace(Justification)) + { + errors.Add("Justification is required"); + } + else if (Justification.Length < 20) + { + errors.Add("Justification must be at least 20 characters"); + } + + if (ExpiresAt.HasValue && ExpiresAt.Value < CreatedAt) + { + errors.Add("ExpiresAt must be after CreatedAt"); + } + + return errors; + } +} + +/// +/// Result of matching an exception pattern against a finding. +/// +public sealed record ExceptionMatchResult +{ + /// + /// Whether any exception matched. + /// + public required bool IsExcepted { get; init; } + + /// + /// The exception that matched, if any. + /// + public SecretExceptionPattern? MatchedException { get; init; } + + /// + /// Reason for the match or non-match. + /// + public string? Reason { get; init; } + + public static ExceptionMatchResult NotExcepted(string reason) => + new() { IsExcepted = false, Reason = reason }; + + public static ExceptionMatchResult Excepted(SecretExceptionPattern exception) => + new() { IsExcepted = true, MatchedException = exception, Reason = $"Matched exception: {exception.Name}" }; +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretRevelationPolicy.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretRevelationPolicy.cs new file mode 100644 index 000000000..c249052dc --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/SecretRevelationPolicy.cs @@ -0,0 +1,114 @@ +// ----------------------------------------------------------------------------- +// SecretRevelationPolicy.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-002 - Create SecretRevelationPolicy enum and config +// Description: Controls how detected secrets are displayed/masked in different contexts. +// ----------------------------------------------------------------------------- + +namespace StellaOps.Scanner.Core.Secrets.Configuration; + +/// +/// Defines how detected secret values are revealed or masked. +/// +public enum SecretRevelationPolicy +{ + /// + /// Show only that a secret was detected, no value shown. + /// Example: [REDACTED] + /// + FullMask = 0, + + /// + /// Show first and last N characters (configurable). + /// Example: AKIA****WXYZ + /// + PartialReveal = 1, + + /// + /// Show full value. Requires elevated permissions and is audit-logged. + /// Use only for debugging/incident response. + /// + FullReveal = 2 +} + +/// +/// Configuration for secret revelation across different contexts. +/// +public sealed record RevelationPolicyConfig +{ + /// + /// Default policy for UI/API responses. + /// + public SecretRevelationPolicy DefaultPolicy { get; init; } = SecretRevelationPolicy.PartialReveal; + + /// + /// Policy for exported reports (PDF, JSON, SARIF). + /// + public SecretRevelationPolicy ExportPolicy { get; init; } = SecretRevelationPolicy.FullMask; + + /// + /// Policy for logs and telemetry. Always enforced as FullMask regardless of setting. + /// + public SecretRevelationPolicy LogPolicy { get; init; } = SecretRevelationPolicy.FullMask; + + /// + /// Roles allowed to use FullReveal policy. + /// + public IReadOnlyList FullRevealRoles { get; init; } = ["security-admin", "incident-responder"]; + + /// + /// Number of characters to show at start and end for PartialReveal. + /// + public int PartialRevealChars { get; init; } = 4; + + /// + /// Maximum characters to show in masked portion for PartialReveal. + /// + public int MaxMaskChars { get; init; } = 8; + + /// + /// Whether to require explicit user action to reveal (even partial). + /// + public bool RequireExplicitReveal { get; init; } = false; + + /// + /// Validates the configuration. + /// + public IReadOnlyList Validate() + { + var errors = new List(); + + if (PartialRevealChars < 1 || PartialRevealChars > 10) + { + errors.Add("PartialRevealChars must be between 1 and 10"); + } + + if (MaxMaskChars < 1 || MaxMaskChars > 20) + { + errors.Add("MaxMaskChars must be between 1 and 20"); + } + + if (FullRevealRoles.Count == 0 && DefaultPolicy == SecretRevelationPolicy.FullReveal) + { + errors.Add("FullRevealRoles must not be empty when DefaultPolicy is FullReveal"); + } + + return errors; + } + + /// + /// Creates a default secure configuration. + /// + public static RevelationPolicyConfig Default => new(); + + /// + /// Creates a strict configuration with maximum masking. + /// + public static RevelationPolicyConfig Strict => new() + { + DefaultPolicy = SecretRevelationPolicy.FullMask, + ExportPolicy = SecretRevelationPolicy.FullMask, + LogPolicy = SecretRevelationPolicy.FullMask, + RequireExplicitReveal = true + }; +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Masking/SecretMasker.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Masking/SecretMasker.cs new file mode 100644 index 000000000..f3df781f4 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Masking/SecretMasker.cs @@ -0,0 +1,164 @@ +// ----------------------------------------------------------------------------- +// SecretMasker.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-008 - Implement revelation policy in findings output +// Description: Utility for masking secret values based on revelation policy. +// ----------------------------------------------------------------------------- + +using StellaOps.Scanner.Core.Secrets.Configuration; + +namespace StellaOps.Scanner.Core.Secrets.Masking; + +/// +/// Utility for masking secret values based on revelation policy. +/// Thread-safe and stateless. +/// +public static class SecretMasker +{ + /// + /// Default mask character. + /// + public const char MaskChar = '*'; + + /// + /// Placeholder for fully masked secrets. + /// + public const string RedactedPlaceholder = "[REDACTED]"; + + /// + /// Masks a secret value according to the specified policy. + /// + /// The secret value to mask. + /// The revelation policy to apply. + /// Number of characters to reveal at start/end for partial reveal. + /// Maximum number of mask characters for partial reveal. + /// The masked value. + public static string Mask( + string secretValue, + SecretRevelationPolicy policy, + int partialChars = 4, + int maxMaskChars = 8) + { + if (string.IsNullOrEmpty(secretValue)) + { + return RedactedPlaceholder; + } + + return policy switch + { + SecretRevelationPolicy.FullMask => RedactedPlaceholder, + SecretRevelationPolicy.PartialReveal => MaskPartial(secretValue, partialChars, maxMaskChars), + SecretRevelationPolicy.FullReveal => secretValue, + _ => RedactedPlaceholder + }; + } + + /// + /// Masks a secret value using the provided policy configuration. + /// + /// The secret value to mask. + /// The revelation policy configuration. + /// The context (default, export, log) to use. + /// The masked value. + public static string Mask( + string secretValue, + RevelationPolicyConfig config, + MaskingContext context = MaskingContext.Default) + { + var policy = context switch + { + MaskingContext.Default => config.DefaultPolicy, + MaskingContext.Export => config.ExportPolicy, + MaskingContext.Log => SecretRevelationPolicy.FullMask, // Always enforce full mask for logs + _ => config.DefaultPolicy + }; + + return Mask(secretValue, policy, config.PartialRevealChars, config.MaxMaskChars); + } + + /// + /// Partially masks a value, showing first and last N characters. + /// + private static string MaskPartial(string value, int revealChars, int maxMaskChars) + { + if (value.Length <= revealChars * 2) + { + // Value too short - mask entirely + return new string(MaskChar, value.Length); + } + + var prefix = value[..revealChars]; + var suffix = value[^revealChars..]; + var hiddenLength = value.Length - (revealChars * 2); + var maskLength = Math.Min(hiddenLength, maxMaskChars); + var masked = new string(MaskChar, maskLength); + + return $"{prefix}{masked}{suffix}"; + } + + /// + /// Creates a safe string representation for logging. + /// Never reveals more than type information. + /// + /// The type of secret detected. + /// Length of the original value. + /// Safe log message. + public static string ForLog(string secretType, int valueLength) + { + return $"[SECRET_DETECTED: {secretType}, length={valueLength}]"; + } + + /// + /// Checks if a string appears to be already masked. + /// + public static bool IsMasked(string value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + return value == RedactedPlaceholder || + value.Contains(MaskChar) || + value.StartsWith("[SECRET_DETECTED:", StringComparison.Ordinal); + } + + /// + /// Masks all occurrences of a secret in a larger text. + /// + /// The text containing secrets. + /// The secret value to mask. + /// The revelation policy to apply. + /// Text with secrets masked. + public static string MaskInText(string text, string secretValue, SecretRevelationPolicy policy) + { + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(secretValue)) + { + return text; + } + + var masked = Mask(secretValue, policy); + return text.Replace(secretValue, masked, StringComparison.Ordinal); + } +} + +/// +/// Context for determining which masking policy to apply. +/// +public enum MaskingContext +{ + /// + /// Default context (UI/API responses). + /// + Default = 0, + + /// + /// Export context (reports, SARIF, JSON exports). + /// + Export = 1, + + /// + /// Log context (always fully masked). + /// + Log = 2 +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Diff/ComponentDiffModels.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Diff/ComponentDiffModels.cs index 3b57e456a..50f680b3e 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Diff/ComponentDiffModels.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Diff/ComponentDiffModels.cs @@ -21,7 +21,7 @@ public sealed record ComponentDiffRequest public SbomView View { get; init; } = SbomView.Inventory; - public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset GeneratedAt { get; init; } public string? OldImageDigest { get; init; } = null; diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Cbom/CbomAggregationService.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Cbom/CbomAggregationService.cs index 9b754e381..a382eeb48 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Cbom/CbomAggregationService.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Cbom/CbomAggregationService.cs @@ -105,13 +105,16 @@ public sealed class CbomAggregationService : ICbomAggregationService { private readonly IEnumerable _extractors; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; public CbomAggregationService( IEnumerable extractors, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _extractors = extractors; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task AggregateAsync( @@ -167,7 +170,7 @@ public sealed class CbomAggregationService : ICbomAggregationService ByComponent = byComponentImmutable, UniqueAlgorithms = uniqueAlgorithms, RiskAssessment = AssessRisk(assetsArray), - GeneratedAt = DateTimeOffset.UtcNow.ToString("o") + GeneratedAt = _timeProvider.GetUtcNow().ToString("o") }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Index/BomIndexBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Index/BomIndexBuilder.cs index 6f66aa196..be76c6bf1 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Index/BomIndexBuilder.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Index/BomIndexBuilder.cs @@ -17,7 +17,7 @@ public sealed record BomIndexBuildRequest public required ComponentGraph Graph { get; init; } - public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset GeneratedAt { get; init; } } public sealed record BomIndexArtifact diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Lineage/SbomDiffEngine.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Lineage/SbomDiffEngine.cs index 9eeb4b7b2..f7835eaa3 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Lineage/SbomDiffEngine.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Lineage/SbomDiffEngine.cs @@ -10,6 +10,13 @@ namespace StellaOps.Scanner.Emit.Lineage; /// public sealed class SbomDiffEngine { + private readonly TimeProvider _timeProvider; + + public SbomDiffEngine(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + /// /// Computes the semantic diff between two SBOMs. /// @@ -115,7 +122,7 @@ public sealed class SbomDiffEngine Unchanged = unchanged, IsBreaking = isBreaking }, - ComputedAt = DateTimeOffset.UtcNow + ComputedAt = _timeProvider.GetUtcNow() }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Baseline/BaselineAnalyzer.cs b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Baseline/BaselineAnalyzer.cs index a6ab70858..612178770 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Baseline/BaselineAnalyzer.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Baseline/BaselineAnalyzer.cs @@ -57,11 +57,13 @@ public interface IBaselineAnalyzer public sealed class BaselineAnalyzer : IBaselineAnalyzer { private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private readonly Dictionary _compiledPatterns = new(); - public BaselineAnalyzer(ILogger logger) + public BaselineAnalyzer(ILogger logger, TimeProvider? timeProvider = null) { _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task AnalyzeAsync( @@ -97,7 +99,7 @@ public sealed class BaselineAnalyzer : IBaselineAnalyzer { ReportId = Guid.NewGuid(), ScanId = context.ScanId, - GeneratedAt = DateTimeOffset.UtcNow, + GeneratedAt = _timeProvider.GetUtcNow(), ConfigUsed = context.Config.ConfigId, EntryPoints = entryPoints.ToImmutableArray(), Statistics = statistics, diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/BinaryAnalysisResult.cs b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/BinaryAnalysisResult.cs index 3dbee74cf..62956a449 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/BinaryAnalysisResult.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/BinaryAnalysisResult.cs @@ -56,7 +56,8 @@ public sealed record BinaryAnalysisResult( string binaryPath, string binaryHash, BinaryArchitecture architecture = BinaryArchitecture.Unknown, - BinaryFormat format = BinaryFormat.Unknown) => new( + BinaryFormat format = BinaryFormat.Unknown, + TimeProvider? timeProvider = null) => new( binaryPath, binaryHash, architecture, @@ -66,7 +67,7 @@ public sealed record BinaryAnalysisResult( ImmutableArray.Empty, ImmutableArray.Empty, BinaryAnalysisMetrics.Empty, - DateTimeOffset.UtcNow); + (timeProvider ?? TimeProvider.System).GetUtcNow()); /// /// Gets functions at high-confidence correlation. @@ -324,18 +325,22 @@ public sealed class BinaryAnalysisResultBuilder private readonly Dictionary _symbols = new(); private readonly List _correlations = new(); private readonly List _vulnerableMatches = new(); - private readonly DateTimeOffset _startTime = DateTimeOffset.UtcNow; + private readonly TimeProvider _timeProvider; + private readonly DateTimeOffset _startTime; public BinaryAnalysisResultBuilder( string binaryPath, string binaryHash, BinaryArchitecture architecture = BinaryArchitecture.Unknown, - BinaryFormat format = BinaryFormat.Unknown) + BinaryFormat format = BinaryFormat.Unknown, + TimeProvider? timeProvider = null) { _binaryPath = binaryPath; _binaryHash = binaryHash; _architecture = architecture; _format = format; + _timeProvider = timeProvider ?? TimeProvider.System; + _startTime = _timeProvider.GetUtcNow(); } /// @@ -379,7 +384,8 @@ public sealed class BinaryAnalysisResultBuilder /// public BinaryAnalysisResult Build() { - var duration = DateTimeOffset.UtcNow - _startTime; + var now = _timeProvider.GetUtcNow(); + var duration = now - _startTime; var metrics = new BinaryAnalysisMetrics( TotalFunctions: _functions.Count, @@ -401,6 +407,6 @@ public sealed class BinaryAnalysisResultBuilder _correlations.OrderBy(c => c.BinaryOffset).ToImmutableArray(), _vulnerableMatches.OrderByDescending(m => m.Severity).ToImmutableArray(), metrics, - DateTimeOffset.UtcNow); + now); } } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/BinaryIntelligenceAnalyzer.cs b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/BinaryIntelligenceAnalyzer.cs index bd72b8338..cbfe876de 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/BinaryIntelligenceAnalyzer.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/BinaryIntelligenceAnalyzer.cs @@ -16,6 +16,7 @@ public sealed class BinaryIntelligenceAnalyzer private readonly ISymbolRecovery _symbolRecovery; private readonly VulnerableFunctionMatcher _vulnerabilityMatcher; private readonly BinaryIntelligenceOptions _options; + private readonly TimeProvider _timeProvider; /// /// Creates a new binary intelligence analyzer. @@ -25,12 +26,14 @@ public sealed class BinaryIntelligenceAnalyzer IFingerprintIndex? fingerprintIndex = null, ISymbolRecovery? symbolRecovery = null, VulnerableFunctionMatcher? vulnerabilityMatcher = null, - BinaryIntelligenceOptions? options = null) + BinaryIntelligenceOptions? options = null, + TimeProvider? timeProvider = null) { + _timeProvider = timeProvider ?? TimeProvider.System; _fingerprintGenerator = fingerprintGenerator ?? new CombinedFingerprintGenerator(); - _fingerprintIndex = fingerprintIndex ?? new InMemoryFingerprintIndex(); + _fingerprintIndex = fingerprintIndex ?? new InMemoryFingerprintIndex(_timeProvider); _symbolRecovery = symbolRecovery ?? new PatternBasedSymbolRecovery(); - _vulnerabilityMatcher = vulnerabilityMatcher ?? new VulnerableFunctionMatcher(_fingerprintIndex); + _vulnerabilityMatcher = vulnerabilityMatcher ?? new VulnerableFunctionMatcher(_fingerprintIndex, timeProvider: _timeProvider); _options = options ?? BinaryIntelligenceOptions.Default; } @@ -53,7 +56,7 @@ public sealed class BinaryIntelligenceAnalyzer CancellationToken cancellationToken = default) { var stopwatch = Stopwatch.StartNew(); - var builder = new BinaryAnalysisResultBuilder(binaryPath, binaryHash, architecture, format); + var builder = new BinaryAnalysisResultBuilder(binaryPath, binaryHash, architecture, format, _timeProvider); // Phase 1: Generate fingerprints for all functions var fingerprints = new Dictionary(); @@ -186,7 +189,7 @@ public sealed class BinaryIntelligenceAnalyzer SourceLine: null, VulnerabilityIds: vulnerabilityIds?.ToImmutableArray() ?? ImmutableArray.Empty, Similarity: 1.0f, - MatchedAt: DateTimeOffset.UtcNow); + MatchedAt: _timeProvider.GetUtcNow()); if (await _fingerprintIndex.AddAsync(entry, cancellationToken)) { diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/FingerprintCorpusBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/FingerprintCorpusBuilder.cs index ede918743..25a87fe3f 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/FingerprintCorpusBuilder.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/FingerprintCorpusBuilder.cs @@ -14,6 +14,7 @@ public sealed class FingerprintCorpusBuilder private readonly IFingerprintGenerator _fingerprintGenerator; private readonly IFingerprintIndex _targetIndex; private readonly FingerprintCorpusOptions _options; + private readonly TimeProvider _timeProvider; private readonly List _buildHistory = new(); /// @@ -22,11 +23,13 @@ public sealed class FingerprintCorpusBuilder public FingerprintCorpusBuilder( IFingerprintIndex targetIndex, IFingerprintGenerator? fingerprintGenerator = null, - FingerprintCorpusOptions? options = null) + FingerprintCorpusOptions? options = null, + TimeProvider? timeProvider = null) { _targetIndex = targetIndex; _fingerprintGenerator = fingerprintGenerator ?? new CombinedFingerprintGenerator(); _options = options ?? FingerprintCorpusOptions.Default; + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -41,7 +44,7 @@ public sealed class FingerprintCorpusBuilder IReadOnlyList functions, CancellationToken cancellationToken = default) { - var startTime = DateTimeOffset.UtcNow; + var startTime = _timeProvider.GetUtcNow(); var indexed = 0; var skipped = 0; var duplicates = 0; @@ -93,7 +96,7 @@ public sealed class FingerprintCorpusBuilder SourceLine: null, VulnerabilityIds: package.VulnerabilityIds, Similarity: 1.0f, - MatchedAt: DateTimeOffset.UtcNow); + MatchedAt: _timeProvider.GetUtcNow()); var added = await _targetIndex.AddAsync(entry, cancellationToken); @@ -119,9 +122,9 @@ public sealed class FingerprintCorpusBuilder Skipped: skipped, Duplicates: duplicates, Errors: errors.ToImmutableArray(), - Duration: DateTimeOffset.UtcNow - startTime); + Duration: _timeProvider.GetUtcNow() - startTime); - _buildHistory.Add(new CorpusBuildRecord(package.Purl, package.Version, result, DateTimeOffset.UtcNow)); + _buildHistory.Add(new CorpusBuildRecord(package.Purl, package.Version, result, _timeProvider.GetUtcNow())); return result; } @@ -207,7 +210,7 @@ public sealed class FingerprintCorpusBuilder // For now, export build history as a summary var data = new CorpusExportData { - ExportedAt = DateTimeOffset.UtcNow, + ExportedAt = _timeProvider.GetUtcNow(), Statistics = _targetIndex.GetStatistics(), Entries = Array.Empty() // Full export would need index enumeration }; diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/IFingerprintIndex.cs b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/IFingerprintIndex.cs index fddb1fdc7..47facd939 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/IFingerprintIndex.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/IFingerprintIndex.cs @@ -140,7 +140,18 @@ public sealed class InMemoryFingerprintIndex : IFingerprintIndex private readonly ConcurrentDictionary> _algorithmIndex = new(); private readonly HashSet _packages = new(); private readonly object _packagesLock = new(); - private DateTimeOffset _lastUpdated = DateTimeOffset.UtcNow; + private readonly TimeProvider _timeProvider; + private DateTimeOffset _lastUpdated; + + /// + /// Creates a new in-memory fingerprint index. + /// + /// Optional time provider for deterministic timestamps. + public InMemoryFingerprintIndex(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + _lastUpdated = _timeProvider.GetUtcNow(); + } /// public int Count => _exactIndex.Count; @@ -182,7 +193,7 @@ public sealed class InMemoryFingerprintIndex : IFingerprintIndex _packages.Add(match.SourcePackage); } - _lastUpdated = DateTimeOffset.UtcNow; + _lastUpdated = _timeProvider.GetUtcNow(); } return Task.FromResult(added); @@ -302,7 +313,7 @@ public sealed class InMemoryFingerprintIndex : IFingerprintIndex SourceLine: null, VulnerabilityIds: ImmutableArray.Empty, Similarity: 1.0f, - MatchedAt: DateTimeOffset.UtcNow); + MatchedAt: _timeProvider.GetUtcNow()); return AddAsync(match, cancellationToken).ContinueWith(_ => { }, cancellationToken); } @@ -313,9 +324,20 @@ public sealed class InMemoryFingerprintIndex : IFingerprintIndex /// public sealed class VulnerableFingerprintIndex : IFingerprintIndex { - private readonly InMemoryFingerprintIndex _baseIndex = new(); + private readonly InMemoryFingerprintIndex _baseIndex; + private readonly TimeProvider _timeProvider; private readonly ConcurrentDictionary _vulnerabilities = new(); + /// + /// Creates a new vulnerability-aware fingerprint index. + /// + /// Optional time provider for deterministic timestamps. + public VulnerableFingerprintIndex(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + _baseIndex = new InMemoryFingerprintIndex(_timeProvider); + } + /// public int Count => _baseIndex.Count; @@ -344,7 +366,7 @@ public sealed class VulnerableFingerprintIndex : IFingerprintIndex SourceLine: null, VulnerabilityIds: ImmutableArray.Create(vulnerabilityId), Similarity: 1.0f, - MatchedAt: DateTimeOffset.UtcNow); + MatchedAt: _timeProvider.GetUtcNow()); var added = await _baseIndex.AddAsync(match, cancellationToken); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/VulnerableFunctionMatcher.cs b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/VulnerableFunctionMatcher.cs index 302ef8037..206e9c701 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/VulnerableFunctionMatcher.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Binary/VulnerableFunctionMatcher.cs @@ -11,16 +11,19 @@ public sealed class VulnerableFunctionMatcher { private readonly IFingerprintIndex _index; private readonly VulnerableMatcherOptions _options; + private readonly TimeProvider _timeProvider; /// /// Creates a new vulnerable function matcher. /// public VulnerableFunctionMatcher( IFingerprintIndex index, - VulnerableMatcherOptions? options = null) + VulnerableMatcherOptions? options = null, + TimeProvider? timeProvider = null) { _index = index; _options = options ?? VulnerableMatcherOptions.Default; + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -165,7 +168,7 @@ public sealed class VulnerableFunctionMatcher SourceLine: null, VulnerabilityIds: ImmutableArray.Create(vulnerabilityId), Similarity: 1.0f, - MatchedAt: DateTimeOffset.UtcNow); + MatchedAt: _timeProvider.GetUtcNow()); return await _index.AddAsync(entry, cancellationToken); } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Risk/CompositeRiskScorer.cs b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Risk/CompositeRiskScorer.cs index f8e7ed6e6..633cf3004 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Risk/CompositeRiskScorer.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Risk/CompositeRiskScorer.cs @@ -11,12 +11,13 @@ public sealed class CompositeRiskScorer : IRiskScorer { private readonly ImmutableArray _contributors; private readonly CompositeRiskScorerOptions _options; + private readonly TimeProvider _timeProvider; /// /// Creates a composite scorer with default contributors. /// - public CompositeRiskScorer(CompositeRiskScorerOptions? options = null) - : this(GetDefaultContributors(), options) + public CompositeRiskScorer(CompositeRiskScorerOptions? options = null, TimeProvider? timeProvider = null) + : this(GetDefaultContributors(), options, timeProvider) { } @@ -25,10 +26,12 @@ public sealed class CompositeRiskScorer : IRiskScorer /// public CompositeRiskScorer( IEnumerable contributors, - CompositeRiskScorerOptions? options = null) + CompositeRiskScorerOptions? options = null, + TimeProvider? timeProvider = null) { _contributors = contributors.ToImmutableArray(); _options = options ?? CompositeRiskScorerOptions.Default; + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -66,7 +69,7 @@ public sealed class CompositeRiskScorer : IRiskScorer Factors: allFactors.ToImmutableArray(), BusinessContext: businessContext, Recommendations: recommendations, - AssessedAt: DateTimeOffset.UtcNow); + AssessedAt: _timeProvider.GetUtcNow()); } private RiskScore ComputeOverallScore( @@ -75,7 +78,7 @@ public sealed class CompositeRiskScorer : IRiskScorer { if (factors.Count == 0) { - return RiskScore.Zero; + return RiskScore.Zero(_timeProvider); } // Weighted average of factor contributions @@ -106,7 +109,7 @@ public sealed class CompositeRiskScorer : IRiskScorer OverallScore: baseScore, Category: primaryCategory, Confidence: confidence, - ComputedAt: DateTimeOffset.UtcNow); + ComputedAt: _timeProvider.GetUtcNow()); } private float ComputeConfidence(IReadOnlyList factors) @@ -217,6 +220,17 @@ public sealed record CompositeRiskScorerOptions( /// public sealed class RiskExplainer { + private readonly TimeProvider _timeProvider; + + /// + /// Creates a new risk explainer. + /// + /// Optional time provider for deterministic timestamps. + public RiskExplainer(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + /// /// Generates a summary explanation for a risk assessment. /// @@ -268,7 +282,7 @@ public sealed class RiskExplainer Confidence: assessment.OverallScore.Confidence, TopFactors: ExplainFactors(assessment), Recommendations: assessment.Recommendations, - GeneratedAt: DateTimeOffset.UtcNow); + GeneratedAt: _timeProvider.GetUtcNow()); } private static string CategoryToString(RiskCategory category) => category switch @@ -313,6 +327,17 @@ public sealed record RiskReport( /// public sealed class RiskAggregator { + private readonly TimeProvider _timeProvider; + + /// + /// Creates a new risk aggregator. + /// + /// Optional time provider for deterministic timestamps. + public RiskAggregator(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + /// /// Aggregates assessments for a fleet-level view. /// @@ -322,7 +347,7 @@ public sealed class RiskAggregator if (assessmentList.Count == 0) { - return FleetRiskSummary.Empty; + return FleetRiskSummary.CreateEmpty(_timeProvider); } var distribution = assessmentList @@ -349,7 +374,7 @@ public sealed class RiskAggregator Distribution: distribution.ToImmutableDictionary(), CategoryBreakdown: categoryBreakdown.ToImmutableDictionary(), TopRisks: topRisks, - AggregatedAt: DateTimeOffset.UtcNow); + AggregatedAt: _timeProvider.GetUtcNow()); } } @@ -373,16 +398,22 @@ public sealed record FleetRiskSummary( DateTimeOffset AggregatedAt) { /// - /// Empty summary. + /// Empty summary with specified timestamp. /// - public static FleetRiskSummary Empty => new( + public static FleetRiskSummary CreateEmpty(TimeProvider? timeProvider = null) => new( TotalSubjects: 0, AverageScore: 0, AverageConfidence: 0, Distribution: ImmutableDictionary.Empty, CategoryBreakdown: ImmutableDictionary.Empty, TopRisks: ImmutableArray.Empty, - AggregatedAt: DateTimeOffset.UtcNow); + AggregatedAt: (timeProvider ?? TimeProvider.System).GetUtcNow()); + + /// + /// Empty summary (uses current time). + /// + [Obsolete("Use CreateEmpty(TimeProvider) for deterministic timestamps")] + public static FleetRiskSummary Empty => CreateEmpty(); /// /// Count of critical/high risk subjects. diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Risk/RiskScore.cs b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Risk/RiskScore.cs index c42d048b8..c1904ffa0 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Risk/RiskScore.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Risk/RiskScore.cs @@ -20,31 +20,32 @@ public sealed record RiskScore( /// /// Creates a zero risk score. /// - public static RiskScore Zero => new(0.0f, RiskCategory.Unknown, 1.0f, DateTimeOffset.UtcNow); + public static RiskScore Zero(TimeProvider? timeProvider = null) + => new(0.0f, RiskCategory.Unknown, 1.0f, (timeProvider ?? TimeProvider.System).GetUtcNow()); /// /// Creates a critical risk score. /// - public static RiskScore Critical(RiskCategory category, float confidence = 0.9f) - => new(1.0f, category, confidence, DateTimeOffset.UtcNow); + public static RiskScore Critical(RiskCategory category, float confidence = 0.9f, TimeProvider? timeProvider = null) + => new(1.0f, category, confidence, (timeProvider ?? TimeProvider.System).GetUtcNow()); /// /// Creates a high risk score. /// - public static RiskScore High(RiskCategory category, float confidence = 0.85f) - => new(0.85f, category, confidence, DateTimeOffset.UtcNow); + public static RiskScore High(RiskCategory category, float confidence = 0.85f, TimeProvider? timeProvider = null) + => new(0.85f, category, confidence, (timeProvider ?? TimeProvider.System).GetUtcNow()); /// /// Creates a medium risk score. /// - public static RiskScore Medium(RiskCategory category, float confidence = 0.8f) - => new(0.5f, category, confidence, DateTimeOffset.UtcNow); + public static RiskScore Medium(RiskCategory category, float confidence = 0.8f, TimeProvider? timeProvider = null) + => new(0.5f, category, confidence, (timeProvider ?? TimeProvider.System).GetUtcNow()); /// /// Creates a low risk score. /// - public static RiskScore Low(RiskCategory category, float confidence = 0.75f) - => new(0.2f, category, confidence, DateTimeOffset.UtcNow); + public static RiskScore Low(RiskCategory category, float confidence = 0.75f, TimeProvider? timeProvider = null) + => new(0.2f, category, confidence, (timeProvider ?? TimeProvider.System).GetUtcNow()); /// /// Descriptive risk level based on score. @@ -349,14 +350,18 @@ public sealed record RiskAssessment( /// /// Creates an empty assessment for a subject with no risk data. /// - public static RiskAssessment Empty(string subjectId, SubjectType subjectType) => new( - SubjectId: subjectId, - SubjectType: subjectType, - OverallScore: RiskScore.Zero, - Factors: ImmutableArray.Empty, - BusinessContext: null, - Recommendations: ImmutableArray.Empty, - AssessedAt: DateTimeOffset.UtcNow); + public static RiskAssessment Empty(string subjectId, SubjectType subjectType, TimeProvider? timeProvider = null) + { + var tp = timeProvider ?? TimeProvider.System; + return new( + SubjectId: subjectId, + SubjectType: subjectType, + OverallScore: RiskScore.Zero(tp), + Factors: ImmutableArray.Empty, + BusinessContext: null, + Recommendations: ImmutableArray.Empty, + AssessedAt: tp.GetUtcNow()); + } } /// diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Semantic/SemanticEntryTraceAnalyzer.cs b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Semantic/SemanticEntryTraceAnalyzer.cs index 0c1e4d7ad..d3c49f7e3 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Semantic/SemanticEntryTraceAnalyzer.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/Semantic/SemanticEntryTraceAnalyzer.cs @@ -16,21 +16,25 @@ public sealed class SemanticEntryTraceAnalyzer : ISemanticEntryTraceAnalyzer private readonly IEntryTraceAnalyzer _baseAnalyzer; private readonly SemanticEntrypointOrchestrator _orchestrator; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; public SemanticEntryTraceAnalyzer( IEntryTraceAnalyzer baseAnalyzer, SemanticEntrypointOrchestrator orchestrator, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _baseAnalyzer = baseAnalyzer ?? throw new ArgumentNullException(nameof(baseAnalyzer)); _orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } public SemanticEntryTraceAnalyzer( IEntryTraceAnalyzer baseAnalyzer, - ILogger logger) - : this(baseAnalyzer, new SemanticEntrypointOrchestrator(), logger) + ILogger logger, + TimeProvider? timeProvider = null) + : this(baseAnalyzer, new SemanticEntrypointOrchestrator(), logger, timeProvider) { } @@ -52,7 +56,7 @@ public sealed class SemanticEntryTraceAnalyzer : ISemanticEntryTraceAnalyzer var traceResult = new EntryTraceResult( context.ScanId, context.ImageDigest, - DateTimeOffset.UtcNow, + _timeProvider.GetUtcNow(), graph, SerializeToNdjson(graph)); @@ -98,7 +102,7 @@ public sealed class SemanticEntryTraceAnalyzer : ISemanticEntryTraceAnalyzer TraceResult = traceResult, SemanticEntrypoint = semanticResult, AnalysisResult = analysisResult, - AnalyzedAt = DateTimeOffset.UtcNow + AnalyzedAt = _timeProvider.GetUtcNow() }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Evidence/FuncProofBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Evidence/FuncProofBuilder.cs index 2b50a6a2f..75e277d29 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Evidence/FuncProofBuilder.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Evidence/FuncProofBuilder.cs @@ -28,6 +28,7 @@ public sealed class FuncProofBuilder private ICryptoHash? _cryptoHash; private FuncProofGenerationOptions _options = new(); + private TimeProvider _timeProvider = TimeProvider.System; private string? _buildId; private string? _buildIdType; private string? _fileSha256; @@ -50,6 +51,16 @@ public sealed class FuncProofBuilder return this; } + /// + /// Sets the TimeProvider for deterministic timestamp generation. + /// If not set, defaults to TimeProvider.System. + /// + public FuncProofBuilder WithTimeProvider(TimeProvider timeProvider) + { + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + return this; + } + /// /// Sets the generation options for configurable parameters. /// @@ -212,7 +223,7 @@ public sealed class FuncProofBuilder Functions = functions, Traces = traces, Meta = _metadata, - GeneratedAt = DateTimeOffset.UtcNow, + GeneratedAt = _timeProvider.GetUtcNow(), GeneratorVersion = _generatorVersion }; diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/Assumptions/IAssumptionCollector.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/Assumptions/IAssumptionCollector.cs index 3b1e869fa..352c7d8c9 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/Assumptions/IAssumptionCollector.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/Assumptions/IAssumptionCollector.cs @@ -50,6 +50,12 @@ public interface IAssumptionCollector public sealed class AssumptionCollector : IAssumptionCollector { private readonly Dictionary<(AssumptionCategory, string), Assumption> _assumptions = new(); + private readonly TimeProvider _timeProvider; + + public AssumptionCollector(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } /// public void Record( @@ -107,7 +113,7 @@ public sealed class AssumptionCollector : IAssumptionCollector { Id = Guid.NewGuid().ToString("N"), Assumptions = [.. _assumptions.Values], - CreatedAt = DateTimeOffset.UtcNow, + CreatedAt = _timeProvider.GetUtcNow(), ContextId = contextId }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.ProofIntegration/ProofAwareVexGenerator.cs b/src/Scanner/__Libraries/StellaOps.Scanner.ProofIntegration/ProofAwareVexGenerator.cs index b5657908a..41d6ac8fd 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.ProofIntegration/ProofAwareVexGenerator.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.ProofIntegration/ProofAwareVexGenerator.cs @@ -14,13 +14,16 @@ public sealed class ProofAwareVexGenerator { private readonly ILogger _logger; private readonly BackportProofService _proofService; + private readonly TimeProvider _timeProvider; public ProofAwareVexGenerator( ILogger logger, - BackportProofService proofService) + BackportProofService proofService, + TimeProvider? timeProvider = null) { _logger = logger; _proofService = proofService; + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -74,7 +77,7 @@ public sealed class ProofAwareVexGenerator Statement = statement, ProofPayload = proofPayload, Proof = proof, - GeneratedAt = DateTimeOffset.UtcNow + GeneratedAt = _timeProvider.GetUtcNow() }; } @@ -135,7 +138,7 @@ public sealed class ProofAwareVexGenerator Statement = statement, ProofPayload = proofPayload, Proof = unknownProof, - GeneratedAt = DateTimeOffset.UtcNow + GeneratedAt = _timeProvider.GetUtcNow() }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Boundary/BoundaryExtractionContext.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Boundary/BoundaryExtractionContext.cs index 4ccdb9b4f..8f6528e1a 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Boundary/BoundaryExtractionContext.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Boundary/BoundaryExtractionContext.cs @@ -18,7 +18,17 @@ public sealed record BoundaryExtractionContext /// /// Empty context for simple extractions. /// - public static readonly BoundaryExtractionContext Empty = new(); + /// Uses system time. For deterministic timestamps, use . + [Obsolete("Use CreateEmpty(TimeProvider) for deterministic timestamps")] + public static BoundaryExtractionContext Empty => CreateEmpty(); + + /// + /// Creates an empty context for simple extractions. + /// + /// Optional time provider for deterministic timestamps. + /// An empty boundary extraction context. + public static BoundaryExtractionContext CreateEmpty(TimeProvider? timeProvider = null) => + new() { Timestamp = (timeProvider ?? TimeProvider.System).GetUtcNow() }; /// /// Environment identifier (e.g., "production", "staging"). @@ -61,7 +71,7 @@ public sealed record BoundaryExtractionContext /// /// Timestamp for the context (for cache invalidation). /// - public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset Timestamp { get; init; } /// /// Source of this context (e.g., "k8s", "iac", "runtime"). @@ -71,20 +81,28 @@ public sealed record BoundaryExtractionContext /// /// Creates a context from detected gates. /// - public static BoundaryExtractionContext FromGates(IReadOnlyList gates) => - new() { DetectedGates = gates }; + /// The detected gates. + /// Optional time provider for deterministic timestamps. + public static BoundaryExtractionContext FromGates(IReadOnlyList gates, TimeProvider? timeProvider = null) => + new() { DetectedGates = gates, Timestamp = (timeProvider ?? TimeProvider.System).GetUtcNow() }; /// /// Creates a context with environment hints. /// + /// The environment identifier. + /// Whether the service is internet-facing. + /// The network zone. + /// Optional time provider for deterministic timestamps. public static BoundaryExtractionContext ForEnvironment( string environmentId, bool? isInternetFacing = null, - string? networkZone = null) => + string? networkZone = null, + TimeProvider? timeProvider = null) => new() { EnvironmentId = environmentId, IsInternetFacing = isInternetFacing, - NetworkZone = networkZone + NetworkZone = networkZone, + Timestamp = (timeProvider ?? TimeProvider.System).GetUtcNow() }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Cache/IncrementalReachabilityService.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Cache/IncrementalReachabilityService.cs index d9e8b4db7..f7747bd98 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Cache/IncrementalReachabilityService.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Cache/IncrementalReachabilityService.cs @@ -123,19 +123,22 @@ public sealed class IncrementalReachabilityService : IIncrementalReachabilitySer private readonly IImpactSetCalculator _impactCalculator; private readonly IStateFlipDetector _stateFlipDetector; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; public IncrementalReachabilityService( IReachabilityCache cache, IGraphDeltaComputer deltaComputer, IImpactSetCalculator impactCalculator, IStateFlipDetector stateFlipDetector, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _deltaComputer = deltaComputer ?? throw new ArgumentNullException(nameof(deltaComputer)); _impactCalculator = impactCalculator ?? throw new ArgumentNullException(nameof(impactCalculator)); _stateFlipDetector = stateFlipDetector ?? throw new ArgumentNullException(nameof(stateFlipDetector)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -265,7 +268,7 @@ public sealed class IncrementalReachabilityService : IIncrementalReachabilitySer private List ComputeFullReachability(IncrementalReachabilityRequest request) { var results = new List(); - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); // Build forward adjacency for BFS var adj = new Dictionary>(); @@ -323,7 +326,7 @@ public sealed class IncrementalReachabilityService : IIncrementalReachabilitySer CancellationToken cancellationToken) { var results = new Dictionary<(string, string), ReachablePairResult>(); - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); // Copy unaffected results from previous foreach (var prev in previousResults) diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Cache/PostgresReachabilityCache.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Cache/PostgresReachabilityCache.cs index 3f06a9ff0..1e98b0d62 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Cache/PostgresReachabilityCache.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Cache/PostgresReachabilityCache.cs @@ -21,13 +21,16 @@ public sealed class PostgresReachabilityCache : IReachabilityCache { private readonly string _connectionString; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; public PostgresReachabilityCache( string connectionString, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -102,7 +105,7 @@ public sealed class PostgresReachabilityCache : IReachabilityCache ServiceId = serviceId, GraphHash = graphHash, CachedAt = cachedAt, - TimeToLive = expiresAt.HasValue ? expiresAt.Value - DateTimeOffset.UtcNow : null, + TimeToLive = expiresAt.HasValue ? expiresAt.Value - _timeProvider.GetUtcNow() : null, ReachablePairs = pairs, EntryPointCount = entryPointCount, SinkCount = sinkCount @@ -143,7 +146,7 @@ public sealed class PostgresReachabilityCache : IReachabilityCache } var expiresAt = entry.TimeToLive.HasValue - ? (object)DateTimeOffset.UtcNow.Add(entry.TimeToLive.Value) + ? (object)_timeProvider.GetUtcNow().Add(entry.TimeToLive.Value) : DBNull.Value; const string insertEntrySql = """ diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/EdgeBundle.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/EdgeBundle.cs index 935c43862..3133a7c9f 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/EdgeBundle.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/EdgeBundle.cs @@ -225,8 +225,9 @@ public sealed class EdgeBundleBuilder return this; } - public EdgeBundle Build() + public EdgeBundle Build(TimeProvider? timeProvider = null) { + var tp = timeProvider ?? TimeProvider.System; var canonical = _edges .Select(e => e.Trimmed()) .OrderBy(e => e.From, StringComparer.Ordinal) @@ -241,7 +242,7 @@ public sealed class EdgeBundleBuilder GraphHash: _graphHash, BundleReason: _bundleReason, Edges: canonical, - GeneratedAt: DateTimeOffset.UtcNow, + GeneratedAt: tp.GetUtcNow(), CustomReason: _customReason); } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Explanation/PathExplanationModels.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Explanation/PathExplanationModels.cs index bb593e6be..4ba4df9a6 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Explanation/PathExplanationModels.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Explanation/PathExplanationModels.cs @@ -322,5 +322,5 @@ public sealed record PathExplanationResult /// When the explanation was generated. /// [JsonPropertyName("generated_at")] - public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset GeneratedAt { get; init; } } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Gates/Detectors/FileSystemCodeContentProvider.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Gates/Detectors/FileSystemCodeContentProvider.cs index 77441e0cc..33225d9a0 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Gates/Detectors/FileSystemCodeContentProvider.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Gates/Detectors/FileSystemCodeContentProvider.cs @@ -24,7 +24,7 @@ public sealed class FileSystemCodeContentProvider : ICodeContentProvider return Task.FromResult(null); } - return File.ReadAllTextAsync(path, ct); + return File.ReadAllTextAsync(path, ct)!; } public async Task?> GetLinesAsync( diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/MiniMap/MiniMapExtractor.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/MiniMap/MiniMapExtractor.cs index 6704dd544..f922e4548 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/MiniMap/MiniMapExtractor.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/MiniMap/MiniMapExtractor.cs @@ -8,7 +8,7 @@ namespace StellaOps.Scanner.Reachability.MiniMap; public interface IMiniMapExtractor { - ReachabilityMiniMap Extract(RichGraph graph, string vulnerableComponent, int maxPaths = 10); + ReachabilityMiniMap Extract(RichGraph graph, string vulnerableComponent, int maxPaths = 10, TimeProvider? timeProvider = null); } public sealed class MiniMapExtractor : IMiniMapExtractor @@ -16,16 +16,19 @@ public sealed class MiniMapExtractor : IMiniMapExtractor public ReachabilityMiniMap Extract( RichGraph graph, string vulnerableComponent, - int maxPaths = 10) + int maxPaths = 10, + TimeProvider? timeProvider = null) { // Find vulnerable component node var vulnNode = graph.Nodes.FirstOrDefault(n => n.Purl == vulnerableComponent || n.SymbolId?.Contains(vulnerableComponent) == true); + var tp = timeProvider ?? TimeProvider.System; + if (vulnNode is null) { - return CreateNotFoundMap(vulnerableComponent); + return CreateNotFoundMap(vulnerableComponent, tp); } // Find all entrypoints @@ -75,11 +78,11 @@ public sealed class MiniMapExtractor : IMiniMapExtractor State = state, Confidence = confidence, GraphDigest = ComputeGraphDigest(graph), - AnalyzedAt = DateTimeOffset.UtcNow + AnalyzedAt = tp.GetUtcNow() }; } - private static ReachabilityMiniMap CreateNotFoundMap(string vulnerableComponent) + private static ReachabilityMiniMap CreateNotFoundMap(string vulnerableComponent, TimeProvider timeProvider) { return new ReachabilityMiniMap { @@ -96,7 +99,7 @@ public sealed class MiniMapExtractor : IMiniMapExtractor State = ReachabilityState.Unknown, Confidence = 0m, GraphDigest = string.Empty, - AnalyzedAt = DateTimeOffset.UtcNow + AnalyzedAt = timeProvider.GetUtcNow() }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/ReachabilityUnionWriter.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/ReachabilityUnionWriter.cs index 4fc94f3ab..ffca1fa0d 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/ReachabilityUnionWriter.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/ReachabilityUnionWriter.cs @@ -17,6 +17,8 @@ namespace StellaOps.Scanner.Reachability; /// public sealed class ReachabilityUnionWriter { + private readonly TimeProvider _timeProvider; + private static readonly JsonWriterOptions JsonOptions = new() { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, @@ -24,6 +26,11 @@ public sealed class ReachabilityUnionWriter SkipValidation = false }; + public ReachabilityUnionWriter(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + public async Task WriteAsync( ReachabilityUnionGraph graph, string outputRoot, @@ -57,7 +64,7 @@ public sealed class ReachabilityUnionWriter File.Delete(factsPath); } - await WriteMetaAsync(metaPath, nodesInfo, edgesInfo, factsInfo, cancellationToken).ConfigureAwait(false); + await WriteMetaAsync(metaPath, nodesInfo, edgesInfo, factsInfo, _timeProvider, cancellationToken).ConfigureAwait(false); return new ReachabilityUnionWriteResult(nodesInfo.ToPublic(), edgesInfo.ToPublic(), factsInfo?.ToPublic(), metaPath); } @@ -387,6 +394,7 @@ public sealed class ReachabilityUnionWriter FileHashInfo nodes, FileHashInfo edges, FileHashInfo? facts, + TimeProvider timeProvider, CancellationToken cancellationToken) { await using var stream = File.Create(path); @@ -394,7 +402,7 @@ public sealed class ReachabilityUnionWriter writer.WriteStartObject(); writer.WriteString("schema", "reachability-union@0.1"); - writer.WriteString("generated_at", DateTimeOffset.UtcNow.ToString("O")); + writer.WriteString("generated_at", timeProvider.GetUtcNow().ToString("O")); writer.WritePropertyName("files"); writer.WriteStartArray(); WriteMetaFile(writer, nodes); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Slices/SliceCache.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Slices/SliceCache.cs index 4474c99af..391766e67 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Slices/SliceCache.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Slices/SliceCache.cs @@ -30,15 +30,17 @@ public sealed class SliceCacheOptions public sealed class SliceCache : ISliceCache, IDisposable { private readonly SliceCacheOptions _options; + private readonly TimeProvider _timeProvider; private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); private readonly Timer _evictionTimer; private long _hitCount; private long _missCount; private bool _disposed; - public SliceCache(IOptions options) + public SliceCache(IOptions options, TimeProvider? timeProvider = null) { _options = options?.Value ?? new SliceCacheOptions(); + _timeProvider = timeProvider ?? TimeProvider.System; _evictionTimer = new Timer(EvictExpired, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); } @@ -53,9 +55,10 @@ public sealed class SliceCache : ISliceCache, IDisposable if (_cache.TryGetValue(cacheKey, out var item)) { - if (item.ExpiresAt > DateTimeOffset.UtcNow) + var now = _timeProvider.GetUtcNow(); + if (item.ExpiresAt > now) { - item.LastAccessed = DateTimeOffset.UtcNow; + item.LastAccessed = now; Interlocked.Increment(ref _hitCount); var result = new CachedSliceResult { @@ -89,7 +92,7 @@ public sealed class SliceCache : ISliceCache, IDisposable EvictLru(); } - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); var item = new CacheItem { Digest = result.SliceDigest, @@ -132,7 +135,7 @@ public sealed class SliceCache : ISliceCache, IDisposable { if (_disposed) return; - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); var keysToRemove = _cache .Where(kvp => kvp.Value.ExpiresAt <= now) .Select(kvp => kvp.Key) diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Stack/ReachabilityStackEvaluator.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Stack/ReachabilityStackEvaluator.cs index 608d21d1e..b07dac195 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Stack/ReachabilityStackEvaluator.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Stack/ReachabilityStackEvaluator.cs @@ -19,7 +19,8 @@ public interface IReachabilityStackEvaluator VulnerableSymbol symbol, ReachabilityLayer1 layer1, ReachabilityLayer2 layer2, - ReachabilityLayer3 layer3); + ReachabilityLayer3 layer3, + TimeProvider? timeProvider = null); /// /// Derives the verdict from three layers. @@ -53,8 +54,10 @@ public sealed class ReachabilityStackEvaluator : IReachabilityStackEvaluator VulnerableSymbol symbol, ReachabilityLayer1 layer1, ReachabilityLayer2 layer2, - ReachabilityLayer3 layer3) + ReachabilityLayer3 layer3, + TimeProvider? timeProvider = null) { + var tp = timeProvider ?? TimeProvider.System; var verdict = DeriveVerdict(layer1, layer2, layer3); var explanation = GenerateExplanation(layer1, layer2, layer3, verdict); @@ -67,7 +70,7 @@ public sealed class ReachabilityStackEvaluator : IReachabilityStackEvaluator BinaryResolution = layer2, RuntimeGating = layer3, Verdict = verdict, - AnalyzedAt = DateTimeOffset.UtcNow, + AnalyzedAt = tp.GetUtcNow(), Explanation = explanation }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/WitnessDsseSigner.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/WitnessDsseSigner.cs index 41efe9f7a..c030c1940 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/WitnessDsseSigner.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/WitnessDsseSigner.cs @@ -125,7 +125,7 @@ public sealed class WitnessDsseSigner : IWitnessDsseSigner return WitnessVerifyResult.Failure($"Signature verification failed: {verifyResult.Error?.Message}"); } - return WitnessVerifyResult.Success(witness, matchingSignature.KeyId); + return WitnessVerifyResult.Success(witness, matchingSignature.KeyId!); } catch (Exception ex) when (ex is JsonException or FormatException or InvalidOperationException) { diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Ebpf/EbpfTraceCollector.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Ebpf/EbpfTraceCollector.cs index f537ef4fb..7b5845a05 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Ebpf/EbpfTraceCollector.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Ebpf/EbpfTraceCollector.cs @@ -12,13 +12,7 @@ public sealed class EbpfTraceCollector : ITraceCollector private readonly ISymbolResolver _symbolResolver; private readonly TimeProvider _timeProvider; private bool _isRunning; - private TraceCollectorStats _stats = new TraceCollectorStats - { - EventsCollected = 0, - EventsDropped = 0, - BytesProcessed = 0, - StartedAt = DateTimeOffset.UtcNow - }; + private TraceCollectorStats _stats; public EbpfTraceCollector( ILogger logger, @@ -28,6 +22,13 @@ public sealed class EbpfTraceCollector : ITraceCollector _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _symbolResolver = symbolResolver ?? throw new ArgumentNullException(nameof(symbolResolver)); _timeProvider = timeProvider ?? TimeProvider.System; + _stats = new TraceCollectorStats + { + EventsCollected = 0, + EventsDropped = 0, + BytesProcessed = 0, + StartedAt = _timeProvider.GetUtcNow() + }; } public Task StartAsync(TraceCollectorConfig config, CancellationToken cancellationToken = default) diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Etw/EtwTraceCollector.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Etw/EtwTraceCollector.cs index b55c00e38..e17c9b631 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Etw/EtwTraceCollector.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Etw/EtwTraceCollector.cs @@ -11,13 +11,7 @@ public sealed class EtwTraceCollector : ITraceCollector private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private bool _isRunning; - private TraceCollectorStats _stats = new TraceCollectorStats - { - EventsCollected = 0, - EventsDropped = 0, - BytesProcessed = 0, - StartedAt = DateTimeOffset.UtcNow - }; + private TraceCollectorStats _stats; public EtwTraceCollector( ILogger logger, @@ -25,6 +19,13 @@ public sealed class EtwTraceCollector : ITraceCollector { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; + _stats = new TraceCollectorStats + { + EventsCollected = 0, + EventsDropped = 0, + BytesProcessed = 0, + StartedAt = _timeProvider.GetUtcNow() + }; } public Task StartAsync(TraceCollectorConfig config, CancellationToken cancellationToken = default) diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Ingestion/TraceIngestionService.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Ingestion/TraceIngestionService.cs index 595ea7780..9d543fd64 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Ingestion/TraceIngestionService.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Ingestion/TraceIngestionService.cs @@ -169,9 +169,9 @@ public sealed class TraceIngestionService : ITraceIngestionService return Array.Empty(); } - private static string GenerateTraceId(string scanId, long eventCount) + private string GenerateTraceId(string scanId, long eventCount) { - var input = $"{scanId}|{eventCount}|{DateTimeOffset.UtcNow.Ticks}"; + var input = $"{scanId}|{eventCount}|{_timeProvider.GetUtcNow().Ticks}"; var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input)); return $"trace_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}"; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Slices/ObservedSliceGenerator.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Slices/ObservedSliceGenerator.cs index 3edab136e..a68961574 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Slices/ObservedSliceGenerator.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Runtime/Slices/ObservedSliceGenerator.cs @@ -10,14 +10,17 @@ namespace StellaOps.Scanner.Runtime.Slices; public sealed class ObservedSliceGenerator { private readonly SliceExtractor _sliceExtractor; + private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public ObservedSliceGenerator( SliceExtractor sliceExtractor, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _sliceExtractor = sliceExtractor ?? throw new ArgumentNullException(nameof(sliceExtractor)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -72,12 +75,13 @@ public sealed class ObservedSliceGenerator if (enrichment.TryGetValue(key, out var enrich) && enrich.Observed) { + var now = _timeProvider.GetUtcNow(); return edge with { Observed = new ObservedEdgeMetadata { - FirstObserved = enrich.FirstObserved ?? DateTimeOffset.UtcNow, - LastObserved = enrich.LastObserved ?? DateTimeOffset.UtcNow, + FirstObserved = enrich.FirstObserved ?? now, + LastObserved = enrich.LastObserved ?? now, ObservationCount = (int)enrich.ObservationCount, TraceDigest = null } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/VexCandidateEmitter.cs b/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/VexCandidateEmitter.cs index 147afa33e..9f75c331a 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/VexCandidateEmitter.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/VexCandidateEmitter.cs @@ -12,11 +12,16 @@ public sealed class VexCandidateEmitter { private readonly VexCandidateEmitterOptions _options; private readonly IVexCandidateStore? _store; + private readonly TimeProvider _timeProvider; - public VexCandidateEmitter(VexCandidateEmitterOptions? options = null, IVexCandidateStore? store = null) + public VexCandidateEmitter( + VexCandidateEmitterOptions? options = null, + IVexCandidateStore? store = null, + TimeProvider? timeProvider = null) { _options = options ?? VexCandidateEmitterOptions.Default; _store = store; + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -79,7 +84,7 @@ public sealed class VexCandidateEmitter ImageDigest: context.TargetImageDigest, CandidatesEmitted: candidates.Count, Candidates: [.. candidates], - Timestamp: DateTimeOffset.UtcNow); + Timestamp: _timeProvider.GetUtcNow()); } /// @@ -163,16 +168,16 @@ public sealed class VexCandidateEmitter EvidenceLinks: [.. evidenceLinks], Confidence: confidence, ImageDigest: context.TargetImageDigest, - GeneratedAt: DateTimeOffset.UtcNow, - ExpiresAt: DateTimeOffset.UtcNow.Add(_options.CandidateTtl), + GeneratedAt: _timeProvider.GetUtcNow(), + ExpiresAt: _timeProvider.GetUtcNow().Add(_options.CandidateTtl), RequiresReview: true); } - private static string GenerateCandidateId( + private string GenerateCandidateId( FindingSnapshot finding, VexCandidateEmissionContext context) { - var input = $"{context.TargetImageDigest}:{finding.FindingKey}:{DateTimeOffset.UtcNow.Ticks}"; + var input = $"{context.TargetImageDigest}:{finding.FindingKey}:{_timeProvider.GetUtcNow().Ticks}"; var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return $"vexc-{Convert.ToHexString(hash).ToLowerInvariant()[..16]}"; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/VexEvidence.cs b/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/VexEvidence.cs index 3c1015a44..bef8497b4 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/VexEvidence.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/VexEvidence.cs @@ -97,9 +97,17 @@ public sealed record VexEvidence /// /// Whether the VEX statement is still valid (not expired). + /// Uses system time for evaluation. For deterministic testing, use . /// [JsonIgnore] - public bool IsValid => ExpiresAt is null || ExpiresAt > DateTimeOffset.UtcNow; + public bool IsValid => IsValidAt(TimeProvider.System.GetUtcNow()); + + /// + /// Checks whether the VEX statement is valid at a specific point in time. + /// + /// The time to check validity against. + /// True if the statement is valid (not expired), false otherwise. + public bool IsValidAt(DateTimeOffset now) => ExpiresAt is null || ExpiresAt > now; /// /// Whether this VEX statement indicates the vulnerability is not exploitable. diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/CliConnectionTester.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/CliConnectionTester.cs index 9c9948d38..3ab3cab05 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/CliConnectionTester.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/CliConnectionTester.cs @@ -15,6 +15,7 @@ namespace StellaOps.Scanner.Sources.ConnectionTesters; public sealed class CliConnectionTester : ISourceTypeConnectionTester { private readonly ICredentialResolver _credentialResolver; + private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private static readonly JsonSerializerOptions JsonOptions = new() @@ -27,10 +28,12 @@ public sealed class CliConnectionTester : ISourceTypeConnectionTester public CliConnectionTester( ICredentialResolver credentialResolver, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _credentialResolver = credentialResolver; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task TestAsync( @@ -45,7 +48,7 @@ public sealed class CliConnectionTester : ISourceTypeConnectionTester { Success = false, Message = "Invalid configuration format", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } @@ -103,7 +106,7 @@ public sealed class CliConnectionTester : ISourceTypeConnectionTester { Success = false, Message = $"Configuration issues: {string.Join("; ", validationIssues)}", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }; } @@ -112,7 +115,7 @@ public sealed class CliConnectionTester : ISourceTypeConnectionTester { Success = true, Message = "CLI source configuration is valid - ready to receive SBOMs", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Contracts/SourceContracts.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Contracts/SourceContracts.cs index b92d419ab..8f7fe1e06 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Contracts/SourceContracts.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Contracts/SourceContracts.cs @@ -298,23 +298,23 @@ public sealed record ConnectionTestResult public required bool Success { get; init; } public string? Message { get; init; } public string? ErrorCode { get; init; } - public DateTimeOffset TestedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset TestedAt { get; init; } public List Checks { get; init; } = []; public Dictionary? Details { get; init; } - public static ConnectionTestResult Succeeded(string? message = null) => new() + public static ConnectionTestResult Succeeded(TimeProvider timeProvider, string? message = null) => new() { Success = true, Message = message ?? "Connection successful", - TestedAt = DateTimeOffset.UtcNow + TestedAt = timeProvider.GetUtcNow() }; - public static ConnectionTestResult Failed(string message, string? errorCode = null) => new() + public static ConnectionTestResult Failed(TimeProvider timeProvider, string message, string? errorCode = null) => new() { Success = false, Message = message, ErrorCode = errorCode, - TestedAt = DateTimeOffset.UtcNow + TestedAt = timeProvider.GetUtcNow() }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Domain/SbomSource.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Domain/SbomSource.cs index c69d0b0c9..02bea4af9 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Domain/SbomSource.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Domain/SbomSource.cs @@ -2,6 +2,8 @@ using System.Text.Json; namespace StellaOps.Scanner.Sources.Domain; +#pragma warning disable CA1062 // Validate arguments of public methods - TimeProvider validated at DI boundary + /// /// Represents a configured SBOM ingestion source. /// Sources can be registry webhooks (Zastava), direct Docker image scans, @@ -115,12 +117,13 @@ public sealed class SbomSource SbomSourceType sourceType, JsonDocument configuration, string createdBy, + TimeProvider timeProvider, string? description = null, string? authRef = null, string? cronSchedule = null, string? cronTimezone = null) { - var now = DateTimeOffset.UtcNow; + var now = timeProvider.GetUtcNow(); var source = new SbomSource { SourceId = Guid.NewGuid(), @@ -148,7 +151,7 @@ public sealed class SbomSource // Calculate next scheduled run if (!string.IsNullOrEmpty(cronSchedule)) { - source.CalculateNextScheduledRun(); + source.CalculateNextScheduledRun(timeProvider); } return source; @@ -161,37 +164,38 @@ public sealed class SbomSource /// /// Activate the source (after successful validation). /// - public void Activate(string updatedBy) + public void Activate(string updatedBy, TimeProvider timeProvider) { if (Status == SbomSourceStatus.Disabled) throw new InvalidOperationException("Cannot activate a disabled source. Enable it first."); Status = SbomSourceStatus.Active; - UpdatedAt = DateTimeOffset.UtcNow; + UpdatedAt = timeProvider.GetUtcNow(); UpdatedBy = updatedBy; } /// /// Pause the source with a reason. /// - public void Pause(string reason, string? ticket, string pausedBy) + public void Pause(string reason, string? ticket, string pausedBy, TimeProvider timeProvider) { if (Paused) return; + var now = timeProvider.GetUtcNow(); Paused = true; PauseReason = reason; PauseTicket = ticket; - PausedAt = DateTimeOffset.UtcNow; + PausedAt = now; PausedBy = pausedBy; Status = SbomSourceStatus.Paused; - UpdatedAt = DateTimeOffset.UtcNow; + UpdatedAt = now; UpdatedBy = pausedBy; } /// /// Resume a paused source. /// - public void Resume(string resumedBy) + public void Resume(string resumedBy, TimeProvider timeProvider) { if (!Paused) return; @@ -201,30 +205,30 @@ public sealed class SbomSource PausedAt = null; PausedBy = null; Status = ConsecutiveFailures > 0 ? SbomSourceStatus.Error : SbomSourceStatus.Active; - UpdatedAt = DateTimeOffset.UtcNow; + UpdatedAt = timeProvider.GetUtcNow(); UpdatedBy = resumedBy; } /// /// Disable the source administratively. /// - public void Disable(string disabledBy) + public void Disable(string disabledBy, TimeProvider timeProvider) { Status = SbomSourceStatus.Disabled; - UpdatedAt = DateTimeOffset.UtcNow; + UpdatedAt = timeProvider.GetUtcNow(); UpdatedBy = disabledBy; } /// /// Enable a disabled source. /// - public void Enable(string enabledBy) + public void Enable(string enabledBy, TimeProvider timeProvider) { if (Status != SbomSourceStatus.Disabled) throw new InvalidOperationException("Source is not disabled."); Status = SbomSourceStatus.Pending; - UpdatedAt = DateTimeOffset.UtcNow; + UpdatedAt = timeProvider.GetUtcNow(); UpdatedBy = enabledBy; } @@ -235,7 +239,7 @@ public sealed class SbomSource /// /// Record a successful run. /// - public void RecordSuccessfulRun(DateTimeOffset runAt) + public void RecordSuccessfulRun(DateTimeOffset runAt, TimeProvider timeProvider) { LastRunAt = runAt; LastRunStatus = SbomSourceRunStatus.Succeeded; @@ -247,14 +251,14 @@ public sealed class SbomSource Status = SbomSourceStatus.Active; } - IncrementHourScans(); - CalculateNextScheduledRun(); + IncrementHourScans(timeProvider); + CalculateNextScheduledRun(timeProvider); } /// /// Record a failed run. /// - public void RecordFailedRun(DateTimeOffset runAt, string error) + public void RecordFailedRun(DateTimeOffset runAt, string error, TimeProvider timeProvider) { LastRunAt = runAt; LastRunStatus = SbomSourceRunStatus.Failed; @@ -266,22 +270,22 @@ public sealed class SbomSource Status = SbomSourceStatus.Error; } - IncrementHourScans(); - CalculateNextScheduledRun(); + IncrementHourScans(timeProvider); + CalculateNextScheduledRun(timeProvider); } /// /// Record a partial success run. /// - public void RecordPartialRun(DateTimeOffset runAt, string? warning = null) + public void RecordPartialRun(DateTimeOffset runAt, TimeProvider timeProvider, string? warning = null) { LastRunAt = runAt; LastRunStatus = SbomSourceRunStatus.PartialSuccess; LastRunError = warning; // Don't reset consecutive failures for partial success - IncrementHourScans(); - CalculateNextScheduledRun(); + IncrementHourScans(timeProvider); + CalculateNextScheduledRun(timeProvider); } // ------------------------------------------------------------------------- @@ -291,12 +295,12 @@ public sealed class SbomSource /// /// Check if the source is rate limited. /// - public bool IsRateLimited() + public bool IsRateLimited(TimeProvider timeProvider) { if (!MaxScansPerHour.HasValue) return false; // Check if we're in a new hour window - var now = DateTimeOffset.UtcNow; + var now = timeProvider.GetUtcNow(); if (!HourWindowStart.HasValue || now - HourWindowStart.Value >= TimeSpan.FromHours(1)) { return false; // New window, not rate limited @@ -305,9 +309,9 @@ public sealed class SbomSource return CurrentHourScans >= MaxScansPerHour.Value; } - private void IncrementHourScans() + private void IncrementHourScans(TimeProvider timeProvider) { - var now = DateTimeOffset.UtcNow; + var now = timeProvider.GetUtcNow(); if (!HourWindowStart.HasValue || now - HourWindowStart.Value >= TimeSpan.FromHours(1)) { @@ -343,14 +347,14 @@ public sealed class SbomSource /// /// Regenerate webhook secret (for rotation). /// - public void RotateWebhookSecret(string updatedBy) + public void RotateWebhookSecret(string updatedBy, TimeProvider timeProvider) { if (WebhookEndpoint == null) throw new InvalidOperationException("Source does not have a webhook endpoint."); // The actual secret rotation happens in the credential store // This just updates the audit trail - UpdatedAt = DateTimeOffset.UtcNow; + UpdatedAt = timeProvider.GetUtcNow(); UpdatedBy = updatedBy; } @@ -361,7 +365,7 @@ public sealed class SbomSource /// /// Calculate the next scheduled run time. /// - public void CalculateNextScheduledRun() + public void CalculateNextScheduledRun(TimeProvider timeProvider) { if (string.IsNullOrEmpty(CronSchedule)) { @@ -373,7 +377,7 @@ public sealed class SbomSource { var cron = Cronos.CronExpression.Parse(CronSchedule); var timezone = TimeZoneInfo.FindSystemTimeZoneById(CronTimezone ?? "UTC"); - NextScheduledRun = cron.GetNextOccurrence(DateTimeOffset.UtcNow, timezone); + NextScheduledRun = cron.GetNextOccurrence(timeProvider.GetUtcNow(), timezone); } catch { @@ -397,10 +401,10 @@ public sealed class SbomSource /// /// Update the configuration. /// - public void UpdateConfiguration(JsonDocument newConfiguration, string updatedBy) + public void UpdateConfiguration(JsonDocument newConfiguration, string updatedBy, TimeProvider timeProvider) { Configuration = newConfiguration; - UpdatedAt = DateTimeOffset.UtcNow; + UpdatedAt = timeProvider.GetUtcNow(); UpdatedBy = updatedBy; } } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Domain/SbomSourceRun.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Domain/SbomSourceRun.cs index 584f7f3b4..e93fbb1df 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Domain/SbomSourceRun.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Domain/SbomSourceRun.cs @@ -1,5 +1,7 @@ namespace StellaOps.Scanner.Sources.Domain; +#pragma warning disable CA1062 // Validate arguments of public methods - TimeProvider validated at DI boundary + /// /// Represents a single execution run of an SBOM source. /// Tracks status, timing, item counts, and any errors. @@ -30,10 +32,17 @@ public sealed class SbomSourceRun /// When the run completed (if finished). public DateTimeOffset? CompletedAt { get; private set; } - /// Duration in milliseconds. - public long DurationMs => CompletedAt.HasValue - ? (long)(CompletedAt.Value - StartedAt).TotalMilliseconds - : (long)(DateTimeOffset.UtcNow - StartedAt).TotalMilliseconds; + /// + /// Duration in milliseconds. Pass a TimeProvider to get the live duration for in-progress runs. + /// + public long GetDurationMs(TimeProvider? timeProvider = null) + { + if (CompletedAt.HasValue) + return (long)(CompletedAt.Value - StartedAt).TotalMilliseconds; + + var now = timeProvider?.GetUtcNow() ?? DateTimeOffset.UtcNow; + return (long)(now - StartedAt).TotalMilliseconds; + } /// Number of items discovered to scan. public int ItemsDiscovered { get; private set; } @@ -74,6 +83,7 @@ public sealed class SbomSourceRun string tenantId, SbomSourceRunTrigger trigger, string correlationId, + TimeProvider timeProvider, string? triggerDetails = null) { return new SbomSourceRun @@ -84,7 +94,7 @@ public sealed class SbomSourceRun Trigger = trigger, TriggerDetails = triggerDetails, Status = SbomSourceRunStatus.Running, - StartedAt = DateTimeOffset.UtcNow, + StartedAt = timeProvider.GetUtcNow(), CorrelationId = correlationId }; } @@ -135,7 +145,7 @@ public sealed class SbomSourceRun /// /// Complete the run successfully. /// - public void Complete() + public void Complete(TimeProvider timeProvider) { Status = ItemsFailed > 0 ? SbomSourceRunStatus.PartialSuccess @@ -143,27 +153,27 @@ public sealed class SbomSourceRun ? SbomSourceRunStatus.Succeeded : SbomSourceRunStatus.Skipped; - CompletedAt = DateTimeOffset.UtcNow; + CompletedAt = timeProvider.GetUtcNow(); } /// /// Fail the run with an error. /// - public void Fail(string message, string? stackTrace = null) + public void Fail(string message, TimeProvider timeProvider, string? stackTrace = null) { Status = SbomSourceRunStatus.Failed; ErrorMessage = message; ErrorStackTrace = stackTrace; - CompletedAt = DateTimeOffset.UtcNow; + CompletedAt = timeProvider.GetUtcNow(); } /// /// Cancel the run. /// - public void Cancel(string reason) + public void Cancel(string reason, TimeProvider timeProvider) { Status = SbomSourceRunStatus.Cancelled; ErrorMessage = reason; - CompletedAt = DateTimeOffset.UtcNow; + CompletedAt = timeProvider.GetUtcNow(); } } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/ISourceTypeHandler.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/ISourceTypeHandler.cs index 766defffe..87643f71a 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/ISourceTypeHandler.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/ISourceTypeHandler.cs @@ -106,7 +106,7 @@ public sealed record WebhookPayloadInfo public string? Actor { get; init; } /// Timestamp of the event. - public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset Timestamp { get; init; } /// Additional metadata from the payload. public Dictionary Metadata { get; init; } = []; diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Services/SbomSourceService.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Services/SbomSourceService.cs index 062d5c4e9..d328efd6f 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Services/SbomSourceService.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Services/SbomSourceService.cs @@ -101,6 +101,7 @@ public sealed class SbomSourceService : ISbomSourceService request.SourceType, request.Configuration, createdBy, + _timeProvider, request.Description, request.AuthRef, request.CronSchedule, @@ -158,7 +159,7 @@ public sealed class SbomSourceService : ISbomSourceService throw new ArgumentException($"Invalid configuration: {string.Join(", ", validationResult.Errors)}"); } - source.UpdateConfiguration(request.Configuration, updatedBy); + source.UpdateConfiguration(request.Configuration, updatedBy, _timeProvider); } // Validate cron schedule if provided @@ -177,7 +178,7 @@ public sealed class SbomSourceService : ISbomSourceService } source.CronSchedule = request.CronSchedule; - source.CalculateNextScheduledRun(); + source.CalculateNextScheduledRun(_timeProvider); } // Update simple fields via reflection (maintaining encapsulation) @@ -199,7 +200,7 @@ public sealed class SbomSourceService : ISbomSourceService if (request.CronTimezone != null) { source.CronTimezone = request.CronTimezone; - source.CalculateNextScheduledRun(); + source.CalculateNextScheduledRun(_timeProvider); } if (request.MaxScansPerHour.HasValue) @@ -265,6 +266,7 @@ public sealed class SbomSourceService : ISbomSourceService request.SourceType, request.Configuration, "__test__", + _timeProvider, authRef: request.AuthRef); return await _connectionTester.TestAsync(tempSource, request.TestCredentials, ct); @@ -280,7 +282,7 @@ public sealed class SbomSourceService : ISbomSourceService var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct) ?? throw new KeyNotFoundException($"Source {sourceId} not found"); - source.Pause(request.Reason, request.Ticket, pausedBy); + source.Pause(request.Reason, request.Ticket, pausedBy, _timeProvider); await _sourceRepository.UpdateAsync(source, ct); _logger.LogInformation( @@ -299,7 +301,7 @@ public sealed class SbomSourceService : ISbomSourceService var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct) ?? throw new KeyNotFoundException($"Source {sourceId} not found"); - source.Resume(resumedBy); + source.Resume(resumedBy, _timeProvider); await _sourceRepository.UpdateAsync(source, ct); _logger.LogInformation( @@ -330,7 +332,7 @@ public sealed class SbomSourceService : ISbomSourceService throw new InvalidOperationException($"Source is paused: {source.PauseReason}"); } - if (source.IsRateLimited() && request?.Force != true) + if (source.IsRateLimited(_timeProvider) && request?.Force != true) { throw new InvalidOperationException("Source is rate limited. Use force=true to override."); } @@ -341,6 +343,7 @@ public sealed class SbomSourceService : ISbomSourceService tenantId, SbomSourceRunTrigger.Manual, Guid.NewGuid().ToString("N"), + _timeProvider, $"Triggered by {triggeredBy}"); await _runRepository.CreateAsync(run, ct); @@ -407,7 +410,7 @@ public sealed class SbomSourceService : ISbomSourceService var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct) ?? throw new KeyNotFoundException($"Source {sourceId} not found"); - source.Activate(activatedBy); + source.Activate(activatedBy, _timeProvider); await _sourceRepository.UpdateAsync(source, ct); _logger.LogInformation( diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Entities/SecretDetectionSettingsRow.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Entities/SecretDetectionSettingsRow.cs new file mode 100644 index 000000000..52b95546a --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Entities/SecretDetectionSettingsRow.cs @@ -0,0 +1,146 @@ +// ----------------------------------------------------------------------------- +// SecretDetectionSettingsRow.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-004 - Add persistence +// Description: Entity mapping for secret_detection_settings table. +// ----------------------------------------------------------------------------- + +namespace StellaOps.Scanner.Storage.Entities; + +/// +/// Entity mapping to scanner.secret_detection_settings table. +/// Per-tenant configuration for secret detection behavior. +/// +public sealed class SecretDetectionSettingsRow +{ + /// Unique identifier for this settings record. + public Guid SettingsId { get; set; } + + /// Tenant this configuration belongs to. + public Guid TenantId { get; set; } + + /// Whether secret detection is enabled for this tenant. + public bool Enabled { get; set; } + + /// Revelation policy configuration as JSON. + public string RevelationPolicy { get; set; } = default!; + + /// Enabled rule categories. + public string[] EnabledRuleCategories { get; set; } = []; + + /// Disabled rule IDs. + public string[] DisabledRuleIds { get; set; } = []; + + /// Alert settings as JSON. + public string AlertSettings { get; set; } = default!; + + /// Maximum file size to scan (bytes). + public long MaxFileSizeBytes { get; set; } + + /// File extensions to exclude from scanning. + public string[] ExcludedFileExtensions { get; set; } = []; + + /// Path patterns to exclude from scanning. + public string[] ExcludedPaths { get; set; } = []; + + /// Whether to scan binary files. + public bool ScanBinaryFiles { get; set; } + + /// Whether to require signature verification for rule bundles. + public bool RequireSignedRuleBundles { get; set; } + + /// Version for optimistic concurrency. + public int Version { get; set; } + + /// When this configuration was last updated. + public DateTimeOffset UpdatedAt { get; set; } + + /// Who last updated this configuration. + public string UpdatedBy { get; set; } = default!; + + /// When this row was created. + public DateTimeOffset CreatedAt { get; set; } +} + +/// +/// Entity mapping to scanner.secret_exception_pattern table. +/// Allowlist patterns for false positive suppression. +/// +public sealed class SecretExceptionPatternRow +{ + /// Unique identifier for this exception. + public Guid ExceptionId { get; set; } + + /// Tenant this exception belongs to. + public Guid TenantId { get; set; } + + /// Human-readable name for the exception. + public string Name { get; set; } = default!; + + /// Detailed description of why this exception exists. + public string Description { get; set; } = default!; + + /// Regex pattern to match against detected secret value. + public string ValuePattern { get; set; } = default!; + + /// Rule IDs this exception applies to. + public string[] ApplicableRuleIds { get; set; } = []; + + /// File path glob pattern. + public string? FilePathGlob { get; set; } + + /// Business justification for this exception. + public string Justification { get; set; } = default!; + + /// Expiration date (null means permanent). + public DateTimeOffset? ExpiresAt { get; set; } + + /// Whether this exception is currently active. + public bool IsActive { get; set; } + + /// Number of times this exception has matched a finding. + public long MatchCount { get; set; } + + /// Last time this exception matched a finding. + public DateTimeOffset? LastMatchedAt { get; set; } + + /// When this exception was created. + public DateTimeOffset CreatedAt { get; set; } + + /// Who created this exception. + public string CreatedBy { get; set; } = default!; + + /// When this exception was last modified. + public DateTimeOffset? UpdatedAt { get; set; } + + /// Who last modified this exception. + public string? UpdatedBy { get; set; } +} + +/// +/// Entity mapping to scanner.secret_exception_match_log table. +/// Audit log for exception matches. +/// +public sealed class SecretExceptionMatchLogRow +{ + /// Unique identifier for this log entry. + public Guid LogId { get; set; } + + /// Tenant this match belongs to. + public Guid TenantId { get; set; } + + /// Exception that matched. + public Guid ExceptionId { get; set; } + + /// Scan ID where the match occurred. + public Guid? ScanId { get; set; } + + /// File path where the match occurred. + public string? FilePath { get; set; } + + /// Rule ID that triggered the finding. + public string? RuleId { get; set; } + + /// When the match occurred. + public DateTimeOffset MatchedAt { get; set; } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/EpssReplayService.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/EpssReplayService.cs index c1bd5f11c..dc3386ac4 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/EpssReplayService.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Epss/EpssReplayService.cs @@ -242,8 +242,8 @@ public sealed class EpssReplayService DateOnly? endDate = null, CancellationToken cancellationToken = default) { - var start = startDate ?? DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-1)); - var end = endDate ?? DateOnly.FromDateTime(DateTime.UtcNow); + var start = startDate ?? DateOnly.FromDateTime(_timeProvider.GetUtcNow().UtcDateTime.AddYears(-1)); + var end = endDate ?? DateOnly.FromDateTime(_timeProvider.GetUtcNow().UtcDateTime); var rawPayloads = await _rawRepository.GetByDateRangeAsync(start, end, cancellationToken) .ConfigureAwait(false); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs index 03e70dd45..8e1a2edd7 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs @@ -116,6 +116,10 @@ public static class ServiceCollectionExtensions // Witness storage (Sprint: SPRINT_3700_0001_0001) services.AddScoped(); + // Secret detection settings (Sprint: SPRINT_20260104_006_BE) + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Models/ClassificationChangeModels.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Models/ClassificationChangeModels.cs index 635f91652..50b11f328 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Models/ClassificationChangeModels.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Models/ClassificationChangeModels.cs @@ -33,7 +33,7 @@ public sealed record ClassificationChange public IReadOnlyDictionary? CauseDetail { get; init; } // Timestamp - public DateTimeOffset ChangedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset ChangedAt { get; init; } } /// diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Models/ScanMetricsModels.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Models/ScanMetricsModels.cs index 11051dd20..458b38749 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Models/ScanMetricsModels.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Models/ScanMetricsModels.cs @@ -58,7 +58,7 @@ public sealed record ScanMetrics // Replay mode public bool IsReplay { get; init; } - public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset CreatedAt { get; init; } } /// diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/S3ArtifactObjectStore.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/S3ArtifactObjectStore.cs index 5778a28e3..fcc62d3bd 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/S3ArtifactObjectStore.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/S3ArtifactObjectStore.cs @@ -10,12 +10,18 @@ public sealed class S3ArtifactObjectStore : IArtifactObjectStore private readonly IAmazonS3 _s3; private readonly ObjectStoreOptions _options; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; - public S3ArtifactObjectStore(IAmazonS3 s3, IOptions options, ILogger logger) + public S3ArtifactObjectStore( + IAmazonS3 s3, + IOptions options, + ILogger logger, + TimeProvider? timeProvider = null) { _s3 = s3 ?? throw new ArgumentNullException(nameof(s3)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _options = (options ?? throw new ArgumentNullException(nameof(options))).Value.ObjectStore; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task PutAsync(ArtifactObjectDescriptor descriptor, Stream content, CancellationToken cancellationToken) @@ -36,11 +42,11 @@ public sealed class S3ArtifactObjectStore : IArtifactObjectStore request.ObjectLockMode = ObjectLockMode.Compliance; if (descriptor.RetainFor is { } retention && retention > TimeSpan.Zero) { - request.ObjectLockRetainUntilDate = DateTime.UtcNow + retention; + request.ObjectLockRetainUntilDate = _timeProvider.GetUtcNow().UtcDateTime + retention; } else if (_options.ComplianceRetention is { } defaultRetention && defaultRetention > TimeSpan.Zero) { - request.ObjectLockRetainUntilDate = DateTime.UtcNow + defaultRetention; + request.ObjectLockRetainUntilDate = _timeProvider.GetUtcNow().UtcDateTime + defaultRetention; } } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/021_secret_detection_settings.sql b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/021_secret_detection_settings.sql new file mode 100644 index 000000000..c1e875a0e --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/021_secret_detection_settings.sql @@ -0,0 +1,143 @@ +-- ============================================================================= +-- Migration: 021_secret_detection_settings.sql +-- Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +-- Task: SDC-004 - Add persistence (EF Core migrations) +-- Description: Per-tenant configuration for secret detection behavior. +-- +-- Note: migrations are executed with the module schema as the active search_path. +-- Keep objects unqualified so integration tests can run in isolated schemas. +-- ============================================================================= + +-- ============================================================================= +-- SECRET_DETECTION_SETTINGS: Per-tenant secret detection configuration +-- ============================================================================= +CREATE TABLE IF NOT EXISTS secret_detection_settings ( + settings_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Global toggle + enabled BOOLEAN NOT NULL DEFAULT FALSE, + + -- Revelation policy configuration (stored as JSONB) + revelation_policy JSONB NOT NULL DEFAULT '{ + "defaultPolicy": 1, + "exportPolicy": 0, + "partialRevealChars": 4, + "maxMaskChars": 8, + "fullRevealRoles": ["security-admin", "incident-responder"] + }'::jsonb, + + -- Rule configuration + enabled_rule_categories TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + disabled_rule_ids TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + + -- Alert settings (stored as JSONB) + alert_settings JSONB NOT NULL DEFAULT '{ + "enabled": false, + "minimumAlertSeverity": 2, + "destinations": [], + "maxAlertsPerScan": 10, + "deduplicationWindowMinutes": 1440, + "includeFilePath": true, + "includeMaskedValue": true, + "includeImageRef": true + }'::jsonb, + + -- Scanning limits + max_file_size_bytes BIGINT NOT NULL DEFAULT 10485760, + excluded_file_extensions TEXT[] NOT NULL DEFAULT ARRAY['.exe', '.dll', '.so', '.dylib', '.bin', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot']::TEXT[], + excluded_paths TEXT[] NOT NULL DEFAULT ARRAY['**/node_modules/**', '**/vendor/**', '**/.git/**']::TEXT[], + scan_binary_files BOOLEAN NOT NULL DEFAULT FALSE, + require_signed_rule_bundles BOOLEAN NOT NULL DEFAULT TRUE, + + -- Audit fields + version INTEGER NOT NULL DEFAULT 1, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by VARCHAR(256) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_secret_detection_settings_tenant UNIQUE (tenant_id) +); + +CREATE INDEX IF NOT EXISTS idx_secret_detection_settings_tenant ON secret_detection_settings(tenant_id); +CREATE INDEX IF NOT EXISTS idx_secret_detection_settings_enabled ON secret_detection_settings(enabled) WHERE enabled = TRUE; + +COMMENT ON TABLE secret_detection_settings IS 'Per-tenant configuration for secret detection behavior.'; + +-- ============================================================================= +-- SECRET_EXCEPTION_PATTERN: Allowlist patterns for false positive suppression +-- ============================================================================= +CREATE TABLE IF NOT EXISTS secret_exception_pattern ( + exception_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Pattern definition + name VARCHAR(100) NOT NULL, + description TEXT NOT NULL, + value_pattern TEXT NOT NULL, + applicable_rule_ids TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + file_path_glob VARCHAR(512), + justification TEXT NOT NULL, + + -- Validity + expires_at TIMESTAMPTZ, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + -- Usage tracking + match_count BIGINT NOT NULL DEFAULT 0, + last_matched_at TIMESTAMPTZ, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by VARCHAR(256) NOT NULL, + updated_at TIMESTAMPTZ, + updated_by VARCHAR(256), + + CONSTRAINT uq_secret_exception_pattern_name UNIQUE (tenant_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_secret_exception_pattern_tenant ON secret_exception_pattern(tenant_id); +CREATE INDEX IF NOT EXISTS idx_secret_exception_pattern_active ON secret_exception_pattern(tenant_id, is_active) WHERE is_active = TRUE; +CREATE INDEX IF NOT EXISTS idx_secret_exception_pattern_expires ON secret_exception_pattern(expires_at) WHERE expires_at IS NOT NULL; + +COMMENT ON TABLE secret_exception_pattern IS 'Allowlist patterns for suppressing false positive secret detections.'; + +-- ============================================================================= +-- SECRET_EXCEPTION_MATCH_LOG: Audit log for exception matches +-- ============================================================================= +CREATE TABLE IF NOT EXISTS secret_exception_match_log ( + log_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + exception_id UUID NOT NULL REFERENCES secret_exception_pattern(exception_id) ON DELETE CASCADE, + + -- Match context + scan_id UUID, + file_path VARCHAR(1024), + rule_id VARCHAR(128), + matched_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_secret_exception_match_log_exception ON secret_exception_match_log(exception_id); +CREATE INDEX IF NOT EXISTS idx_secret_exception_match_log_tenant_date ON secret_exception_match_log(tenant_id, matched_at DESC); + +COMMENT ON TABLE secret_exception_match_log IS 'Audit log tracking when exception patterns match findings.'; + +-- ============================================================================= +-- Trigger to update match_count on exception patterns +-- ============================================================================= +CREATE OR REPLACE FUNCTION update_exception_match_count() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE secret_exception_pattern + SET match_count = match_count + 1, + last_matched_at = NEW.matched_at + WHERE exception_id = NEW.exception_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_secret_exception_match_count ON secret_exception_match_log; +CREATE TRIGGER trg_secret_exception_match_count + AFTER INSERT ON secret_exception_match_log + FOR EACH ROW + EXECUTE FUNCTION update_exception_match_count(); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/MigrationIds.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/MigrationIds.cs index 23beea5f2..cabc5a549 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/MigrationIds.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/MigrationIds.cs @@ -21,5 +21,9 @@ internal static class MigrationIds public const string ReachCache = "016_reach_cache.sql"; public const string IdempotencyKeys = "017_idempotency_keys.sql"; public const string BinaryEvidence = "018_binary_evidence.sql"; + public const string FuncProofTables = "019_func_proof_tables.sql"; + public const string EnablePgTrgm = "019_enable_pg_trgm.sql"; + public const string SbomSources = "020_sbom_sources.sql"; + public const string SecretDetectionSettings = "021_secret_detection_settings.sql"; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/PostgresSecretDetectionSettingsRepository.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/PostgresSecretDetectionSettingsRepository.cs new file mode 100644 index 000000000..9afe3b28c --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/PostgresSecretDetectionSettingsRepository.cs @@ -0,0 +1,451 @@ +// ----------------------------------------------------------------------------- +// PostgresSecretDetectionSettingsRepository.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-004 - Add persistence +// Description: PostgreSQL implementation for secret detection settings. +// ----------------------------------------------------------------------------- + +using Dapper; +using StellaOps.Scanner.Storage.Entities; +using StellaOps.Scanner.Storage.Repositories; + +namespace StellaOps.Scanner.Storage.Postgres; + +/// +/// PostgreSQL implementation of secret detection settings repository. +/// +public sealed class PostgresSecretDetectionSettingsRepository : ISecretDetectionSettingsRepository +{ + private readonly ScannerDataSource _dataSource; + private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema; + private string TableName => $"{SchemaName}.secret_detection_settings"; + + public PostgresSecretDetectionSettingsRepository(ScannerDataSource dataSource) + { + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + } + + public async Task GetByTenantAsync( + Guid tenantId, + CancellationToken cancellationToken = default) + { + var sql = $""" + SELECT + settings_id AS SettingsId, + tenant_id AS TenantId, + enabled AS Enabled, + revelation_policy AS RevelationPolicy, + enabled_rule_categories AS EnabledRuleCategories, + disabled_rule_ids AS DisabledRuleIds, + alert_settings AS AlertSettings, + max_file_size_bytes AS MaxFileSizeBytes, + excluded_file_extensions AS ExcludedFileExtensions, + excluded_paths AS ExcludedPaths, + scan_binary_files AS ScanBinaryFiles, + require_signed_rule_bundles AS RequireSignedRuleBundles, + version AS Version, + updated_at AS UpdatedAt, + updated_by AS UpdatedBy, + created_at AS CreatedAt + FROM {TableName} + WHERE tenant_id = @TenantId + """; + + await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleOrDefaultAsync( + new CommandDefinition(sql, new { TenantId = tenantId }, cancellationToken: cancellationToken)) + .ConfigureAwait(false); + } + + public async Task CreateAsync( + SecretDetectionSettingsRow settings, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(settings); + + var sql = $""" + INSERT INTO {TableName} ( + tenant_id, + enabled, + revelation_policy, + enabled_rule_categories, + disabled_rule_ids, + alert_settings, + max_file_size_bytes, + excluded_file_extensions, + excluded_paths, + scan_binary_files, + require_signed_rule_bundles, + updated_by + ) VALUES ( + @TenantId, + @Enabled, + @RevelationPolicy::jsonb, + @EnabledRuleCategories, + @DisabledRuleIds, + @AlertSettings::jsonb, + @MaxFileSizeBytes, + @ExcludedFileExtensions, + @ExcludedPaths, + @ScanBinaryFiles, + @RequireSignedRuleBundles, + @UpdatedBy + ) + RETURNING settings_id AS SettingsId, version AS Version, created_at AS CreatedAt, updated_at AS UpdatedAt + """; + + await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + var result = await connection.QuerySingleAsync<(Guid SettingsId, int Version, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt)>( + new CommandDefinition(sql, settings, cancellationToken: cancellationToken)) + .ConfigureAwait(false); + + settings.SettingsId = result.SettingsId; + settings.Version = result.Version; + settings.CreatedAt = result.CreatedAt; + settings.UpdatedAt = result.UpdatedAt; + return settings; + } + + public async Task UpdateAsync( + SecretDetectionSettingsRow settings, + int expectedVersion, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(settings); + + var sql = $""" + UPDATE {TableName} + SET + enabled = @Enabled, + revelation_policy = @RevelationPolicy::jsonb, + enabled_rule_categories = @EnabledRuleCategories, + disabled_rule_ids = @DisabledRuleIds, + alert_settings = @AlertSettings::jsonb, + max_file_size_bytes = @MaxFileSizeBytes, + excluded_file_extensions = @ExcludedFileExtensions, + excluded_paths = @ExcludedPaths, + scan_binary_files = @ScanBinaryFiles, + require_signed_rule_bundles = @RequireSignedRuleBundles, + version = version + 1, + updated_at = NOW(), + updated_by = @UpdatedBy + WHERE settings_id = @SettingsId AND version = @ExpectedVersion + """; + + await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + var rowsAffected = await connection.ExecuteAsync( + new CommandDefinition(sql, new + { + settings.SettingsId, + settings.Enabled, + settings.RevelationPolicy, + settings.EnabledRuleCategories, + settings.DisabledRuleIds, + settings.AlertSettings, + settings.MaxFileSizeBytes, + settings.ExcludedFileExtensions, + settings.ExcludedPaths, + settings.ScanBinaryFiles, + settings.RequireSignedRuleBundles, + settings.UpdatedBy, + ExpectedVersion = expectedVersion + }, cancellationToken: cancellationToken)) + .ConfigureAwait(false); + + return rowsAffected > 0; + } + + public async Task> GetEnabledTenantsAsync(CancellationToken cancellationToken = default) + { + var sql = $""" + SELECT tenant_id + FROM {TableName} + WHERE enabled = TRUE + ORDER BY tenant_id + """; + + await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + var result = await connection.QueryAsync( + new CommandDefinition(sql, cancellationToken: cancellationToken)) + .ConfigureAwait(false); + + return result.ToList(); + } +} + +/// +/// PostgreSQL implementation of secret exception pattern repository. +/// +public sealed class PostgresSecretExceptionPatternRepository : ISecretExceptionPatternRepository +{ + private readonly ScannerDataSource _dataSource; + private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema; + private string PatternTableName => $"{SchemaName}.secret_exception_pattern"; + private string MatchLogTableName => $"{SchemaName}.secret_exception_match_log"; + + public PostgresSecretExceptionPatternRepository(ScannerDataSource dataSource) + { + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + } + + public async Task> GetActiveByTenantAsync( + Guid tenantId, + CancellationToken cancellationToken = default) + { + var sql = $""" + SELECT + exception_id AS ExceptionId, + tenant_id AS TenantId, + name AS Name, + description AS Description, + value_pattern AS ValuePattern, + applicable_rule_ids AS ApplicableRuleIds, + file_path_glob AS FilePathGlob, + justification AS Justification, + expires_at AS ExpiresAt, + is_active AS IsActive, + match_count AS MatchCount, + last_matched_at AS LastMatchedAt, + created_at AS CreatedAt, + created_by AS CreatedBy, + updated_at AS UpdatedAt, + updated_by AS UpdatedBy + FROM {PatternTableName} + WHERE tenant_id = @TenantId + AND is_active = TRUE + AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY name + """; + + await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + var result = await connection.QueryAsync( + new CommandDefinition(sql, new { TenantId = tenantId }, cancellationToken: cancellationToken)) + .ConfigureAwait(false); + + return result.ToList(); + } + + public async Task GetByIdAsync( + Guid exceptionId, + CancellationToken cancellationToken = default) + { + var sql = $""" + SELECT + exception_id AS ExceptionId, + tenant_id AS TenantId, + name AS Name, + description AS Description, + value_pattern AS ValuePattern, + applicable_rule_ids AS ApplicableRuleIds, + file_path_glob AS FilePathGlob, + justification AS Justification, + expires_at AS ExpiresAt, + is_active AS IsActive, + match_count AS MatchCount, + last_matched_at AS LastMatchedAt, + created_at AS CreatedAt, + created_by AS CreatedBy, + updated_at AS UpdatedAt, + updated_by AS UpdatedBy + FROM {PatternTableName} + WHERE exception_id = @ExceptionId + """; + + await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleOrDefaultAsync( + new CommandDefinition(sql, new { ExceptionId = exceptionId }, cancellationToken: cancellationToken)) + .ConfigureAwait(false); + } + + public async Task CreateAsync( + SecretExceptionPatternRow pattern, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pattern); + + var sql = $""" + INSERT INTO {PatternTableName} ( + tenant_id, + name, + description, + value_pattern, + applicable_rule_ids, + file_path_glob, + justification, + expires_at, + is_active, + created_by + ) VALUES ( + @TenantId, + @Name, + @Description, + @ValuePattern, + @ApplicableRuleIds, + @FilePathGlob, + @Justification, + @ExpiresAt, + @IsActive, + @CreatedBy + ) + RETURNING exception_id AS ExceptionId, created_at AS CreatedAt + """; + + await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + var result = await connection.QuerySingleAsync<(Guid ExceptionId, DateTimeOffset CreatedAt)>( + new CommandDefinition(sql, pattern, cancellationToken: cancellationToken)) + .ConfigureAwait(false); + + pattern.ExceptionId = result.ExceptionId; + pattern.CreatedAt = result.CreatedAt; + return pattern; + } + + public async Task UpdateAsync( + SecretExceptionPatternRow pattern, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pattern); + + var sql = $""" + UPDATE {PatternTableName} + SET + name = @Name, + description = @Description, + value_pattern = @ValuePattern, + applicable_rule_ids = @ApplicableRuleIds, + file_path_glob = @FilePathGlob, + justification = @Justification, + expires_at = @ExpiresAt, + is_active = @IsActive, + updated_at = NOW(), + updated_by = @UpdatedBy + WHERE exception_id = @ExceptionId + """; + + await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + var rowsAffected = await connection.ExecuteAsync( + new CommandDefinition(sql, pattern, cancellationToken: cancellationToken)) + .ConfigureAwait(false); + + return rowsAffected > 0; + } + + public async Task DeleteAsync( + Guid exceptionId, + CancellationToken cancellationToken = default) + { + var sql = $""" + DELETE FROM {PatternTableName} + WHERE exception_id = @ExceptionId + """; + + await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + var rowsAffected = await connection.ExecuteAsync( + new CommandDefinition(sql, new { ExceptionId = exceptionId }, cancellationToken: cancellationToken)) + .ConfigureAwait(false); + + return rowsAffected > 0; + } + + public async Task> GetAllByTenantAsync( + Guid tenantId, + CancellationToken cancellationToken = default) + { + var sql = $""" + SELECT + exception_id AS ExceptionId, + tenant_id AS TenantId, + name AS Name, + description AS Description, + value_pattern AS ValuePattern, + applicable_rule_ids AS ApplicableRuleIds, + file_path_glob AS FilePathGlob, + justification AS Justification, + expires_at AS ExpiresAt, + is_active AS IsActive, + match_count AS MatchCount, + last_matched_at AS LastMatchedAt, + created_at AS CreatedAt, + created_by AS CreatedBy, + updated_at AS UpdatedAt, + updated_by AS UpdatedBy + FROM {PatternTableName} + WHERE tenant_id = @TenantId + ORDER BY name + """; + + await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + var result = await connection.QueryAsync( + new CommandDefinition(sql, new { TenantId = tenantId }, cancellationToken: cancellationToken)) + .ConfigureAwait(false); + + return result.ToList(); + } + + public async Task RecordMatchAsync( + Guid tenantId, + Guid exceptionId, + Guid? scanId, + string? filePath, + string? ruleId, + CancellationToken cancellationToken = default) + { + var sql = $""" + INSERT INTO {MatchLogTableName} ( + tenant_id, + exception_id, + scan_id, + file_path, + rule_id + ) VALUES ( + @TenantId, + @ExceptionId, + @ScanId, + @FilePath, + @RuleId + ) + """; + + await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync( + new CommandDefinition(sql, new { TenantId = tenantId, ExceptionId = exceptionId, ScanId = scanId, FilePath = filePath, RuleId = ruleId }, cancellationToken: cancellationToken)) + .ConfigureAwait(false); + } + + public async Task> GetExpiredAsync( + DateTimeOffset asOf, + CancellationToken cancellationToken = default) + { + var sql = $""" + SELECT + exception_id AS ExceptionId, + tenant_id AS TenantId, + name AS Name, + description AS Description, + value_pattern AS ValuePattern, + applicable_rule_ids AS ApplicableRuleIds, + file_path_glob AS FilePathGlob, + justification AS Justification, + expires_at AS ExpiresAt, + is_active AS IsActive, + match_count AS MatchCount, + last_matched_at AS LastMatchedAt, + created_at AS CreatedAt, + created_by AS CreatedBy, + updated_at AS UpdatedAt, + updated_by AS UpdatedBy + FROM {PatternTableName} + WHERE expires_at IS NOT NULL + AND expires_at <= @AsOf + AND is_active = TRUE + ORDER BY expires_at + """; + + await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false); + var result = await connection.QueryAsync( + new CommandDefinition(sql, new { AsOf = asOf }, cancellationToken: cancellationToken)) + .ConfigureAwait(false); + + return result.ToList(); + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/ISecretDetectionSettingsRepository.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/ISecretDetectionSettingsRepository.cs new file mode 100644 index 000000000..9e3dc4d41 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/ISecretDetectionSettingsRepository.cs @@ -0,0 +1,111 @@ +// ----------------------------------------------------------------------------- +// ISecretDetectionSettingsRepository.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-004 - Add persistence +// Description: Repository interfaces for secret detection settings. +// ----------------------------------------------------------------------------- + +using StellaOps.Scanner.Storage.Entities; + +namespace StellaOps.Scanner.Storage.Repositories; + +/// +/// Repository interface for secret detection settings operations. +/// +public interface ISecretDetectionSettingsRepository +{ + /// + /// Gets the settings for a tenant. + /// + Task GetByTenantAsync( + Guid tenantId, + CancellationToken cancellationToken = default); + + /// + /// Creates new settings for a tenant. + /// + Task CreateAsync( + SecretDetectionSettingsRow settings, + CancellationToken cancellationToken = default); + + /// + /// Updates settings for a tenant with optimistic concurrency. + /// + /// True if update succeeded, false if version conflict. + Task UpdateAsync( + SecretDetectionSettingsRow settings, + int expectedVersion, + CancellationToken cancellationToken = default); + + /// + /// Gets all tenants with secret detection enabled. + /// + Task> GetEnabledTenantsAsync( + CancellationToken cancellationToken = default); +} + +/// +/// Repository interface for secret exception pattern operations. +/// +public interface ISecretExceptionPatternRepository +{ + /// + /// Gets all active exception patterns for a tenant. + /// + Task> GetActiveByTenantAsync( + Guid tenantId, + CancellationToken cancellationToken = default); + + /// + /// Gets a specific exception pattern. + /// + Task GetByIdAsync( + Guid exceptionId, + CancellationToken cancellationToken = default); + + /// + /// Creates a new exception pattern. + /// + Task CreateAsync( + SecretExceptionPatternRow pattern, + CancellationToken cancellationToken = default); + + /// + /// Updates an exception pattern. + /// + Task UpdateAsync( + SecretExceptionPatternRow pattern, + CancellationToken cancellationToken = default); + + /// + /// Deletes an exception pattern. + /// + Task DeleteAsync( + Guid exceptionId, + CancellationToken cancellationToken = default); + + /// + /// Gets all exception patterns for a tenant (including inactive). + /// + Task> GetAllByTenantAsync( + Guid tenantId, + CancellationToken cancellationToken = default); + + /// + /// Records that an exception pattern matched a finding. + /// + Task RecordMatchAsync( + Guid tenantId, + Guid exceptionId, + Guid? scanId, + string? filePath, + string? ruleId, + CancellationToken cancellationToken = default); + + /// + /// Gets expired exception patterns that need review. + /// + Task> GetExpiredAsync( + DateTimeOffset asOf, + CancellationToken cancellationToken = default); +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/PostgresWitnessRepository.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/PostgresWitnessRepository.cs index 31093b8bc..58c0d1fe8 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/PostgresWitnessRepository.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/PostgresWitnessRepository.cs @@ -22,11 +22,16 @@ public sealed class PostgresWitnessRepository : IWitnessRepository private readonly ScannerDataSource _dataSource; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; - public PostgresWitnessRepository(ScannerDataSource dataSource, ILogger logger) + public PostgresWitnessRepository( + ScannerDataSource dataSource, + ILogger logger, + TimeProvider? timeProvider = null) { _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task StoreAsync(WitnessRecord witness, CancellationToken cancellationToken = default) @@ -61,7 +66,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository cmd.Parameters.AddWithValue("run_id", witness.RunId.HasValue ? witness.RunId.Value : DBNull.Value); cmd.Parameters.AddWithValue("payload_json", witness.PayloadJson); cmd.Parameters.AddWithValue("dsse_envelope", string.IsNullOrEmpty(witness.DsseEnvelope) ? DBNull.Value : witness.DsseEnvelope); - cmd.Parameters.AddWithValue("created_at", witness.CreatedAt == default ? DateTimeOffset.UtcNow : witness.CreatedAt); + cmd.Parameters.AddWithValue("created_at", witness.CreatedAt == default ? _timeProvider.GetUtcNow() : witness.CreatedAt); cmd.Parameters.AddWithValue("signed_at", witness.SignedAt.HasValue ? witness.SignedAt.Value : DBNull.Value); cmd.Parameters.AddWithValue("signer_key_id", string.IsNullOrEmpty(witness.SignerKeyId) ? DBNull.Value : witness.SignerKeyId); cmd.Parameters.AddWithValue("entrypoint_fqn", string.IsNullOrEmpty(witness.EntrypointFqn) ? DBNull.Value : witness.EntrypointFqn); @@ -217,7 +222,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository await using var cmd = new NpgsqlCommand(sql, conn); cmd.Parameters.AddWithValue("witness_id", witnessId); cmd.Parameters.AddWithValue("dsse_envelope", dsseEnvelopeJson); - cmd.Parameters.AddWithValue("signed_at", DateTimeOffset.UtcNow); + cmd.Parameters.AddWithValue("signed_at", _timeProvider.GetUtcNow()); cmd.Parameters.AddWithValue("signer_key_id", string.IsNullOrEmpty(signerKeyId) ? DBNull.Value : signerKeyId); var affected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); @@ -244,7 +249,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false); await using var cmd = new NpgsqlCommand(sql, conn); cmd.Parameters.AddWithValue("witness_id", verification.WitnessId); - cmd.Parameters.AddWithValue("verified_at", verification.VerifiedAt == default ? DateTimeOffset.UtcNow : verification.VerifiedAt); + cmd.Parameters.AddWithValue("verified_at", verification.VerifiedAt == default ? _timeProvider.GetUtcNow() : verification.VerifiedAt); cmd.Parameters.AddWithValue("verified_by", string.IsNullOrEmpty(verification.VerifiedBy) ? DBNull.Value : verification.VerifiedBy); cmd.Parameters.AddWithValue("verification_status", verification.VerificationStatus); cmd.Parameters.AddWithValue("verification_error", string.IsNullOrEmpty(verification.VerificationError) ? DBNull.Value : verification.VerificationError); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Services/FnDriftCalculator.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Services/FnDriftCalculator.cs index a9c0614ea..8ea3414c0 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Services/FnDriftCalculator.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Services/FnDriftCalculator.cs @@ -11,13 +11,16 @@ public sealed class FnDriftCalculator { private readonly IClassificationHistoryRepository _repository; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; public FnDriftCalculator( IClassificationHistoryRepository repository, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -32,7 +35,7 @@ public sealed class FnDriftCalculator int windowDays = 30, CancellationToken cancellationToken = default) { - var since = DateTimeOffset.UtcNow.AddDays(-windowDays); + var since = _timeProvider.GetUtcNow().AddDays(-windowDays); var changes = await _repository.GetChangesAsync(tenantId, since, cancellationToken); var fnTransitions = changes.Where(c => c.IsFnTransition).ToList(); @@ -146,7 +149,7 @@ public sealed class FnDriftCalculator NewStatus = newStatus, Cause = cause, CauseDetail = causeDetail, - ChangedAt = DateTimeOffset.UtcNow + ChangedAt = _timeProvider.GetUtcNow() }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Env/SurfaceEnvironmentBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Env/SurfaceEnvironmentBuilder.cs index e83b7ea9a..96d1e2c7a 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Env/SurfaceEnvironmentBuilder.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Env/SurfaceEnvironmentBuilder.cs @@ -18,18 +18,21 @@ public sealed class SurfaceEnvironmentBuilder private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly SurfaceEnvironmentOptions _options; + private readonly TimeProvider _timeProvider; private readonly Dictionary _raw = new(StringComparer.OrdinalIgnoreCase); public SurfaceEnvironmentBuilder( IServiceProvider services, IConfiguration configuration, ILogger logger, - SurfaceEnvironmentOptions options) + SurfaceEnvironmentOptions options, + TimeProvider? timeProvider = null) { _services = services ?? throw new ArgumentNullException(nameof(services)); _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _options = options ?? throw new ArgumentNullException(nameof(options)); + _timeProvider = timeProvider ?? TimeProvider.System; if (_options.Prefixes.Count == 0) { @@ -60,9 +63,12 @@ public sealed class SurfaceEnvironmentBuilder featureFlags, secrets, tenant, - tls); + tls) + { + CreatedAtUtc = _timeProvider.GetUtcNow() + }; - return settings with { CreatedAtUtc = DateTimeOffset.UtcNow }; + return settings; } public IReadOnlyDictionary GetRawVariables() diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Env/SurfaceEnvironmentSettings.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Env/SurfaceEnvironmentSettings.cs index 8401e0e6a..576f1145f 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Env/SurfaceEnvironmentSettings.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Env/SurfaceEnvironmentSettings.cs @@ -21,5 +21,5 @@ public sealed record SurfaceEnvironmentSettings( /// /// Gets the timestamp (UTC) when the configuration snapshot was created. /// - public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset CreatedAtUtc { get; init; } } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Surface/SurfaceAnalyzer.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Surface/SurfaceAnalyzer.cs index 665996689..5d4845aad 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Surface/SurfaceAnalyzer.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Surface/SurfaceAnalyzer.cs @@ -49,17 +49,20 @@ public sealed class SurfaceAnalyzer : ISurfaceAnalyzer private readonly ISurfaceSignalEmitter _signalEmitter; private readonly ISurfaceAnalysisWriter _writer; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; public SurfaceAnalyzer( ISurfaceEntryRegistry registry, ISurfaceSignalEmitter signalEmitter, ISurfaceAnalysisWriter writer, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _registry = registry; _signalEmitter = signalEmitter; _writer = writer; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task AnalyzeAsync( @@ -146,7 +149,7 @@ public sealed class SurfaceAnalyzer : ISurfaceAnalyzer var result = new SurfaceAnalysisResult { ScanId = scanId, - Timestamp = DateTimeOffset.UtcNow, + Timestamp = _timeProvider.GetUtcNow(), Summary = summary, Entries = entries, EntryPoints = entryPoints diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageAttestation.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageAttestation.cs index cf751ecf9..1f78195a3 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageAttestation.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageAttestation.cs @@ -14,7 +14,7 @@ public sealed class TriageAttestation /// [Key] [Column("id")] - public Guid Id { get; init; } = Guid.NewGuid(); + public required Guid Id { get; init; } /// /// The finding this attestation applies to. @@ -57,7 +57,7 @@ public sealed class TriageAttestation /// When this attestation was collected. /// [Column("collected_at")] - public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset CollectedAt { get; init; } /// /// Navigation property back to the finding. diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageDecision.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageDecision.cs index 407e321ef..67841a859 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageDecision.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageDecision.cs @@ -14,7 +14,7 @@ public sealed class TriageDecision /// [Key] [Column("id")] - public Guid Id { get; init; } = Guid.NewGuid(); + public required Guid Id { get; init; } /// /// The finding this decision applies to. @@ -82,7 +82,7 @@ public sealed class TriageDecision /// When the decision was created. /// [Column("created_at")] - public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset CreatedAt { get; init; } /// /// When the decision was revoked (null = active). diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEffectiveVex.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEffectiveVex.cs index 310516a1e..645339d23 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEffectiveVex.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEffectiveVex.cs @@ -15,7 +15,7 @@ public sealed class TriageEffectiveVex /// [Key] [Column("id")] - public Guid Id { get; init; } = Guid.NewGuid(); + public required Guid Id { get; init; } /// /// The finding this VEX status applies to. @@ -71,7 +71,7 @@ public sealed class TriageEffectiveVex /// When this VEX status became valid. /// [Column("valid_from")] - public DateTimeOffset ValidFrom { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset ValidFrom { get; init; } /// /// When this VEX status expires (null = indefinite). @@ -83,7 +83,7 @@ public sealed class TriageEffectiveVex /// When this record was collected. /// [Column("collected_at")] - public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset CollectedAt { get; init; } // Navigation property [ForeignKey(nameof(FindingId))] diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEvidenceArtifact.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEvidenceArtifact.cs index 15ee64a81..3e0f8c690 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEvidenceArtifact.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageEvidenceArtifact.cs @@ -14,7 +14,7 @@ public sealed class TriageEvidenceArtifact /// [Key] [Column("id")] - public Guid Id { get; init; } = Guid.NewGuid(); + public required Guid Id { get; init; } /// /// The finding this evidence applies to. @@ -95,7 +95,7 @@ public sealed class TriageEvidenceArtifact /// When this artifact was created. /// [Column("created_at")] - public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset CreatedAt { get; init; } // Navigation property [ForeignKey(nameof(FindingId))] diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageFinding.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageFinding.cs index 99253dc81..735b5a205 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageFinding.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageFinding.cs @@ -16,7 +16,7 @@ public sealed class TriageFinding /// [Key] [Column("id")] - public Guid Id { get; init; } = Guid.NewGuid(); + public required Guid Id { get; init; } /// /// The asset this finding applies to. @@ -60,19 +60,19 @@ public sealed class TriageFinding /// When this finding was first observed. /// [Column("first_seen_at")] - public DateTimeOffset FirstSeenAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset FirstSeenAt { get; init; } /// /// When this finding was last observed. /// [Column("last_seen_at")] - public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow; + public required DateTimeOffset LastSeenAt { get; set; } /// /// When this finding was last updated. /// [Column("updated_at")] - public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public required DateTimeOffset UpdatedAt { get; set; } /// /// Current status of the finding (e.g., "open", "resolved", "muted"). diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriagePolicyDecision.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriagePolicyDecision.cs index f1be8502f..27d237045 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriagePolicyDecision.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriagePolicyDecision.cs @@ -14,7 +14,7 @@ public sealed class TriagePolicyDecision /// [Key] [Column("id")] - public Guid Id { get; init; } = Guid.NewGuid(); + public required Guid Id { get; init; } /// /// The finding this decision applies to. @@ -46,7 +46,7 @@ public sealed class TriagePolicyDecision /// When this decision was applied. /// [Column("applied_at")] - public DateTimeOffset AppliedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset AppliedAt { get; init; } /// /// Navigation property back to the finding. diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageReachabilityResult.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageReachabilityResult.cs index 9f352a257..cae37188b 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageReachabilityResult.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageReachabilityResult.cs @@ -14,7 +14,7 @@ public sealed class TriageReachabilityResult /// [Key] [Column("id")] - public Guid Id { get; init; } = Guid.NewGuid(); + public required Guid Id { get; init; } /// /// The finding this reachability result applies to. @@ -58,7 +58,7 @@ public sealed class TriageReachabilityResult /// When this result was computed. /// [Column("computed_at")] - public DateTimeOffset ComputedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset ComputedAt { get; init; } /// /// Content-addressed ID of the reachability subgraph for this finding. diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageRiskResult.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageRiskResult.cs index 80b2eafca..a1ee0e596 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageRiskResult.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageRiskResult.cs @@ -14,7 +14,7 @@ public sealed class TriageRiskResult /// [Key] [Column("id")] - public Guid Id { get; init; } = Guid.NewGuid(); + public required Guid Id { get; init; } /// /// The finding this risk result applies to. @@ -79,7 +79,7 @@ public sealed class TriageRiskResult /// When this result was computed. /// [Column("computed_at")] - public DateTimeOffset ComputedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset ComputedAt { get; init; } // Navigation property [ForeignKey(nameof(FindingId))] diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageScan.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageScan.cs index 6df125f5a..2cf5f5639 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageScan.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageScan.cs @@ -14,7 +14,7 @@ public sealed class TriageScan /// [Key] [Column("id")] - public Guid Id { get; init; } = Guid.NewGuid(); + public required Guid Id { get; init; } /// /// Image reference that was scanned. @@ -51,7 +51,7 @@ public sealed class TriageScan /// When the scan started. /// [Column("started_at")] - public DateTimeOffset StartedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset StartedAt { get; init; } /// /// When the scan completed. diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageSnapshot.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageSnapshot.cs index af79a40a5..ba7605686 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageSnapshot.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/TriageSnapshot.cs @@ -14,7 +14,7 @@ public sealed class TriageSnapshot /// [Key] [Column("id")] - public Guid Id { get; init; } = Guid.NewGuid(); + public required Guid Id { get; init; } /// /// The finding this snapshot applies to. @@ -58,7 +58,7 @@ public sealed class TriageSnapshot /// When this snapshot was created. /// [Column("created_at")] - public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset CreatedAt { get; init; } // Navigation property [ForeignKey(nameof(FindingId))] diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.VulnSurfaces/Builder/VulnSurfaceBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.VulnSurfaces/Builder/VulnSurfaceBuilder.cs index 900de452a..b3cdf3d49 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.VulnSurfaces/Builder/VulnSurfaceBuilder.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.VulnSurfaces/Builder/VulnSurfaceBuilder.cs @@ -32,6 +32,7 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder private readonly ITriggerMethodExtractor _triggerExtractor; private readonly IEnumerable _graphBuilders; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; public VulnSurfaceBuilder( IEnumerable downloaders, @@ -39,7 +40,8 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder IMethodDiffEngine diffEngine, ITriggerMethodExtractor triggerExtractor, IEnumerable graphBuilders, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _downloaders = downloaders ?? throw new ArgumentNullException(nameof(downloaders)); _fingerprinters = fingerprinters ?? throw new ArgumentNullException(nameof(fingerprinters)); @@ -47,6 +49,7 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder _triggerExtractor = triggerExtractor ?? throw new ArgumentNullException(nameof(triggerExtractor)); _graphBuilders = graphBuilders ?? throw new ArgumentNullException(nameof(graphBuilders)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -239,7 +242,7 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder TriggerCount = triggerCount, Status = VulnSurfaceStatus.Computed, Confidence = ComputeConfidence(diff, sinks.Count), - ComputedAt = DateTimeOffset.UtcNow + ComputedAt = _timeProvider.GetUtcNow() }; sw.Stop(); diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/Fixtures/aws-access-key.txt b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/Fixtures/aws-access-key.txt new file mode 100644 index 000000000..d36e07581 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/Fixtures/aws-access-key.txt @@ -0,0 +1,14 @@ +# AWS Configuration File +# This file contains test AWS credentials for integration testing + +[default] +aws_access_key_id = AKIAIOSFODNN7EXAMPLE +aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +# Another profile with different credentials +[production] +aws_access_key_id = AKIAI44QH8DHBEXAMPLE +aws_secret_access_key = je7MtGbClwBF/2Zp9Utk/h3yCo8nvbEXAMPLEKEY + +# This should NOT be detected (invalid format) +fake_key = NOT_A_REAL_AWS_KEY diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/Fixtures/github-token.txt b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/Fixtures/github-token.txt new file mode 100644 index 000000000..07c3f82ac --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/Fixtures/github-token.txt @@ -0,0 +1,23 @@ +# GitHub Configuration +# Contains test GitHub tokens for integration testing + +# Personal Access Token (classic) +GITHUB_TOKEN=ghp_1234567890123456789012345678901234567890 + +# Fine-grained PAT +github_pat_11AAAAAA_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789012345678901234567890 + +# GitHub App installation token +ghs_abc123def456ghi789jkl012mno345pqr678stu90 + +# OAuth Access Token +gho_1234567890abcdefghijklmnopqrstuvwxyz0123 + +# GitHub Actions token (starts with ghs_) +ACTIONS_TOKEN=ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Fake token that should NOT match (too short) +fake_token=gh_tooshort + +# Comment with token pattern that should NOT match +# See documentation at ghp_notarealtoken for examples diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/Fixtures/private-key.pem b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/Fixtures/private-key.pem new file mode 100644 index 000000000..a912fa33e --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/Fixtures/private-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA2Z3qX2BTLS4e0rqg/uvSsRA7jlbdFvjDsGvDlIMLvmkuESwL +gWVPemCwQQEuJoBCPcaSRxsC0+BiQhTCLZdPkpZ0YETLG3vYxfOqhLdLxP0PVpNo +sVRQcVmXuxCpgJpvxc/CIwPdPfA1RFVZmJQEzvLEpCheKlCJIPhZ0xSqR0AuY9rq +qcVAIBfLnMo7EFCgDBwU/B5GdLXo8FX3ZeHCVGZKuhYHhgG0VQKvtbZdyLBLdC2x +OjvJXDBxPLzwrAvvTkbIMRy4MptE3fS3pBoXKvnA3cXLjyOCqXYabtQ7AwXG7sOl +6b3t6gDqtC3VmGHsLH3fLrqMpaKimHq14JeZJwIDAQABAoIBAEK6XHTgHpL0gTSy +IL4NzfBQDqOK5MzIJmhDOB8sToNDX/ZjY14NVfPOS0zXQBVlLk1kp0zquNaQkCrP +n42vF8G0/HYqVLeApGLF3LECqHdp9o7SbKkJRndC0M7IOC1NTQj9cRTFyK6R3cD3 +rLaCNbpvoSN5x3ohCdzxnkdBwCGvZ7USkgpZZTjF/1AyB7akzoBLLzMAzkVD8yMS +KBheyGi9JHAB9pYLxQDnNCGdGNL36yPcVewvQvJKMc0FD4FJtShVDUOxf3wAJ2m8 +mQa3VJDDtfq1/nVN8NN0DC+PLyU9CqtFO3nDgVZt6IoYosgn0LoxEMNYjJrtB3nW +dW3nLwECgYEA8X6x0qXVRLxLWC7lPzoLOP3rMi1vLBYV4BjLERkskfN1PLBLDCYO +MEwI8JFGlLNvOhP2C3hIv2FAqfnW8dLh0GAZrTfhsLkLA0TA3ORwvY0PfIXrp/39 +4IzxVeQ6hFs0Np1D3j7F4KFKA2pDO2B5nhFVZMglH0yE7bJKb+e4Z5cCgYEA5rCO +cVvwKnfwi5qZKNl3zw8Xk5J13WK4B5XlUuCpNWVVk1sVd6BLl0R3RHF8J6AQq9hN +z6sbDoBtKxoZl6RfmJLdmZGdVCtKlhBoKlaO4u5lffKdP+S0vS8PVo6DeAcuIy3Y +ZKPhFHef0PCAQD2wCmamL1eKsOFFCqr5wCAXwQECgYAhJfyHswqp9AfgWHGExYOh +a1wViqHJVb1LDdLhJy/F5MgK3jA6h3B33WZBlGqkHetCPCaQ7PhOratFQ2wVBpWW +UGlcWoZpCzAaBRuN2re+8SoOFnqJJDdzYR0DPwUYjPRQyYmFy1jDLVT8X5W0O/1A +h7zaEQntGsr9fXVMxwqjZwKBgGX1kIQRgtYk9VzEJDrPNHjAZXBvuPf4T4AOxpBN +5e95PE+fN4LEpnCLr6VGJhGFaCs0xPPT3vCshL3uf9zD/HNM7Rl/0m/X4Fe0aMqv +3Dnb/FbPFDoLHu3y9KRuygaFJHeXgZT5CBB4F7cOtCB3A0xVz5xVNlBUcB6fzJFv +AYABAoGBAKc0geMzI/XuMRUL5X5lxjnkMiLuVmy5gjbmJGygXcLPQXg5nIW3HDAA +nT0q9j1M0yLZyRy7kCBFkxE3rXXOYhhzJPJj/K1I5Yxo8aO3daCf4W/CPDZ/VnDA +lsj/0vBMtZ3iGVewAiGnEPRIYhMv6zOO1QfOJlH+VnJS6EYc0fQT +-----END RSA PRIVATE KEY----- diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/Fixtures/test-ruleset.jsonl b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/Fixtures/test-ruleset.jsonl new file mode 100644 index 000000000..31cd2b7bc --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/Fixtures/test-ruleset.jsonl @@ -0,0 +1,6 @@ +{"id":"stellaops.secrets.aws-access-key","version":"2026.01.0","name":"AWS Access Key ID","description":"Detects AWS Access Key IDs starting with AKIA","type":"Regex","pattern":"AKIA[0-9A-Z]{16}","severity":"Critical","confidence":"High","maskingHint":"prefix:4,suffix:4","keywords":["AKIA"],"filePatterns":["**/*"],"minLength":20,"maxLength":20,"enabled":true} +{"id":"stellaops.secrets.aws-secret-key","version":"2026.01.0","name":"AWS Secret Access Key","description":"Detects AWS Secret Access Keys (high-entropy 40-char strings)","type":"Composite","pattern":"(?:[A-Za-z0-9+/]{40})","severity":"Critical","confidence":"Medium","maskingHint":"prefix:4,suffix:4","keywords":["aws","secret","key"],"filePatterns":["**/*"],"minLength":40,"maxLength":40,"entropyThreshold":4.0,"enabled":true} +{"id":"stellaops.secrets.github-pat","version":"2026.01.0","name":"GitHub Personal Access Token","description":"Detects GitHub Personal Access Tokens (classic and fine-grained)","type":"Regex","pattern":"ghp_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255}","severity":"Critical","confidence":"High","maskingHint":"prefix:4,suffix:4","keywords":["ghp_","github_pat_"],"filePatterns":["**/*"],"minLength":40,"maxLength":260,"enabled":true} +{"id":"stellaops.secrets.github-app","version":"2026.01.0","name":"GitHub App Token","description":"Detects GitHub App tokens (ghs_, gho_)","type":"Regex","pattern":"gh[so]_[A-Za-z0-9]{36,255}","severity":"High","confidence":"High","maskingHint":"prefix:4,suffix:4","keywords":["ghs_","gho_"],"filePatterns":["**/*"],"minLength":40,"maxLength":260,"enabled":true} +{"id":"stellaops.secrets.private-key-rsa","version":"2026.01.0","name":"RSA Private Key","description":"Detects PEM-encoded RSA private keys","type":"Regex","pattern":"-----BEGIN RSA PRIVATE KEY-----[\\s\\S]*?-----END RSA PRIVATE KEY-----","severity":"Critical","confidence":"High","maskingHint":"prefix:30,suffix:0","keywords":["-----BEGIN RSA PRIVATE KEY-----"],"filePatterns":["**/*.pem","**/*.key","**/*"],"minLength":100,"maxLength":10000,"enabled":true} +{"id":"stellaops.secrets.generic-high-entropy","version":"2026.01.0","name":"Generic High-Entropy String","description":"Detects high-entropy strings that may be secrets","type":"Entropy","pattern":"[A-Za-z0-9+/=_-]{20,}","severity":"Medium","confidence":"Low","maskingHint":"prefix:4,suffix:4","keywords":[],"filePatterns":["**/*"],"minLength":20,"maxLength":500,"entropyThreshold":4.5,"enabled":true} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/SecretsAnalyzerIntegrationTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/SecretsAnalyzerIntegrationTests.cs new file mode 100644 index 000000000..d2c06572c --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Secrets.Tests/SecretsAnalyzerIntegrationTests.cs @@ -0,0 +1,331 @@ +using System.Collections.Immutable; +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using StellaOps.Scanner.Analyzers.Secrets; +using Xunit; + +namespace StellaOps.Scanner.Analyzers.Secrets.Tests; + +/// +/// Integration tests for the Secrets Analyzer. +/// These tests verify end-to-end secret detection, masking, and evidence emission. +/// +[Trait("Category", "Integration")] +public sealed class SecretsAnalyzerIntegrationTests : IAsyncLifetime +{ + private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero)); + private readonly string _fixturesPath; + private SecretRuleset? _ruleset; + + public SecretsAnalyzerIntegrationTests() + { + _fixturesPath = Path.Combine(AppContext.BaseDirectory, "Fixtures"); + } + + public async ValueTask InitializeAsync() + { + // Load test ruleset + var rulesetPath = Path.Combine(_fixturesPath, "test-ruleset.jsonl"); + if (File.Exists(rulesetPath)) + { + var loader = new RulesetLoader( + NullLogger.Instance, + _timeProvider); + + await using var stream = File.OpenRead(rulesetPath); + _ruleset = await loader.LoadFromJsonlAsync( + stream, + "test-bundle", + "2026.01.0", + TestContext.Current.CancellationToken); + } + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + [Fact] + public async Task FullScan_DetectsAwsAccessKeys() + { + // Arrange + var options = CreateOptions(enabled: true); + var analyzer = CreateAnalyzer(options); + analyzer.SetRuleset(_ruleset!); + + var filePath = Path.Combine(_fixturesPath, "aws-access-key.txt"); + var content = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken); + + // Act + var matches = await DetectAllSecretsAsync(analyzer, content, filePath); + + // Assert + matches.Should().NotBeEmpty(); + var awsKeyMatches = matches.Where(m => m.Rule.Id == "stellaops.secrets.aws-access-key").ToList(); + awsKeyMatches.Should().HaveCount(2, "there are 2 AKIA keys in the fixture"); + + // Verify masking + foreach (var match in awsKeyMatches) + { + var masked = new PayloadMasker().Mask(match.RawMatch.Span, match.Rule.MaskingHint); + masked.Should().Contain("****", "secrets must be masked"); + masked.Should().StartWith("AKIA", "prefix should be preserved"); + } + } + + [Fact] + public async Task FullScan_DetectsGitHubTokens() + { + // Arrange + var options = CreateOptions(enabled: true); + var analyzer = CreateAnalyzer(options); + analyzer.SetRuleset(_ruleset!); + + var filePath = Path.Combine(_fixturesPath, "github-token.txt"); + var content = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken); + + // Act + var matches = await DetectAllSecretsAsync(analyzer, content, filePath); + + // Assert + matches.Should().NotBeEmpty(); + + // Should detect ghp_ tokens + var patMatches = matches.Where(m => m.Rule.Id == "stellaops.secrets.github-pat").ToList(); + patMatches.Should().NotBeEmpty("should detect GitHub PAT tokens"); + + // Should detect ghs_ / gho_ tokens + var appMatches = matches.Where(m => m.Rule.Id == "stellaops.secrets.github-app").ToList(); + appMatches.Should().NotBeEmpty("should detect GitHub app tokens"); + } + + [Fact] + public async Task FullScan_DetectsPrivateKeys() + { + // Arrange + var options = CreateOptions(enabled: true); + var analyzer = CreateAnalyzer(options); + analyzer.SetRuleset(_ruleset!); + + var filePath = Path.Combine(_fixturesPath, "private-key.pem"); + var content = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken); + + // Act + var matches = await DetectAllSecretsAsync(analyzer, content, filePath); + + // Assert + matches.Should().NotBeEmpty(); + var keyMatches = matches.Where(m => m.Rule.Id == "stellaops.secrets.private-key-rsa").ToList(); + keyMatches.Should().HaveCount(1, "there is 1 RSA key in the fixture"); + + // Verify the key is masked + var match = keyMatches[0]; + var masked = new PayloadMasker().Mask(match.RawMatch.Span, match.Rule.MaskingHint); + masked.Should().NotContain("MIIEowIBAAKCAQEA", "private key content must not be exposed"); + } + + [Fact] + public async Task FeatureFlag_WhenDisabled_NoDetection() + { + // Arrange + var options = CreateOptions(enabled: false); + var analyzer = CreateAnalyzer(options); + analyzer.SetRuleset(_ruleset!); + + var content = Encoding.UTF8.GetBytes("AKIAIOSFODNN7EXAMPLE"); + + // Act + var matches = await DetectAllSecretsAsync(analyzer, content, "test.txt"); + + // Assert + matches.Should().BeEmpty("analyzer is disabled via feature flag"); + } + + [Fact] + public async Task MaxFindings_CircuitBreaker_LimitsResults() + { + // Arrange + var options = CreateOptions(enabled: true, maxFindings: 2); + var analyzer = CreateAnalyzer(options); + analyzer.SetRuleset(_ruleset!); + + // Create content with many secrets + var content = Encoding.UTF8.GetBytes( + "AKIAIOSFODNN7EXAMPLE\n" + + "AKIABCDEFGHIJKLMNOP1\n" + + "AKIAZYXWVUTSRQPONML2\n" + + "AKIAQWERTYUIOPASDFGH\n" + + "AKIAMNBVCXZLKJHGFDSA"); + + // Act + var matches = await DetectAllSecretsAsync(analyzer, content, "test.txt"); + + // Assert + matches.Should().HaveCountLessThanOrEqualTo(2, "max findings limit should be respected"); + } + + [Fact] + public async Task Masking_NeverExposesFullSecret() + { + // Arrange + var masker = new PayloadMasker(); + var testCases = new[] + { + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "ghp_1234567890123456789012345678901234567890", + "ghs_abc123def456ghi789jkl012mno345pqr678stu90" + }; + + foreach (var secret in testCases) + { + var bytes = Encoding.UTF8.GetBytes(secret); + + // Act + var masked = masker.Mask(bytes); + + // Assert + masked.Should().Contain("****", $"secret '{secret[..4]}...' must be masked"); + masked.Length.Should().BeLessThan(secret.Length, "masked output should be shorter"); + + // Ensure no more than 6 characters are exposed total + var exposedChars = masked.Replace("*", "").Length; + exposedChars.Should().BeLessThanOrEqualTo(6, "at most 6 characters should be exposed"); + } + } + + [Fact] + public async Task Evidence_ContainsRequiredFields() + { + // Arrange + var options = CreateOptions(enabled: true); + var analyzer = CreateAnalyzer(options); + analyzer.SetRuleset(_ruleset!); + + var content = Encoding.UTF8.GetBytes("api_key = AKIAIOSFODNN7EXAMPLE"); + var filePath = "config/secrets.txt"; + + // Act + var matches = await DetectAllSecretsAsync(analyzer, content, filePath); + + // Assert + matches.Should().NotBeEmpty(); + var match = matches[0]; + + // Create evidence from match + var masker = new PayloadMasker(); + var evidence = new SecretLeakEvidence + { + DetectorId = "secrets-integration-test", + RuleId = match.Rule.Id, + RuleVersion = match.Rule.Version, + Severity = match.Rule.Severity, + Confidence = match.Rule.Confidence, + FilePath = match.FilePath, + LineNumber = match.LineNumber, + ColumnNumber = match.ColumnStart, + Mask = masker.Mask(match.RawMatch.Span, match.Rule.MaskingHint), + BundleId = _ruleset!.Id, + BundleVersion = _ruleset.Version, + DetectedAt = _timeProvider.GetUtcNow() + }; + + evidence.RuleId.Should().NotBeNullOrEmpty(); + evidence.RuleVersion.Should().NotBeNullOrEmpty(); + evidence.FilePath.Should().Be(filePath); + evidence.LineNumber.Should().BeGreaterThan(0); + evidence.Mask.Should().Contain("****"); + evidence.BundleId.Should().Be("test-bundle"); + evidence.DetectedAt.Should().Be(_timeProvider.GetUtcNow()); + } + + [Fact] + public async Task Determinism_SameInput_SameOutput() + { + // Arrange + var options = CreateOptions(enabled: true); + var content = Encoding.UTF8.GetBytes( + "AKIAIOSFODNN7EXAMPLE\n" + + "ghp_1234567890123456789012345678901234567890"); + + // Act - Run twice + var analyzer1 = CreateAnalyzer(options); + analyzer1.SetRuleset(_ruleset!); + var matches1 = await DetectAllSecretsAsync(analyzer1, content, "test.txt"); + + var analyzer2 = CreateAnalyzer(options); + analyzer2.SetRuleset(_ruleset!); + var matches2 = await DetectAllSecretsAsync(analyzer2, content, "test.txt"); + + // Assert - Results should be identical + matches1.Should().HaveCount(matches2.Count); + + for (int i = 0; i < matches1.Count; i++) + { + matches1[i].Rule.Id.Should().Be(matches2[i].Rule.Id); + matches1[i].LineNumber.Should().Be(matches2[i].LineNumber); + matches1[i].ColumnStart.Should().Be(matches2[i].ColumnStart); + } + } + + private SecretsAnalyzer CreateAnalyzer(SecretsAnalyzerOptions options) + { + var optionsWrapper = Options.Create(options); + var regexDetector = new RegexDetector(NullLogger.Instance); + var entropyDetector = new EntropyDetector(NullLogger.Instance); + var compositeDetector = new CompositeSecretDetector( + regexDetector, + entropyDetector, + NullLogger.Instance); + var masker = new PayloadMasker(); + + return new SecretsAnalyzer( + optionsWrapper, + compositeDetector, + masker, + NullLogger.Instance, + _timeProvider); + } + + private static SecretsAnalyzerOptions CreateOptions(bool enabled, int maxFindings = 1000) + { + return new SecretsAnalyzerOptions + { + Enabled = enabled, + MaxFindingsPerScan = maxFindings, + MinConfidence = SecretConfidence.Low, + EnableEntropyDetection = true, + EntropyThreshold = 4.5 + }; + } + + private async Task> DetectAllSecretsAsync( + SecretsAnalyzer analyzer, + byte[] content, + string filePath) + { + if (!analyzer.IsEnabled || analyzer.Ruleset is null) + { + return []; + } + + var allMatches = new List(); + var detector = new CompositeSecretDetector( + new RegexDetector(NullLogger.Instance), + new EntropyDetector(NullLogger.Instance), + NullLogger.Instance); + + foreach (var rule in analyzer.Ruleset.Rules.Where(r => r.Enabled)) + { + var matches = await detector.DetectAsync( + content.AsMemory(), + filePath, + rule, + TestContext.Current.CancellationToken); + allMatches.AddRange(matches); + } + + return allMatches; + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Alerts/SecretAlertEmitterTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Alerts/SecretAlertEmitterTests.cs new file mode 100644 index 000000000..d87bc0514 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Alerts/SecretAlertEmitterTests.cs @@ -0,0 +1,439 @@ +// ----------------------------------------------------------------------------- +// SecretAlertEmitterTests.cs +// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration) +// Task: SDA-009 - Add integration tests +// Description: Unit tests for SecretAlertEmitter. +// ----------------------------------------------------------------------------- + +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using StellaOps.Scanner.Core.Secrets.Alerts; +using StellaOps.Scanner.Core.Secrets.Configuration; +using Xunit; + +namespace StellaOps.Scanner.Core.Tests.Secrets.Alerts; + +/// +/// Unit tests for . +/// +[Trait("Category", "Unit")] +public sealed class SecretAlertEmitterTests +{ + private readonly Mock _routerMock = new(); + private readonly Mock _deduplicatorMock = new(); + private readonly Mock _channelSenderMock = new(); + private readonly Mock _settingsProviderMock = new(); + private readonly ILogger _logger = NullLogger.Instance; + + private static readonly Guid TestTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid TestScanId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly DateTimeOffset TestTimestamp = new(2026, 1, 4, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task EmitAsync_SkipsWhenAlertingDisabled() + { + // Arrange + var alert = CreateTestAlert(); + var settings = new SecretAlertSettings { Enabled = false }; + _settingsProviderMock + .Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny())) + .ReturnsAsync(settings); + + var emitter = CreateEmitter(); + + // Act + var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken); + + // Assert + result.WasEmitted.Should().BeFalse(); + result.SkipReason.Should().Be(AlertSkipReason.AlertingDisabled); + _channelSenderMock.Verify( + s => s.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task EmitAsync_SkipsWhenSettingsNull() + { + // Arrange + var alert = CreateTestAlert(); + _settingsProviderMock + .Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny())) + .ReturnsAsync((SecretAlertSettings?)null); + + var emitter = CreateEmitter(); + + // Act + var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken); + + // Assert + result.WasEmitted.Should().BeFalse(); + result.SkipReason.Should().Be(AlertSkipReason.AlertingDisabled); + } + + [Fact] + public async Task EmitAsync_SkipsWhenBelowSeverityThreshold() + { + // Arrange + var alert = CreateTestAlert(SecretSeverity.Low); + var settings = new SecretAlertSettings + { + Enabled = true, + MinimumAlertSeverity = SecretSeverity.High + }; + _settingsProviderMock + .Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny())) + .ReturnsAsync(settings); + _routerMock + .Setup(r => r.MeetsSeverityThreshold(SecretSeverity.Low, SecretSeverity.High)) + .Returns(false); + + var emitter = CreateEmitter(); + + // Act + var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken); + + // Assert + result.WasEmitted.Should().BeFalse(); + result.SkipReason.Should().Be(AlertSkipReason.BelowSeverityThreshold); + } + + [Fact] + public async Task EmitAsync_SkipsWhenNoMatchingDestinations() + { + // Arrange + var alert = CreateTestAlert(); + var settings = CreateEnabledSettings(); + _settingsProviderMock + .Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny())) + .ReturnsAsync(settings); + _routerMock + .Setup(r => r.MeetsSeverityThreshold(It.IsAny(), It.IsAny())) + .Returns(true); + _routerMock + .Setup(r => r.RouteAlert(alert, settings)) + .Returns([]); + + var emitter = CreateEmitter(); + + // Act + var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken); + + // Assert + result.WasEmitted.Should().BeFalse(); + result.SkipReason.Should().Be(AlertSkipReason.NoMatchingDestinations); + } + + [Fact] + public async Task EmitAsync_SkipsWhenRateLimitExceeded() + { + // Arrange + var alert = CreateTestAlert(); + var settings = CreateEnabledSettings(); + var destination = CreateDestination(); + + _settingsProviderMock + .Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny())) + .ReturnsAsync(settings); + _routerMock + .Setup(r => r.MeetsSeverityThreshold(It.IsAny(), It.IsAny())) + .Returns(true); + _routerMock + .Setup(r => r.RouteAlert(alert, settings)) + .Returns([destination]); + _deduplicatorMock + .Setup(d => d.IsUnderRateLimitAsync(TestScanId, settings.MaxAlertsPerScan, It.IsAny())) + .ReturnsAsync(false); + + var emitter = CreateEmitter(); + + // Act + var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken); + + // Assert + result.WasEmitted.Should().BeFalse(); + result.SkipReason.Should().Be(AlertSkipReason.RateLimitExceeded); + } + + [Fact] + public async Task EmitAsync_SkipsWhenDeduplicated() + { + // Arrange + var alert = CreateTestAlert(); + var settings = CreateEnabledSettings(); + var destination = CreateDestination(); + + _settingsProviderMock + .Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny())) + .ReturnsAsync(settings); + _routerMock + .Setup(r => r.MeetsSeverityThreshold(It.IsAny(), It.IsAny())) + .Returns(true); + _routerMock + .Setup(r => r.RouteAlert(alert, settings)) + .Returns([destination]); + _deduplicatorMock + .Setup(d => d.IsUnderRateLimitAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _deduplicatorMock + .Setup(d => d.ShouldAlertAsync(alert.DeduplicationKey, settings.DeduplicationWindow, It.IsAny())) + .ReturnsAsync(false); + + var emitter = CreateEmitter(); + + // Act + var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken); + + // Assert + result.WasEmitted.Should().BeFalse(); + result.SkipReason.Should().Be(AlertSkipReason.Deduplicated); + } + + [Fact] + public async Task EmitAsync_EmitsToAllDestinations() + { + // Arrange + var alert = CreateTestAlert(); + var settings = CreateEnabledSettings(); + var dest1 = CreateDestination("slack-channel"); + var dest2 = CreateDestination("webhook"); + + _settingsProviderMock + .Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny())) + .ReturnsAsync(settings); + _routerMock + .Setup(r => r.MeetsSeverityThreshold(It.IsAny(), It.IsAny())) + .Returns(true); + _routerMock + .Setup(r => r.RouteAlert(alert, settings)) + .Returns([dest1, dest2]); + _deduplicatorMock + .Setup(d => d.IsUnderRateLimitAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _deduplicatorMock + .Setup(d => d.ShouldAlertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var emitter = CreateEmitter(); + + // Act + var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken); + + // Assert + result.WasEmitted.Should().BeTrue(); + result.Channels.Should().HaveCount(2); + _channelSenderMock.Verify( + s => s.SendAsync(alert, dest1, settings, It.IsAny()), + Times.Once); + _channelSenderMock.Verify( + s => s.SendAsync(alert, dest2, settings, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task EmitAsync_RecordsDeduplicationAndRateLimit() + { + // Arrange + var alert = CreateTestAlert(); + var settings = CreateEnabledSettings(); + var destination = CreateDestination(); + + _settingsProviderMock + .Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny())) + .ReturnsAsync(settings); + _routerMock + .Setup(r => r.MeetsSeverityThreshold(It.IsAny(), It.IsAny())) + .Returns(true); + _routerMock + .Setup(r => r.RouteAlert(alert, settings)) + .Returns([destination]); + _deduplicatorMock + .Setup(d => d.IsUnderRateLimitAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _deduplicatorMock + .Setup(d => d.ShouldAlertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var emitter = CreateEmitter(); + + // Act + await emitter.EmitAsync(alert, TestContext.Current.CancellationToken); + + // Assert + _deduplicatorMock.Verify( + d => d.RecordAlertSentAsync(alert.DeduplicationKey, settings.DeduplicationWindow, It.IsAny()), + Times.Once); + _deduplicatorMock.Verify( + d => d.IncrementScanAlertCountAsync(alert.ScanId, It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task EmitAsync_ContinuesOnChannelSendFailure() + { + // Arrange + var alert = CreateTestAlert(); + var settings = CreateEnabledSettings(); + var dest1 = CreateDestination("failing"); + var dest2 = CreateDestination("working"); + + _settingsProviderMock + .Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny())) + .ReturnsAsync(settings); + _routerMock + .Setup(r => r.MeetsSeverityThreshold(It.IsAny(), It.IsAny())) + .Returns(true); + _routerMock + .Setup(r => r.RouteAlert(alert, settings)) + .Returns([dest1, dest2]); + _deduplicatorMock + .Setup(d => d.IsUnderRateLimitAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _deduplicatorMock + .Setup(d => d.ShouldAlertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _channelSenderMock + .Setup(s => s.SendAsync(alert, dest1, settings, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Channel failed")); + + var emitter = CreateEmitter(); + + // Act + var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken); + + // Assert - Should still succeed with partial delivery + result.WasEmitted.Should().BeTrue(); + result.Channels.Should().HaveCount(1); + result.Channels.Should().Contain(c => c.Contains("working")); + } + + [Fact] + public async Task EmitBatchAsync_ProcessesAllAlerts() + { + // Arrange + var alerts = new[] + { + CreateTestAlert(), + CreateTestAlert() with { EventId = Guid.NewGuid() }, + CreateTestAlert() with { EventId = Guid.NewGuid() } + }; + var settings = CreateEnabledSettings(); + var destination = CreateDestination(); + + _settingsProviderMock + .Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny())) + .ReturnsAsync(settings); + _routerMock + .Setup(r => r.MeetsSeverityThreshold(It.IsAny(), It.IsAny())) + .Returns(true); + _routerMock + .Setup(r => r.RouteAlert(It.IsAny(), settings)) + .Returns([destination]); + _deduplicatorMock + .Setup(d => d.IsUnderRateLimitAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _deduplicatorMock + .Setup(d => d.ShouldAlertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var emitter = CreateEmitter(); + + // Act + var results = await emitter.EmitBatchAsync(alerts, TestContext.Current.CancellationToken); + + // Assert + results.Should().HaveCount(3); + results.Should().AllSatisfy(r => r.WasEmitted.Should().BeTrue()); + } + + [Fact] + public async Task EmitBatchAsync_StopsOnRateLimit() + { + // Arrange + var alerts = new[] + { + CreateTestAlert(), + CreateTestAlert() with { EventId = Guid.NewGuid() }, + CreateTestAlert() with { EventId = Guid.NewGuid() } + }; + var settings = CreateEnabledSettings(); + var destination = CreateDestination(); + + _settingsProviderMock + .Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny())) + .ReturnsAsync(settings); + _routerMock + .Setup(r => r.MeetsSeverityThreshold(It.IsAny(), It.IsAny())) + .Returns(true); + _routerMock + .Setup(r => r.RouteAlert(It.IsAny(), settings)) + .Returns([destination]); + + // First call returns true, subsequent calls return false (rate limit hit) + var callCount = 0; + _deduplicatorMock + .Setup(d => d.IsUnderRateLimitAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => ++callCount <= 1); + _deduplicatorMock + .Setup(d => d.ShouldAlertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var emitter = CreateEmitter(); + + // Act + var results = await emitter.EmitBatchAsync(alerts, TestContext.Current.CancellationToken); + + // Assert + results.Should().HaveCount(3); + results[0].WasEmitted.Should().BeTrue(); + results[1].SkipReason.Should().Be(AlertSkipReason.RateLimitExceeded); + results[2].SkipReason.Should().Be(AlertSkipReason.RateLimitExceeded); + } + + #region Helpers + + private SecretAlertEmitter CreateEmitter() => new( + _routerMock.Object, + _deduplicatorMock.Object, + _channelSenderMock.Object, + _settingsProviderMock.Object, + _logger); + + private static SecretFindingAlertEvent CreateTestAlert(SecretSeverity severity = SecretSeverity.High) => new() + { + EventId = Guid.NewGuid(), + TenantId = TestTenantId, + ScanId = TestScanId, + ImageRef = "registry.example.com/app:v1.0.0", + Severity = severity, + RuleId = "test-rule-001", + RuleName = "Test Rule", + RuleCategory = "test_category", + FilePath = "/app/config.yaml", + LineNumber = 42, + MaskedValue = "****masked****", + DetectedAt = TestTimestamp, + ScanTriggeredBy = "test-user" + }; + + private static SecretAlertSettings CreateEnabledSettings() => new() + { + Enabled = true, + MinimumAlertSeverity = SecretSeverity.Low, + MaxAlertsPerScan = 10, + DeduplicationWindow = TimeSpan.FromHours(24), + Destinations = [] + }; + + private static SecretAlertDestination CreateDestination(string name = "test") => new() + { + Id = Guid.NewGuid(), + Name = name, + ChannelType = AlertChannelType.Webhook, + ChannelId = "https://webhook.example.com/alert" + }; + + #endregion +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Alerts/SecretAlertRouterTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Alerts/SecretAlertRouterTests.cs new file mode 100644 index 000000000..1f771b1f1 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Alerts/SecretAlertRouterTests.cs @@ -0,0 +1,306 @@ +// ----------------------------------------------------------------------------- +// SecretAlertRouterTests.cs +// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration) +// Task: SDA-009 - Add integration tests +// Description: Unit tests for SecretAlertRouter. +// ----------------------------------------------------------------------------- + +using FluentAssertions; +using StellaOps.Scanner.Core.Secrets.Alerts; +using StellaOps.Scanner.Core.Secrets.Configuration; +using Xunit; + +namespace StellaOps.Scanner.Core.Tests.Secrets.Alerts; + +/// +/// Unit tests for . +/// +[Trait("Category", "Unit")] +public sealed class SecretAlertRouterTests +{ + private readonly SecretAlertRouter _router = new(); + + private static readonly Guid TestTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly DateTimeOffset TestTimestamp = new(2026, 1, 4, 12, 0, 0, TimeSpan.Zero); + + #region MeetsSeverityThreshold Tests + + [Theory] + [InlineData(SecretSeverity.Critical, SecretSeverity.Low, true)] + [InlineData(SecretSeverity.Critical, SecretSeverity.Medium, true)] + [InlineData(SecretSeverity.Critical, SecretSeverity.High, true)] + [InlineData(SecretSeverity.Critical, SecretSeverity.Critical, true)] + [InlineData(SecretSeverity.High, SecretSeverity.Low, true)] + [InlineData(SecretSeverity.High, SecretSeverity.Medium, true)] + [InlineData(SecretSeverity.High, SecretSeverity.High, true)] + [InlineData(SecretSeverity.High, SecretSeverity.Critical, false)] + [InlineData(SecretSeverity.Medium, SecretSeverity.Low, true)] + [InlineData(SecretSeverity.Medium, SecretSeverity.Medium, true)] + [InlineData(SecretSeverity.Medium, SecretSeverity.High, false)] + [InlineData(SecretSeverity.Low, SecretSeverity.Low, true)] + [InlineData(SecretSeverity.Low, SecretSeverity.Medium, false)] + public void MeetsSeverityThreshold_CorrectlyComparesSeverities( + SecretSeverity findingSeverity, + SecretSeverity minimumSeverity, + bool expected) + { + // Act + var result = _router.MeetsSeverityThreshold(findingSeverity, minimumSeverity); + + // Assert + result.Should().Be(expected); + } + + #endregion + + #region RouteAlert Tests + + [Fact] + public void RouteAlert_ReturnsEmpty_WhenAlertingDisabled() + { + // Arrange + var alert = CreateTestAlert(SecretSeverity.Critical); + var settings = new SecretAlertSettings + { + Enabled = false, + Destinations = [CreateDestination(Guid.NewGuid())] + }; + + // Act + var destinations = _router.RouteAlert(alert, settings); + + // Assert + destinations.Should().BeEmpty(); + } + + [Fact] + public void RouteAlert_ReturnsEmpty_WhenBelowSeverityThreshold() + { + // Arrange + var alert = CreateTestAlert(SecretSeverity.Low); + var settings = new SecretAlertSettings + { + Enabled = true, + MinimumAlertSeverity = SecretSeverity.High, + Destinations = [CreateDestination(Guid.NewGuid())] + }; + + // Act + var destinations = _router.RouteAlert(alert, settings); + + // Assert + destinations.Should().BeEmpty(); + } + + [Fact] + public void RouteAlert_ReturnsAllMatchingDestinations_WhenNoFilters() + { + // Arrange + var dest1 = CreateDestination(Guid.NewGuid(), "slack-security"); + var dest2 = CreateDestination(Guid.NewGuid(), "teams-devops"); + var alert = CreateTestAlert(SecretSeverity.High); + var settings = new SecretAlertSettings + { + Enabled = true, + MinimumAlertSeverity = SecretSeverity.Medium, + Destinations = [dest1, dest2] + }; + + // Act + var destinations = _router.RouteAlert(alert, settings); + + // Assert + destinations.Should().HaveCount(2); + destinations.Should().Contain(dest1); + destinations.Should().Contain(dest2); + } + + [Fact] + public void RouteAlert_FiltersDestinationsBySeverity() + { + // Arrange + var criticalOnly = CreateDestination( + Guid.NewGuid(), + "pagerduty", + severityFilter: [SecretSeverity.Critical]); + var highAndAbove = CreateDestination( + Guid.NewGuid(), + "slack-security", + severityFilter: [SecretSeverity.Critical, SecretSeverity.High]); + var all = CreateDestination(Guid.NewGuid(), "webhook"); + + var alert = CreateTestAlert(SecretSeverity.High); + var settings = new SecretAlertSettings + { + Enabled = true, + MinimumAlertSeverity = SecretSeverity.Low, + Destinations = [criticalOnly, highAndAbove, all] + }; + + // Act + var destinations = _router.RouteAlert(alert, settings); + + // Assert + destinations.Should().HaveCount(2); + destinations.Should().NotContain(criticalOnly); + destinations.Should().Contain(highAndAbove); + destinations.Should().Contain(all); + } + + [Fact] + public void RouteAlert_FiltersDestinationsByCategory() + { + // Arrange + var cloudOnly = CreateDestination( + Guid.NewGuid(), + "slack-cloud", + categoryFilter: ["cloud_credentials"]); + var apiKeysOnly = CreateDestination( + Guid.NewGuid(), + "teams-api", + categoryFilter: ["api_keys"]); + var all = CreateDestination(Guid.NewGuid(), "webhook"); + + var alert = CreateTestAlert(SecretSeverity.High) with { RuleCategory = "cloud_credentials" }; + var settings = new SecretAlertSettings + { + Enabled = true, + MinimumAlertSeverity = SecretSeverity.Low, + Destinations = [cloudOnly, apiKeysOnly, all] + }; + + // Act + var destinations = _router.RouteAlert(alert, settings); + + // Assert + destinations.Should().HaveCount(2); + destinations.Should().Contain(cloudOnly); + destinations.Should().NotContain(apiKeysOnly); + destinations.Should().Contain(all); + } + + [Fact] + public void RouteAlert_CombinesSeverityAndCategoryFilters() + { + // Arrange + var criticalCloud = CreateDestination( + Guid.NewGuid(), + "pagerduty", + severityFilter: [SecretSeverity.Critical], + categoryFilter: ["cloud_credentials"]); + var highCloud = CreateDestination( + Guid.NewGuid(), + "slack", + severityFilter: [SecretSeverity.High, SecretSeverity.Critical], + categoryFilter: ["cloud_credentials", "api_keys"]); + + // Alert is High + cloud_credentials + var alert = CreateTestAlert(SecretSeverity.High) with { RuleCategory = "cloud_credentials" }; + var settings = new SecretAlertSettings + { + Enabled = true, + MinimumAlertSeverity = SecretSeverity.Low, + Destinations = [criticalCloud, highCloud] + }; + + // Act + var destinations = _router.RouteAlert(alert, settings); + + // Assert - criticalCloud excluded (severity), highCloud included + destinations.Should().HaveCount(1); + destinations.Should().Contain(highCloud); + } + + [Fact] + public void RouteAlert_CategoryFilterIsCaseInsensitive() + { + // Arrange + var dest = CreateDestination( + Guid.NewGuid(), + "slack", + categoryFilter: ["CLOUD_CREDENTIALS"]); + + var alert = CreateTestAlert(SecretSeverity.High) with { RuleCategory = "cloud_credentials" }; + var settings = new SecretAlertSettings + { + Enabled = true, + MinimumAlertSeverity = SecretSeverity.Low, + Destinations = [dest] + }; + + // Act + var destinations = _router.RouteAlert(alert, settings); + + // Assert + destinations.Should().HaveCount(1); + } + + #endregion + + #region AlertPriority Extension Tests + + [Theory] + [InlineData(SecretSeverity.Critical, AlertPriority.P1Immediate)] + [InlineData(SecretSeverity.High, AlertPriority.P2Urgent)] + [InlineData(SecretSeverity.Medium, AlertPriority.P3Normal)] + [InlineData(SecretSeverity.Low, AlertPriority.P4Info)] + public void GetDefaultPriority_MapsCorrectly(SecretSeverity severity, AlertPriority expected) + { + // Act + var priority = severity.GetDefaultPriority(); + + // Assert + priority.Should().Be(expected); + } + + [Theory] + [InlineData(SecretSeverity.Critical, true)] + [InlineData(SecretSeverity.High, false)] + [InlineData(SecretSeverity.Medium, false)] + [InlineData(SecretSeverity.Low, false)] + public void ShouldPage_OnlyCritical(SecretSeverity severity, bool expected) + { + // Act + var shouldPage = severity.ShouldPage(); + + // Assert + shouldPage.Should().Be(expected); + } + + #endregion + + #region Helpers + + private static SecretFindingAlertEvent CreateTestAlert(SecretSeverity severity) => new() + { + EventId = Guid.NewGuid(), + TenantId = TestTenantId, + ScanId = Guid.NewGuid(), + ImageRef = "registry.example.com/app:v1.0.0", + Severity = severity, + RuleId = "test-rule-001", + RuleName = "Test Rule", + RuleCategory = "test_category", + FilePath = "/app/config.yaml", + LineNumber = 42, + MaskedValue = "****masked****", + DetectedAt = TestTimestamp, + ScanTriggeredBy = "test-user" + }; + + private static SecretAlertDestination CreateDestination( + Guid id, + string name = "test-destination", + IReadOnlyList? severityFilter = null, + IReadOnlyList? categoryFilter = null) => new() + { + Id = id, + Name = name, + ChannelType = AlertChannelType.Webhook, + ChannelId = "https://webhook.example.com/alert", + SeverityFilter = severityFilter, + RuleCategoryFilter = categoryFilter + }; + + #endregion +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Alerts/SecretFindingAlertEventTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Alerts/SecretFindingAlertEventTests.cs new file mode 100644 index 000000000..3476d82e8 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Alerts/SecretFindingAlertEventTests.cs @@ -0,0 +1,215 @@ +// ----------------------------------------------------------------------------- +// SecretFindingAlertEventTests.cs +// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration) +// Task: SDA-009 - Add integration tests +// Description: Unit tests for SecretFindingAlertEvent. +// ----------------------------------------------------------------------------- + +using FluentAssertions; +using StellaOps.Scanner.Core.Secrets.Alerts; +using StellaOps.Scanner.Core.Secrets.Configuration; +using Xunit; + +namespace StellaOps.Scanner.Core.Tests.Secrets.Alerts; + +/// +/// Unit tests for . +/// +[Trait("Category", "Unit")] +public sealed class SecretFindingAlertEventTests +{ + private static readonly Guid TestTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid TestScanId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Guid TestEventId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly DateTimeOffset TestTimestamp = new(2026, 1, 4, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void DeduplicationKey_IsDeterministic() + { + // Arrange + var alert = CreateTestAlert(); + + // Act + var key1 = alert.DeduplicationKey; + var key2 = alert.DeduplicationKey; + + // Assert + key1.Should().Be(key2); + key1.Should().Contain(TestTenantId.ToString()); + key1.Should().Contain("test-rule-001"); + key1.Should().Contain("/app/config.yaml"); + key1.Should().Contain("42"); + } + + [Fact] + public void DeduplicationKey_DiffersBySameFieldCombination() + { + // Arrange + var alert1 = CreateTestAlert(); + var alert2 = CreateTestAlert() with { FilePath = "/different/path.txt" }; + var alert3 = CreateTestAlert() with { RuleId = "different-rule" }; + var alert4 = CreateTestAlert() with { LineNumber = 100 }; + + // Act & Assert + alert1.DeduplicationKey.Should().NotBe(alert2.DeduplicationKey); + alert1.DeduplicationKey.Should().NotBe(alert3.DeduplicationKey); + alert1.DeduplicationKey.Should().NotBe(alert4.DeduplicationKey); + } + + [Fact] + public void DeduplicationKeyWithImage_IncludesImageRef() + { + // Arrange + var alert = CreateTestAlert(); + + // Act + var key = alert.DeduplicationKeyWithImage; + + // Assert + key.Should().Contain("registry.example.com/app:v1.0.0"); + key.Should().Contain(TestTenantId.ToString()); + } + + [Fact] + public void DeduplicationKeyWithImage_DiffersByImage() + { + // Arrange + var alert1 = CreateTestAlert(); + var alert2 = CreateTestAlert() with { ImageRef = "registry.example.com/app:v2.0.0" }; + + // Act & Assert + alert1.DeduplicationKeyWithImage.Should().NotBe(alert2.DeduplicationKeyWithImage); + // But standard key should be the same (doesn't include image) + alert1.DeduplicationKey.Should().Be(alert2.DeduplicationKey); + } + + [Fact] + public void Create_FromFindingInfo_SetsAllRequiredFields() + { + // Arrange + var finding = new SecretFindingInfo + { + Severity = SecretSeverity.High, + RuleId = "aws-access-key", + RuleName = "AWS Access Key ID", + RuleCategory = "cloud_credentials", + FilePath = "/app/secrets.env", + LineNumber = 15, + ImageDigest = "sha256:abc123", + RemediationGuidance = "Rotate the AWS access key immediately" + }; + + // Act + var alert = SecretFindingAlertEvent.Create( + tenantId: TestTenantId, + scanId: TestScanId, + imageRef: "myregistry.io/myapp:latest", + finding: finding, + maskedValue: "AKIA****WXYZ", + scanTriggeredBy: "ci-pipeline", + eventId: TestEventId, + detectedAt: TestTimestamp); + + // Assert + alert.EventId.Should().Be(TestEventId); + alert.TenantId.Should().Be(TestTenantId); + alert.ScanId.Should().Be(TestScanId); + alert.ImageRef.Should().Be("myregistry.io/myapp:latest"); + alert.Severity.Should().Be(SecretSeverity.High); + alert.RuleId.Should().Be("aws-access-key"); + alert.RuleName.Should().Be("AWS Access Key ID"); + alert.RuleCategory.Should().Be("cloud_credentials"); + alert.FilePath.Should().Be("/app/secrets.env"); + alert.LineNumber.Should().Be(15); + alert.MaskedValue.Should().Be("AKIA****WXYZ"); + alert.DetectedAt.Should().Be(TestTimestamp); + alert.ScanTriggeredBy.Should().Be("ci-pipeline"); + alert.ImageDigest.Should().Be("sha256:abc123"); + alert.RemediationGuidance.Should().Be("Rotate the AWS access key immediately"); + alert.FindingUrl.Should().BeNull(); + } + + [Fact] + public void Create_ThrowsOnNullImageRef() + { + // Arrange + var finding = CreateTestFindingInfo(); + + // Act & Assert + var act = () => SecretFindingAlertEvent.Create( + tenantId: TestTenantId, + scanId: TestScanId, + imageRef: null!, + finding: finding, + maskedValue: "masked", + scanTriggeredBy: "user", + eventId: TestEventId, + detectedAt: TestTimestamp); + + act.Should().Throw(); + } + + [Fact] + public void Create_ThrowsOnNullFinding() + { + // Act & Assert + var act = () => SecretFindingAlertEvent.Create( + tenantId: TestTenantId, + scanId: TestScanId, + imageRef: "registry/app:v1", + finding: null!, + maskedValue: "masked", + scanTriggeredBy: "user", + eventId: TestEventId, + detectedAt: TestTimestamp); + + act.Should().Throw(); + } + + [Fact] + public void Create_ThrowsOnEmptyMaskedValue() + { + // Arrange + var finding = CreateTestFindingInfo(); + + // Act & Assert + var act = () => SecretFindingAlertEvent.Create( + tenantId: TestTenantId, + scanId: TestScanId, + imageRef: "registry/app:v1", + finding: finding, + maskedValue: "", + scanTriggeredBy: "user", + eventId: TestEventId, + detectedAt: TestTimestamp); + + act.Should().Throw(); + } + + private static SecretFindingAlertEvent CreateTestAlert() => new() + { + EventId = TestEventId, + TenantId = TestTenantId, + ScanId = TestScanId, + ImageRef = "registry.example.com/app:v1.0.0", + Severity = SecretSeverity.High, + RuleId = "test-rule-001", + RuleName = "Test Rule", + RuleCategory = "test_category", + FilePath = "/app/config.yaml", + LineNumber = 42, + MaskedValue = "****masked****", + DetectedAt = TestTimestamp, + ScanTriggeredBy = "test-user" + }; + + private static SecretFindingInfo CreateTestFindingInfo() => new() + { + Severity = SecretSeverity.Medium, + RuleId = "test-rule", + RuleName = "Test Rule", + RuleCategory = "test", + FilePath = "/test/file.txt", + LineNumber = 1 + }; +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Configuration/RevelationPolicyConfigTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Configuration/RevelationPolicyConfigTests.cs new file mode 100644 index 000000000..78cbf59d2 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Configuration/RevelationPolicyConfigTests.cs @@ -0,0 +1,181 @@ +// ----------------------------------------------------------------------------- +// RevelationPolicyConfigTests.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-009 - Add unit and integration tests +// Description: Tests for RevelationPolicyConfig validation. +// ----------------------------------------------------------------------------- + +using FluentAssertions; +using StellaOps.Scanner.Core.Secrets.Configuration; +using Xunit; + +namespace StellaOps.Scanner.Core.Tests.Secrets.Configuration; + +/// +/// Tests for . +/// +[Trait("Category", "Unit")] +public sealed class RevelationPolicyConfigTests +{ + [Fact] + public void Default_HasSecureDefaults() + { + // Arrange & Act + var config = RevelationPolicyConfig.Default; + + // Assert + config.DefaultPolicy.Should().Be(SecretRevelationPolicy.PartialReveal); + config.ExportPolicy.Should().Be(SecretRevelationPolicy.FullMask); + config.LogPolicy.Should().Be(SecretRevelationPolicy.FullMask); + config.PartialRevealChars.Should().Be(4); + config.MaxMaskChars.Should().Be(8); + } + + [Fact] + public void Default_RequiresSecurityAdminForFullReveal() + { + // Arrange & Act + var config = RevelationPolicyConfig.Default; + + // Assert + config.FullRevealRoles.Should().Contain("security-admin"); + config.FullRevealRoles.Should().Contain("incident-responder"); + } + + [Fact] + public void Validate_ValidConfig_ReturnsEmptyErrorList() + { + // Arrange + var config = new RevelationPolicyConfig + { + DefaultPolicy = SecretRevelationPolicy.PartialReveal, + PartialRevealChars = 4, + MaxMaskChars = 8, + FullRevealRoles = ["admin"] + }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().BeEmpty(); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(11)] + public void Validate_InvalidPartialRevealChars_ReturnsError(int chars) + { + // Arrange + var config = new RevelationPolicyConfig { PartialRevealChars = chars }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("PartialRevealChars", StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(21)] + public void Validate_InvalidMaxMaskChars_ReturnsError(int chars) + { + // Arrange + var config = new RevelationPolicyConfig { MaxMaskChars = chars }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("MaxMaskChars", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_FullRevealWithNoRoles_ReturnsError() + { + // Arrange + var config = new RevelationPolicyConfig + { + DefaultPolicy = SecretRevelationPolicy.FullReveal, + FullRevealRoles = [] + }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("FullRevealRoles", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_PartialRevealWithNoRoles_ReturnsNoError() + { + // Arrange + var config = new RevelationPolicyConfig + { + DefaultPolicy = SecretRevelationPolicy.PartialReveal, + PartialRevealChars = 4, + MaxMaskChars = 8, + FullRevealRoles = [] + }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().BeEmpty(); + } + + [Theory] + [InlineData(1)] + [InlineData(5)] + [InlineData(10)] + public void Validate_ValidPartialRevealChars_ReturnsNoError(int chars) + { + // Arrange + var config = new RevelationPolicyConfig + { + PartialRevealChars = chars, + MaxMaskChars = 8 + }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().BeEmpty(); + } + + [Theory] + [InlineData(1)] + [InlineData(10)] + [InlineData(20)] + public void Validate_ValidMaxMaskChars_ReturnsNoError(int chars) + { + // Arrange + var config = new RevelationPolicyConfig + { + PartialRevealChars = 4, + MaxMaskChars = chars + }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().BeEmpty(); + } + + [Fact] + public void DefaultRequireExplicitReveal_IsFalse() + { + // Arrange & Act + var config = new RevelationPolicyConfig(); + + // Assert + config.RequireExplicitReveal.Should().BeFalse(); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Configuration/SecretDetectionSettingsTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Configuration/SecretDetectionSettingsTests.cs new file mode 100644 index 000000000..4b9108e72 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Configuration/SecretDetectionSettingsTests.cs @@ -0,0 +1,179 @@ +// ----------------------------------------------------------------------------- +// SecretDetectionSettingsTests.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-009 - Add unit and integration tests +// Description: Tests for SecretDetectionSettings validation and defaults. +// ----------------------------------------------------------------------------- + +using FluentAssertions; +using StellaOps.Scanner.Core.Secrets.Configuration; +using Xunit; + +namespace StellaOps.Scanner.Core.Tests.Secrets.Configuration; + +/// +/// Tests for . +/// +[Trait("Category", "Unit")] +public sealed class SecretDetectionSettingsTests +{ + private static readonly DateTimeOffset FixedTime = new(2026, 1, 4, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void Defaults_AreSecure() + { + // Arrange & Act + var settings = new SecretDetectionSettings + { + TenantId = Guid.NewGuid(), + UpdatedAt = FixedTime, + UpdatedBy = "test-user" + }; + + // Assert + settings.Enabled.Should().BeFalse("secret detection should be opt-in"); + settings.RequireSignedRuleBundles.Should().BeTrue("bundles must be signed by default"); + settings.ScanBinaryFiles.Should().BeFalse("binary scanning should be opt-in"); + settings.MaxFileSizeBytes.Should().Be(10 * 1024 * 1024, "10 MB limit expected"); + } + + [Fact] + public void Defaults_ExcludeCommonBinaryExtensions() + { + // Arrange & Act + var settings = new SecretDetectionSettings + { + TenantId = Guid.NewGuid(), + UpdatedAt = FixedTime, + UpdatedBy = "test-user" + }; + + // Assert + settings.ExcludedFileExtensions.Should().Contain(".exe"); + settings.ExcludedFileExtensions.Should().Contain(".dll"); + settings.ExcludedFileExtensions.Should().Contain(".png"); + settings.ExcludedFileExtensions.Should().Contain(".woff"); + } + + [Fact] + public void Defaults_ExcludeNodeModulesAndVendor() + { + // Arrange & Act + var settings = new SecretDetectionSettings + { + TenantId = Guid.NewGuid(), + UpdatedAt = FixedTime, + UpdatedBy = "test-user" + }; + + // Assert + settings.ExcludedPaths.Should().Contain("**/node_modules/**"); + settings.ExcludedPaths.Should().Contain("**/vendor/**"); + settings.ExcludedPaths.Should().Contain("**/.git/**"); + } + + [Fact] + public void Validate_ValidSettings_ReturnsEmptyErrorList() + { + // Arrange + var settings = new SecretDetectionSettings + { + TenantId = Guid.NewGuid(), + Enabled = true, + UpdatedAt = FixedTime, + UpdatedBy = "test-user" + }; + + // Act + var errors = settings.Validate(); + + // Assert + errors.Should().BeEmpty(); + } + + [Fact] + public void Validate_NegativeMaxFileSize_ReturnsError() + { + // Arrange + var settings = new SecretDetectionSettings + { + TenantId = Guid.NewGuid(), + MaxFileSizeBytes = -1, + UpdatedAt = FixedTime, + UpdatedBy = "test-user" + }; + + // Act + var errors = settings.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("MaxFileSizeBytes", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_ZeroMaxFileSize_ReturnsError() + { + // Arrange + var settings = new SecretDetectionSettings + { + TenantId = Guid.NewGuid(), + MaxFileSizeBytes = 0, + UpdatedAt = FixedTime, + UpdatedBy = "test-user" + }; + + // Act + var errors = settings.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("MaxFileSizeBytes", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Version_DefaultsToOne() + { + // Arrange & Act + var settings = new SecretDetectionSettings + { + TenantId = Guid.NewGuid(), + UpdatedAt = FixedTime, + UpdatedBy = "test-user" + }; + + // Assert + settings.Version.Should().Be(1); + } + + [Fact] + public void DefaultRevelationPolicy_UsesPartialReveal() + { + // Arrange & Act + var settings = new SecretDetectionSettings + { + TenantId = Guid.NewGuid(), + UpdatedAt = FixedTime, + UpdatedBy = "test-user" + }; + + // Assert + settings.RevelationPolicy.DefaultPolicy.Should().Be(SecretRevelationPolicy.PartialReveal); + settings.RevelationPolicy.ExportPolicy.Should().Be(SecretRevelationPolicy.FullMask); + settings.RevelationPolicy.LogPolicy.Should().Be(SecretRevelationPolicy.FullMask); + } + + [Fact] + public void DefaultAlertSettings_AreDisabled() + { + // Arrange & Act + var settings = new SecretDetectionSettings + { + TenantId = Guid.NewGuid(), + UpdatedAt = FixedTime, + UpdatedBy = "test-user" + }; + + // Assert + settings.AlertSettings.Enabled.Should().BeFalse(); + settings.AlertSettings.MinimumAlertSeverity.Should().Be(SecretSeverity.High); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Configuration/SecretExceptionMatcherTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Configuration/SecretExceptionMatcherTests.cs new file mode 100644 index 000000000..9985c0c72 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Configuration/SecretExceptionMatcherTests.cs @@ -0,0 +1,222 @@ +// ----------------------------------------------------------------------------- +// SecretExceptionMatcherTests.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-009 - Add unit and integration tests +// Description: Tests for SecretExceptionMatcher functionality. +// ----------------------------------------------------------------------------- + +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using StellaOps.Scanner.Core.Secrets.Configuration; +using Xunit; + +namespace StellaOps.Scanner.Core.Tests.Secrets.Configuration; + +/// +/// Tests for . +/// +[Trait("Category", "Unit")] +public sealed class SecretExceptionMatcherTests +{ + private static readonly DateTimeOffset FixedTime = new(2026, 1, 4, 12, 0, 0, TimeSpan.Zero); + private readonly FakeTimeProvider _timeProvider = new(FixedTime); + + [Fact] + public void Empty_MatchesNothing() + { + // Arrange + var matcher = SecretExceptionMatcher.Empty; + + // Act + var result = matcher.Match("AKIAIOSFODNN7EXAMPLE", "stellaops.secrets.aws", "/config/secrets.txt"); + + // Assert + result.IsExcepted.Should().BeFalse(); + } + + [Fact] + public void Match_SimpleValuePattern_ReturnsExcepted() + { + // Arrange + var pattern = CreatePattern(valuePattern: @"^AKIA[A-Z0-9]{16}$"); + var matcher = new SecretExceptionMatcher([pattern], _timeProvider); + + // Act + var result = matcher.Match("AKIAIOSFODNN7EXAMPLE", "stellaops.secrets.aws", "/config/test.txt"); + + // Assert + result.IsExcepted.Should().BeTrue(); + } + + [Fact] + public void Match_NonMatchingPattern_ReturnsNotExcepted() + { + // Arrange + var pattern = CreatePattern(valuePattern: @"^test_\d+$"); + var matcher = new SecretExceptionMatcher([pattern], _timeProvider); + + // Act + var result = matcher.Match("AKIAIOSFODNN7EXAMPLE", "stellaops.secrets.aws", "/config/test.txt"); + + // Assert + result.IsExcepted.Should().BeFalse(); + } + + [Fact] + public void Match_ExpiredPattern_ReturnsNotExcepted() + { + // Arrange + var pattern = CreatePattern( + valuePattern: @".*", + expiresAt: FixedTime.AddDays(-1)); + var matcher = new SecretExceptionMatcher([pattern], _timeProvider); + + // Act + var result = matcher.Match("any-value", "any-rule", "/any/path.txt"); + + // Assert + result.IsExcepted.Should().BeFalse(); + } + + [Fact] + public void Match_InactivePattern_ReturnsNotExcepted() + { + // Arrange + var pattern = CreatePattern(valuePattern: @".*") with { IsActive = false }; + var matcher = new SecretExceptionMatcher([pattern], _timeProvider); + + // Act + var result = matcher.Match("any-value", "any-rule", "/any/path.txt"); + + // Assert + result.IsExcepted.Should().BeFalse(); + } + + [Fact] + public void Match_WithApplicableRuleIds_MatchesSpecificRules() + { + // Arrange + var pattern = CreatePattern( + valuePattern: @".*", + applicableRuleIds: ["stellaops.secrets.aws-access-key"]); + var matcher = new SecretExceptionMatcher([pattern], _timeProvider); + + // Act + var awsResult = matcher.Match("AKIAIOSFODNN7EXAMPLE", "stellaops.secrets.aws-access-key", "/test.txt"); + var githubResult = matcher.Match("ghp_1234", "stellaops.secrets.github-pat", "/test.txt"); + + // Assert + awsResult.IsExcepted.Should().BeTrue(); + githubResult.IsExcepted.Should().BeFalse(); + } + + [Fact] + public void Match_WithFilePathGlob_MatchesSpecificPaths() + { + // Arrange + var pattern = CreatePattern( + valuePattern: @".*", + filePathGlob: "**/test/**"); + var matcher = new SecretExceptionMatcher([pattern], _timeProvider); + + // Act + var testResult = matcher.Match("secret", "any-rule", "/project/test/fixtures/data.txt"); + var srcResult = matcher.Match("secret", "any-rule", "/project/src/config.txt"); + + // Assert + testResult.IsExcepted.Should().BeTrue(); + srcResult.IsExcepted.Should().BeFalse(); + } + + [Fact] + public void Match_FirstMatchingPatternWins() + { + // Arrange + var pattern1 = CreatePattern(valuePattern: @"^AKIA.*"); + var pattern2 = CreatePattern(valuePattern: @"^ASIA.*"); + var matcher = new SecretExceptionMatcher([pattern1, pattern2], _timeProvider); + + // Act + var akiaResult = matcher.Match("AKIAIOSFODNN7EXAMPLE", "any-rule", "/test.txt"); + var asiaResult = matcher.Match("ASIAXYZ123456789000", "any-rule", "/test.txt"); + + // Assert + akiaResult.IsExcepted.Should().BeTrue(); + asiaResult.IsExcepted.Should().BeTrue(); + } + + [Fact] + public void Match_BothRuleAndPathMustMatch() + { + // Arrange + var pattern = CreatePattern( + valuePattern: @".*", + applicableRuleIds: ["stellaops.secrets.aws-access-key"], + filePathGlob: "**/test/**"); + var matcher = new SecretExceptionMatcher([pattern], _timeProvider); + + // Act - matches rule but not path + var wrongPath = matcher.Match("secret", "stellaops.secrets.aws-access-key", "/src/config.txt"); + // Act - matches path but not rule + var wrongRule = matcher.Match("secret", "stellaops.secrets.github-pat", "/test/data.txt"); + // Act - matches both + var bothMatch = matcher.Match("secret", "stellaops.secrets.aws-access-key", "/test/data.txt"); + + // Assert + wrongPath.IsExcepted.Should().BeFalse(); + wrongRule.IsExcepted.Should().BeFalse(); + bothMatch.IsExcepted.Should().BeTrue(); + } + + [Fact] + public void Match_ReturnsMatchedException() + { + // Arrange + var pattern = CreatePattern(valuePattern: @"^AKIA.*"); + var matcher = new SecretExceptionMatcher([pattern], _timeProvider); + + // Act + var result = matcher.Match("AKIAIOSFODNN7EXAMPLE", "any-rule", "/test.txt"); + + // Assert + result.IsExcepted.Should().BeTrue(); + result.MatchedException.Should().NotBeNull(); + result.MatchedException!.Id.Should().Be(pattern.Id); + } + + [Fact] + public void Match_NoMatch_ReturnsNullMatchedException() + { + // Arrange + var pattern = CreatePattern(valuePattern: @"^AKIA.*"); + var matcher = new SecretExceptionMatcher([pattern], _timeProvider); + + // Act + var result = matcher.Match("ghp_1234", "any-rule", "/test.txt"); + + // Assert + result.IsExcepted.Should().BeFalse(); + result.MatchedException.Should().BeNull(); + } + + private static SecretExceptionPattern CreatePattern( + string valuePattern = @".*", + DateTimeOffset? expiresAt = null, + IReadOnlyList? applicableRuleIds = null, + string? filePathGlob = null) + { + return new SecretExceptionPattern + { + Id = Guid.NewGuid(), + Name = "Test Exception", + Description = "Test exception for unit tests", + ValuePattern = valuePattern, + ApplicableRuleIds = applicableRuleIds ?? [], + FilePathGlob = filePathGlob, + Justification = "Required for testing", + ExpiresAt = expiresAt, + CreatedAt = FixedTime.AddDays(-7), + CreatedBy = "test-user" + }; + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Configuration/SecretExceptionPatternTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Configuration/SecretExceptionPatternTests.cs new file mode 100644 index 000000000..f02106b25 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Configuration/SecretExceptionPatternTests.cs @@ -0,0 +1,185 @@ +// ----------------------------------------------------------------------------- +// SecretExceptionPatternTests.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-009 - Add unit and integration tests +// Description: Tests for SecretExceptionPattern model. +// ----------------------------------------------------------------------------- + +using FluentAssertions; +using StellaOps.Scanner.Core.Secrets.Configuration; +using Xunit; + +namespace StellaOps.Scanner.Core.Tests.Secrets.Configuration; + +/// +/// Tests for . +/// +[Trait("Category", "Unit")] +public sealed class SecretExceptionPatternTests +{ + private static readonly DateTimeOffset FixedTime = new(2026, 1, 4, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void IsExpired_NoExpiration_ReturnsFalse() + { + // Arrange + var pattern = CreatePattern(expiresAt: null); + + // Act & Assert + pattern.IsExpired(FixedTime).Should().BeFalse(); + } + + [Fact] + public void IsExpired_FutureExpiration_ReturnsFalse() + { + // Arrange + var pattern = CreatePattern(expiresAt: FixedTime.AddDays(30)); + + // Act & Assert + pattern.IsExpired(FixedTime).Should().BeFalse(); + } + + [Fact] + public void IsExpired_PastExpiration_ReturnsTrue() + { + // Arrange + var pattern = CreatePattern(expiresAt: FixedTime.AddDays(-1)); + + // Act & Assert + pattern.IsExpired(FixedTime).Should().BeTrue(); + } + + [Fact] + public void IsExpired_ExactExpiration_ReturnsTrue() + { + // Arrange + var pattern = CreatePattern(expiresAt: FixedTime); + + // Act & Assert + pattern.IsExpired(FixedTime.AddMilliseconds(1)).Should().BeTrue(); + } + + [Fact] + public void Validate_ValidPattern_ReturnsEmptyErrorList() + { + // Arrange + var pattern = CreatePattern(); + + // Act + var errors = pattern.Validate(); + + // Assert + errors.Should().BeEmpty(); + } + + [Fact] + public void Validate_EmptyName_ReturnsError() + { + // Arrange + var pattern = CreatePattern() with { Name = "" }; + + // Act + var errors = pattern.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("Name", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_EmptyValuePattern_ReturnsError() + { + // Arrange + var pattern = CreatePattern() with { ValuePattern = "" }; + + // Act + var errors = pattern.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("ValuePattern", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_InvalidRegexPattern_ReturnsError() + { + // Arrange + var pattern = CreatePattern() with { ValuePattern = "[invalid(" }; + + // Act + var errors = pattern.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("regex", StringComparison.OrdinalIgnoreCase) || + e.Contains("pattern", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_EmptyJustification_ReturnsError() + { + // Arrange + var pattern = CreatePattern() with { Justification = "" }; + + // Act + var errors = pattern.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("Justification", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_PastExpiration_ReturnsWarning() + { + // Arrange + var pattern = CreatePattern(expiresAt: FixedTime.AddDays(-1)); + + // Act + var errors = pattern.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("expir", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void DefaultActiveState_IsTrue() + { + // Arrange & Act + var pattern = CreatePattern(); + + // Assert + pattern.IsActive.Should().BeTrue(); + } + + [Fact] + public void DefaultMatchCount_IsZero() + { + // Arrange & Act + var pattern = CreatePattern(); + + // Assert + pattern.MatchCount.Should().Be(0); + } + + [Fact] + public void DefaultApplicableRuleIds_IsEmpty() + { + // Arrange & Act + var pattern = CreatePattern(); + + // Assert + pattern.ApplicableRuleIds.Should().BeEmpty(); + } + + private static SecretExceptionPattern CreatePattern(DateTimeOffset? expiresAt = null) + { + return new SecretExceptionPattern + { + Id = Guid.NewGuid(), + Name = "Test Exception", + Description = "Test exception for unit tests", + ValuePattern = @"^test_\d+$", + Justification = "Required for testing", + ExpiresAt = expiresAt, + CreatedAt = FixedTime.AddDays(-7), + CreatedBy = "test-user" + }; + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Masking/SecretMaskerTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Masking/SecretMaskerTests.cs new file mode 100644 index 000000000..b09e45f97 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Core.Tests/Secrets/Masking/SecretMaskerTests.cs @@ -0,0 +1,224 @@ +// ----------------------------------------------------------------------------- +// SecretMaskerTests.cs +// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) +// Task: SDC-009 - Add unit and integration tests +// Description: Tests for SecretMasker utility. +// ----------------------------------------------------------------------------- + +using FluentAssertions; +using StellaOps.Scanner.Core.Secrets.Configuration; +using StellaOps.Scanner.Core.Secrets.Masking; +using Xunit; + +namespace StellaOps.Scanner.Core.Tests.Secrets.Masking; + +/// +/// Tests for . +/// +[Trait("Category", "Unit")] +public sealed class SecretMaskerTests +{ + private const string AwsAccessKey = "AKIAIOSFODNN7EXAMPLE"; + private const string GithubToken = "ghp_1234567890123456789012345678901234567890"; + private const string ShortSecret = "abc"; + + [Fact] + public void Mask_FullMask_ReturnsRedactedPlaceholder() + { + // Arrange & Act + var result = SecretMasker.Mask(AwsAccessKey, SecretRevelationPolicy.FullMask); + + // Assert + result.Should().Be(SecretMasker.RedactedPlaceholder); + result.Should().NotContain("AKIA"); + } + + [Fact] + public void Mask_FullReveal_ReturnsOriginalValue() + { + // Arrange & Act + var result = SecretMasker.Mask(AwsAccessKey, SecretRevelationPolicy.FullReveal); + + // Assert + result.Should().Be(AwsAccessKey); + } + + [Fact] + public void Mask_PartialReveal_ShowsPrefixAndSuffix() + { + // Arrange & Act + var result = SecretMasker.Mask(AwsAccessKey, SecretRevelationPolicy.PartialReveal, partialChars: 4); + + // Assert + result.Should().StartWith("AKIA"); + result.Should().EndWith("MPLE"); + result.Should().Contain("*"); + } + + [Fact] + public void Mask_PartialReveal_LimitsMiddleMaskLength() + { + // Arrange + var longSecret = "A" + new string('B', 100) + "Z"; + + // Act + var result = SecretMasker.Mask(longSecret, SecretRevelationPolicy.PartialReveal, partialChars: 4, maxMaskChars: 8); + + // Assert + var maskCount = result.Count(c => c == SecretMasker.MaskChar); + maskCount.Should().Be(8, "mask length should be limited to maxMaskChars"); + } + + [Fact] + public void Mask_PartialReveal_ShortSecret_FullyMasks() + { + // Arrange & Act + var result = SecretMasker.Mask(ShortSecret, SecretRevelationPolicy.PartialReveal, partialChars: 4); + + // Assert + result.Should().Be("***"); + result.Should().NotContain("a"); + } + + [Fact] + public void Mask_NullOrEmpty_ReturnsRedactedPlaceholder() + { + // Arrange & Act & Assert + SecretMasker.Mask(null!, SecretRevelationPolicy.PartialReveal).Should().Be(SecretMasker.RedactedPlaceholder); + SecretMasker.Mask("", SecretRevelationPolicy.PartialReveal).Should().Be(SecretMasker.RedactedPlaceholder); + } + + [Fact] + public void Mask_UnknownPolicy_DefaultsToFullMask() + { + // Arrange & Act + var result = SecretMasker.Mask(AwsAccessKey, (SecretRevelationPolicy)999); + + // Assert + result.Should().Be(SecretMasker.RedactedPlaceholder); + } + + [Fact] + public void Mask_WithConfig_UsesCorrectContext() + { + // Arrange + var config = new RevelationPolicyConfig + { + DefaultPolicy = SecretRevelationPolicy.PartialReveal, + ExportPolicy = SecretRevelationPolicy.FullMask, + LogPolicy = SecretRevelationPolicy.FullMask, + PartialRevealChars = 4 + }; + + // Act + var defaultResult = SecretMasker.Mask(AwsAccessKey, config, MaskingContext.Default); + var exportResult = SecretMasker.Mask(AwsAccessKey, config, MaskingContext.Export); + var logResult = SecretMasker.Mask(AwsAccessKey, config, MaskingContext.Log); + + // Assert + defaultResult.Should().Contain("*").And.StartWith("AKIA"); + exportResult.Should().Be(SecretMasker.RedactedPlaceholder); + logResult.Should().Be(SecretMasker.RedactedPlaceholder); + } + + [Fact] + public void Mask_LogContext_AlwaysFullyMasks() + { + // Arrange + var config = new RevelationPolicyConfig + { + DefaultPolicy = SecretRevelationPolicy.FullReveal, // Even with full reveal + LogPolicy = SecretRevelationPolicy.FullReveal // This should be ignored + }; + + // Act + var result = SecretMasker.Mask(AwsAccessKey, config, MaskingContext.Log); + + // Assert + result.Should().Be(SecretMasker.RedactedPlaceholder, "logs must always be fully masked"); + } + + [Fact] + public void ForLog_ReturnsSecretTypeAndLength() + { + // Arrange & Act + var result = SecretMasker.ForLog("aws_access_key_id", AwsAccessKey.Length); + + // Assert + result.Should().Contain("aws_access_key_id"); + result.Should().Contain(AwsAccessKey.Length.ToString()); + result.Should().StartWith("[SECRET_DETECTED:"); + result.Should().NotContain("AKIA"); + } + + [Fact] + public void IsMasked_DetectsFullyMaskedPlaceholder() + { + // Arrange & Act & Assert + SecretMasker.IsMasked(SecretMasker.RedactedPlaceholder).Should().BeTrue(); + } + + [Fact] + public void IsMasked_DetectsPartiallyMaskedValues() + { + // Arrange + var masked = SecretMasker.Mask(AwsAccessKey, SecretRevelationPolicy.PartialReveal); + + // Act & Assert + SecretMasker.IsMasked(masked).Should().BeTrue(); + } + + [Fact] + public void IsMasked_FalseForPlainText() + { + // Arrange & Act & Assert + SecretMasker.IsMasked(AwsAccessKey).Should().BeFalse(); + SecretMasker.IsMasked("").Should().BeFalse(); + SecretMasker.IsMasked(null!).Should().BeFalse(); + } + + [Theory] + [InlineData("AKIAIOSFODNN7EXAMPLE", 4)] + [InlineData("ghp_abcdefghij12345678901234567890123456", 3)] + [InlineData("a", 4)] // Single char + [InlineData("ab", 4)] // Two chars + public void Mask_PartialReveal_VariousInputs(string input, int partialChars) + { + // Arrange & Act + var result = SecretMasker.Mask(input, SecretRevelationPolicy.PartialReveal, partialChars, maxMaskChars: 8); + + // Assert + result.Length.Should().BeLessThanOrEqualTo(input.Length); + + if (input.Length > partialChars * 2) + { + result.Should().StartWith(input[..partialChars]); + result.Should().EndWith(input[^partialChars..]); + } + else + { + // Short secrets should be fully masked + result.All(c => c == SecretMasker.MaskChar).Should().BeTrue(); + } + } + + [Fact] + public void Mask_Deterministic_SameInputProducesSameOutput() + { + // Arrange & Act + var result1 = SecretMasker.Mask(GithubToken, SecretRevelationPolicy.PartialReveal); + var result2 = SecretMasker.Mask(GithubToken, SecretRevelationPolicy.PartialReveal); + var result3 = SecretMasker.Mask(GithubToken, SecretRevelationPolicy.PartialReveal); + + // Assert + result1.Should().Be(result2); + result2.Should().Be(result3); + } + + [Fact] + public void MaskChar_IsAsterisk() + { + // Assert + SecretMasker.MaskChar.Should().Be('*'); + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Sources.Tests/Domain/SbomSourceTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Sources.Tests/Domain/SbomSourceTests.cs index cce5480d1..8e42e40eb 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Sources.Tests/Domain/SbomSourceTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Sources.Tests/Domain/SbomSourceTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using FluentAssertions; +using Microsoft.Extensions.Time.Testing; using StellaOps.Scanner.Sources.Domain; using Xunit; @@ -7,6 +8,13 @@ namespace StellaOps.Scanner.Sources.Tests.Domain; public class SbomSourceTests { + private readonly FakeTimeProvider _timeProvider; + + public SbomSourceTests() + { + _timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero)); + } + private static readonly JsonDocument SampleConfig = JsonDocument.Parse(""" { "registryType": "Harbor", @@ -23,14 +31,15 @@ public class SbomSourceTests name: "test-source", sourceType: SbomSourceType.Zastava, configuration: SampleConfig, - createdBy: "user-1"); + createdBy: "user-1", + timeProvider: _timeProvider); // Assert source.SourceId.Should().NotBeEmpty(); source.TenantId.Should().Be("tenant-1"); source.Name.Should().Be("test-source"); source.SourceType.Should().Be(SbomSourceType.Zastava); - source.Status.Should().Be(SbomSourceStatus.Draft); + source.Status.Should().Be(SbomSourceStatus.Pending); source.CreatedBy.Should().Be("user-1"); source.Paused.Should().BeFalse(); source.ConsecutiveFailures.Should().Be(0); @@ -46,16 +55,17 @@ public class SbomSourceTests sourceType: SbomSourceType.Docker, configuration: SampleConfig, createdBy: "user-1", + timeProvider: _timeProvider, cronSchedule: "0 * * * *"); // Every hour // Assert source.CronSchedule.Should().Be("0 * * * *"); source.NextScheduledRun.Should().NotBeNull(); - source.NextScheduledRun.Should().BeAfter(DateTimeOffset.UtcNow); + source.NextScheduledRun.Should().BeAfter(_timeProvider.GetUtcNow()); } [Fact] - public void Create_WithZastavaType_GeneratesWebhookEndpointAndSecret() + public void Create_WithZastavaType_GeneratesWebhookEndpoint() { // Arrange & Act var source = SbomSource.Create( @@ -63,16 +73,16 @@ public class SbomSourceTests name: "webhook-source", sourceType: SbomSourceType.Zastava, configuration: SampleConfig, - createdBy: "user-1"); + createdBy: "user-1", + timeProvider: _timeProvider); // Assert source.WebhookEndpoint.Should().NotBeNullOrEmpty(); - source.WebhookSecret.Should().NotBeNullOrEmpty(); - source.WebhookSecret!.Length.Should().BeGreaterOrEqualTo(32); + source.WebhookSecretRef.Should().NotBeNullOrEmpty(); } [Fact] - public void Activate_FromDraft_ChangesStatusToActive() + public void Activate_FromPending_ChangesStatusToActive() { // Arrange var source = SbomSource.Create( @@ -80,10 +90,11 @@ public class SbomSourceTests name: "test-source", sourceType: SbomSourceType.Docker, configuration: SampleConfig, - createdBy: "user-1"); + createdBy: "user-1", + timeProvider: _timeProvider); // Act - source.Activate("activator"); + source.Activate("activator", _timeProvider); // Assert source.Status.Should().Be(SbomSourceStatus.Active); @@ -99,11 +110,12 @@ public class SbomSourceTests name: "test-source", sourceType: SbomSourceType.Docker, configuration: SampleConfig, - createdBy: "user-1"); - source.Activate("activator"); + createdBy: "user-1", + timeProvider: _timeProvider); + source.Activate("activator", _timeProvider); // Act - source.Pause("Maintenance window", "TICKET-123", "operator"); + source.Pause("Maintenance window", "TICKET-123", "operator", _timeProvider); // Assert source.Paused.Should().BeTrue(); @@ -121,12 +133,13 @@ public class SbomSourceTests name: "test-source", sourceType: SbomSourceType.Docker, configuration: SampleConfig, - createdBy: "user-1"); - source.Activate("activator"); - source.Pause("Maintenance", null, "operator"); + createdBy: "user-1", + timeProvider: _timeProvider); + source.Activate("activator", _timeProvider); + source.Pause("Maintenance", null, "operator", _timeProvider); // Act - source.Resume("operator"); + source.Resume("operator", _timeProvider); // Assert source.Paused.Should().BeFalse(); @@ -143,16 +156,18 @@ public class SbomSourceTests name: "test-source", sourceType: SbomSourceType.Docker, configuration: SampleConfig, - createdBy: "user-1"); - source.Activate("activator"); + createdBy: "user-1", + timeProvider: _timeProvider); + source.Activate("activator", _timeProvider); // Simulate some failures - source.RecordFailedRun("Error 1"); - source.RecordFailedRun("Error 2"); + var runAt = _timeProvider.GetUtcNow(); + source.RecordFailedRun(runAt, "Error 1", _timeProvider); + source.RecordFailedRun(runAt, "Error 2", _timeProvider); source.ConsecutiveFailures.Should().Be(2); // Act - source.RecordSuccessfulRun(); + source.RecordSuccessfulRun(runAt, _timeProvider); // Assert source.ConsecutiveFailures.Should().Be(0); @@ -169,13 +184,15 @@ public class SbomSourceTests name: "test-source", sourceType: SbomSourceType.Docker, configuration: SampleConfig, - createdBy: "user-1"); - source.Activate("activator"); + createdBy: "user-1", + timeProvider: _timeProvider); + source.Activate("activator", _timeProvider); - // Act - fail 5 times (threshold is 5) + // Act - fail multiple times + var runAt = _timeProvider.GetUtcNow(); for (var i = 0; i < 5; i++) { - source.RecordFailedRun($"Error {i + 1}"); + source.RecordFailedRun(runAt, $"Error {i + 1}", _timeProvider); } // Assert @@ -192,12 +209,13 @@ public class SbomSourceTests name: "test-source", sourceType: SbomSourceType.Docker, configuration: SampleConfig, - createdBy: "user-1"); + createdBy: "user-1", + timeProvider: _timeProvider); source.MaxScansPerHour = 10; - source.Activate("activator"); + source.Activate("activator", _timeProvider); // Act - var isLimited = source.IsRateLimited(); + var isLimited = source.IsRateLimited(_timeProvider); // Assert isLimited.Should().BeFalse(); @@ -212,7 +230,8 @@ public class SbomSourceTests name: "test-source", sourceType: SbomSourceType.Docker, configuration: SampleConfig, - createdBy: "user-1"); + createdBy: "user-1", + timeProvider: _timeProvider); var newConfig = JsonDocument.Parse(""" { @@ -222,7 +241,7 @@ public class SbomSourceTests """); // Act - source.UpdateConfiguration(newConfig, "updater"); + source.UpdateConfiguration(newConfig, "updater", _timeProvider); // Assert source.Configuration.RootElement.GetProperty("registryType").GetString() diff --git a/src/Web/StellaOps.Web/e2e/secret-detection.e2e.spec.ts b/src/Web/StellaOps.Web/e2e/secret-detection.e2e.spec.ts new file mode 100644 index 000000000..ffa0fee6c --- /dev/null +++ b/src/Web/StellaOps.Web/e2e/secret-detection.e2e.spec.ts @@ -0,0 +1,518 @@ +/** + * Secret Detection UI E2E Tests. + * Sprint: SPRINT_20260104_008_FE (Secret Detection UI) + * Task: SDU-012 - E2E tests + * + * Tests the complete secret detection configuration and viewing flow: + * 1. Settings page navigation and toggles + * 2. Revelation policy selection + * 3. Rule category configuration + * 4. Exception management + * 5. Alert destination configuration + * 6. Findings list and masked value display + */ + +import { test, expect, Page } from '@playwright/test'; + +test.describe('Secret Detection UI', () => { + const settingsUrl = '/tenants/test-tenant/secrets/settings'; + const findingsUrl = '/tenants/test-tenant/secrets/findings'; + + test.describe('Settings Page', () => { + test('displays settings page with header and tabs', async ({ page }) => { + await page.goto(settingsUrl); + + // Verify header is visible + const header = page.locator('.settings-header'); + await expect(header).toBeVisible(); + await expect(header).toContainText('Secret Detection'); + + // Verify enable/disable toggle + const toggle = page.locator('mat-slide-toggle'); + await expect(toggle).toBeVisible(); + + // Verify tabs are present + const tabs = page.locator('mat-tab-group'); + await expect(tabs).toBeVisible(); + await expect(page.locator('role=tab')).toHaveCount(3); + }); + + test('toggles secret detection on and off', async ({ page }) => { + await page.goto(settingsUrl); + + const toggle = page.locator('mat-slide-toggle'); + const initialState = await toggle.getAttribute('class'); + + // Toggle the switch + await toggle.click(); + + // Wait for API call and state change + await page.waitForResponse(resp => + resp.url().includes('/secrets/config/settings') && resp.status() === 200 + ); + + // Verify snackbar confirmation + const snackbar = page.locator('mat-snack-bar-container'); + await expect(snackbar).toBeVisible(); + }); + + test('navigates between tabs', async ({ page }) => { + await page.goto(settingsUrl); + + // Click Exceptions tab + await page.locator('role=tab', { hasText: 'Exceptions' }).click(); + await expect(page.locator('app-exception-manager')).toBeVisible(); + + // Click Alerts tab + await page.locator('role=tab', { hasText: 'Alerts' }).click(); + await expect(page.locator('app-alert-destination-config')).toBeVisible(); + + // Back to General tab + await page.locator('role=tab', { hasText: 'General' }).click(); + await expect(page.locator('app-revelation-policy-selector')).toBeVisible(); + }); + }); + + test.describe('Revelation Policy Selector', () => { + test('displays all policy options', async ({ page }) => { + await page.goto(settingsUrl); + + // Wait for component to load + const selector = page.locator('app-revelation-policy-selector'); + await expect(selector).toBeVisible(); + + // Verify all four options are visible + await expect(page.locator('.policy-option')).toHaveCount(4); + await expect(page.getByText('Fully Masked')).toBeVisible(); + await expect(page.getByText('Partially Revealed')).toBeVisible(); + await expect(page.getByText('Full Revelation')).toBeVisible(); + await expect(page.getByText('Redacted')).toBeVisible(); + }); + + test('shows preview for selected policy', async ({ page }) => { + await page.goto(settingsUrl); + + // Select partial reveal + const partialOption = page.locator('.policy-option', { hasText: 'Partially Revealed' }); + await partialOption.click(); + + // Verify preview is shown + const preview = partialOption.locator('.preview code'); + await expect(preview).toBeVisible(); + await expect(preview).toContainText('****'); + }); + + test('shows additional controls when masked policy selected', async ({ page }) => { + await page.goto(settingsUrl); + + // Select masked policy + const maskedOption = page.locator('.policy-option', { hasText: 'Fully Masked' }); + await maskedOption.click(); + + // Verify additional controls appear + await expect(maskedOption.locator('.option-controls')).toBeVisible(); + await expect(maskedOption.locator('mat-form-field', { hasText: 'Mask Character' })).toBeVisible(); + }); + + test('updates preview when mask length changes', async ({ page }) => { + await page.goto(settingsUrl); + + // Select masked policy + const maskedOption = page.locator('.policy-option', { hasText: 'Fully Masked' }); + await maskedOption.click(); + + // Change mask length + const lengthSelect = maskedOption.locator('mat-select', { hasText: /characters/ }); + await lengthSelect.click(); + await page.locator('mat-option', { hasText: '16 characters' }).click(); + + // Verify preview updated + const preview = maskedOption.locator('.preview code'); + const text = await preview.textContent(); + expect(text?.length).toBe(16); + }); + }); + + test.describe('Rule Category Toggles', () => { + test('displays rule categories grouped by type', async ({ page }) => { + await page.goto(settingsUrl); + + const toggles = page.locator('app-rule-category-toggles'); + await expect(toggles).toBeVisible(); + + // Verify accordion panels exist + const panels = page.locator('mat-expansion-panel'); + await expect(panels.first()).toBeVisible(); + }); + + test('shows selection count in toolbar', async ({ page }) => { + await page.goto(settingsUrl); + + const selectionInfo = page.locator('.selection-info'); + await expect(selectionInfo).toBeVisible(); + await expect(selectionInfo).toContainText(/\d+ of \d+ categories selected/); + }); + + test('toggles entire group when group checkbox clicked', async ({ page }) => { + await page.goto(settingsUrl); + + // Expand first group + const panel = page.locator('mat-expansion-panel').first(); + await panel.click(); + + // Get the group checkbox + const groupCheckbox = panel.locator('mat-expansion-panel-header mat-checkbox'); + + // Toggle group + await groupCheckbox.click(); + + // Wait for API call + await page.waitForResponse(resp => + resp.url().includes('/secrets/config/settings') && resp.status() === 200 + ); + }); + + test('select all and clear all buttons work', async ({ page }) => { + await page.goto(settingsUrl); + + // Click select all + await page.locator('button', { hasText: 'Select All' }).click(); + + // Wait for API response + await page.waitForResponse(resp => + resp.url().includes('/secrets/config/settings') && resp.status() === 200 + ); + + // Click clear all + await page.locator('button', { hasText: 'Clear All' }).click(); + + await page.waitForResponse(resp => + resp.url().includes('/secrets/config/settings') && resp.status() === 200 + ); + }); + }); + + test.describe('Exception Manager', () => { + test('displays exception list on Exceptions tab', async ({ page }) => { + await page.goto(settingsUrl); + + // Navigate to exceptions tab + await page.locator('role=tab', { hasText: 'Exceptions' }).click(); + + const manager = page.locator('app-exception-manager'); + await expect(manager).toBeVisible(); + }); + + test('opens create dialog when Add Exception clicked', async ({ page }) => { + await page.goto(settingsUrl); + await page.locator('role=tab', { hasText: 'Exceptions' }).click(); + + // Click add button + await page.locator('button', { hasText: 'Add Exception' }).click(); + + // Verify dialog opens + const dialog = page.locator('.dialog-overlay'); + await expect(dialog).toBeVisible(); + await expect(page.locator('.dialog-card')).toContainText('Create Exception'); + }); + + test('validates required fields in exception form', async ({ page }) => { + await page.goto(settingsUrl); + await page.locator('role=tab', { hasText: 'Exceptions' }).click(); + await page.locator('button', { hasText: 'Add Exception' }).click(); + + // Try to submit without required fields + const submitButton = page.locator('button[type="submit"]'); + await expect(submitButton).toBeDisabled(); + + // Fill required fields + await page.locator('input[formcontrolname="name"]').fill('Test Exception'); + await page.locator('input[formcontrolname="pattern"]').fill('test-pattern'); + + // Button should now be enabled + await expect(submitButton).toBeEnabled(); + }); + + test('closes dialog when Cancel clicked', async ({ page }) => { + await page.goto(settingsUrl); + await page.locator('role=tab', { hasText: 'Exceptions' }).click(); + await page.locator('button', { hasText: 'Add Exception' }).click(); + + // Click cancel + await page.locator('button', { hasText: 'Cancel' }).click(); + + // Dialog should close + const dialog = page.locator('.dialog-overlay'); + await expect(dialog).not.toBeVisible(); + }); + }); + + test.describe('Alert Destination Configuration', () => { + test('displays alert configuration on Alerts tab', async ({ page }) => { + await page.goto(settingsUrl); + + // Navigate to alerts tab + await page.locator('role=tab', { hasText: 'Alerts' }).click(); + + const config = page.locator('app-alert-destination-config'); + await expect(config).toBeVisible(); + await expect(page.getByText('Alert Destinations')).toBeVisible(); + }); + + test('shows global settings when alerts enabled', async ({ page }) => { + await page.goto(settingsUrl); + await page.locator('role=tab', { hasText: 'Alerts' }).click(); + + // Enable alerts if not already + const enableToggle = page.locator('app-alert-destination-config mat-slide-toggle'); + const isChecked = await enableToggle.getAttribute('class'); + if (!isChecked?.includes('checked')) { + await enableToggle.click(); + } + + // Verify global settings visible + await expect(page.locator('.global-settings')).toBeVisible(); + await expect(page.getByText('Minimum Severity')).toBeVisible(); + await expect(page.getByText('Rate Limit')).toBeVisible(); + }); + + test('adds new destination when Add Destination clicked', async ({ page }) => { + await page.goto(settingsUrl); + await page.locator('role=tab', { hasText: 'Alerts' }).click(); + + // Enable alerts + const enableToggle = page.locator('app-alert-destination-config mat-slide-toggle'); + const isChecked = await enableToggle.getAttribute('class'); + if (!isChecked?.includes('checked')) { + await enableToggle.click(); + } + + // Count initial destinations + const initialCount = await page.locator('mat-expansion-panel').count(); + + // Add destination + await page.locator('button', { hasText: 'Add Destination' }).click(); + + // Verify new panel added + const newCount = await page.locator('mat-expansion-panel').count(); + expect(newCount).toBe(initialCount + 1); + }); + + test('shows different config fields based on destination type', async ({ page }) => { + await page.goto(settingsUrl); + await page.locator('role=tab', { hasText: 'Alerts' }).click(); + + // Enable alerts and add destination + const enableToggle = page.locator('app-alert-destination-config mat-slide-toggle'); + const isChecked = await enableToggle.getAttribute('class'); + if (!isChecked?.includes('checked')) { + await enableToggle.click(); + } + await page.locator('button', { hasText: 'Add Destination' }).click(); + + // Expand the new destination + const panel = page.locator('mat-expansion-panel').last(); + await panel.click(); + + // Select Slack type + await panel.locator('mat-select', { hasText: 'Type' }).click(); + await page.locator('mat-option', { hasText: 'Slack' }).click(); + + // Verify Slack-specific fields + await expect(panel.locator('mat-form-field', { hasText: 'Webhook URL' })).toBeVisible(); + await expect(panel.locator('mat-form-field', { hasText: 'Channel' })).toBeVisible(); + }); + + test('test button sends test alert', async ({ page }) => { + await page.goto(settingsUrl); + await page.locator('role=tab', { hasText: 'Alerts' }).click(); + + // Enable alerts + const enableToggle = page.locator('app-alert-destination-config mat-slide-toggle'); + const isChecked = await enableToggle.getAttribute('class'); + if (!isChecked?.includes('checked')) { + await enableToggle.click(); + } + + // Expand first destination (if exists) + const panel = page.locator('mat-expansion-panel').first(); + if (await panel.isVisible()) { + await panel.click(); + + // Click test button + const testButton = panel.locator('button', { hasText: 'Test' }); + await testButton.click(); + + // Wait for API response + await page.waitForResponse(resp => + resp.url().includes('/alerts/test') && resp.status() === 200 + ); + + // Verify snackbar shows result + const snackbar = page.locator('mat-snack-bar-container'); + await expect(snackbar).toBeVisible(); + } + }); + }); + + test.describe('Findings List', () => { + test('displays findings list with filters', async ({ page }) => { + await page.goto(findingsUrl); + + // Verify header + await expect(page.getByText('Secret Findings')).toBeVisible(); + + // Verify filters + await expect(page.locator('.filters')).toBeVisible(); + await expect(page.locator('mat-form-field', { hasText: 'Severity' })).toBeVisible(); + await expect(page.locator('mat-form-field', { hasText: 'Status' })).toBeVisible(); + }); + + test('displays table with correct columns', async ({ page }) => { + await page.goto(findingsUrl); + + const table = page.locator('table[mat-table]'); + await expect(table).toBeVisible(); + + // Verify column headers + await expect(page.locator('th', { hasText: 'Severity' })).toBeVisible(); + await expect(page.locator('th', { hasText: 'Type' })).toBeVisible(); + await expect(page.locator('th', { hasText: 'Location' })).toBeVisible(); + await expect(page.locator('th', { hasText: 'Value' })).toBeVisible(); + await expect(page.locator('th', { hasText: 'Status' })).toBeVisible(); + }); + + test('filters by severity', async ({ page }) => { + await page.goto(findingsUrl); + + // Open severity filter + await page.locator('mat-form-field', { hasText: 'Severity' }).click(); + + // Select Critical + await page.locator('mat-option', { hasText: 'Critical' }).click(); + + // Close dropdown + await page.keyboard.press('Escape'); + + // Wait for filtered results + await page.waitForResponse(resp => + resp.url().includes('/findings') && resp.url().includes('severity') + ); + }); + + test('pagination works correctly', async ({ page }) => { + await page.goto(findingsUrl); + + const paginator = page.locator('mat-paginator'); + await expect(paginator).toBeVisible(); + + // Click next page if available + const nextButton = paginator.locator('button[aria-label="Next page"]'); + if (await nextButton.isEnabled()) { + await nextButton.click(); + + // Verify page changed + await page.waitForResponse(resp => + resp.url().includes('/findings') && resp.url().includes('page=2') + ); + } + }); + + test('clicking row navigates to details', async ({ page }) => { + await page.goto(findingsUrl); + + // Click first row + const firstRow = page.locator('tr[mat-row]').first(); + if (await firstRow.isVisible()) { + await firstRow.click(); + + // Verify navigation + await expect(page).toHaveURL(/\/findings\/.+/); + } + }); + }); + + test.describe('Masked Value Display', () => { + test('displays masked value by default', async ({ page }) => { + await page.goto(findingsUrl); + + const maskedDisplay = page.locator('app-masked-value-display').first(); + if (await maskedDisplay.isVisible()) { + const code = maskedDisplay.locator('.value-text'); + await expect(code).toBeVisible(); + await expect(code).toContainText(/\*+/); + } + }); + + test('reveal button shows for authorized users', async ({ page }) => { + await page.goto(findingsUrl); + + const maskedDisplay = page.locator('app-masked-value-display').first(); + if (await maskedDisplay.isVisible()) { + const revealButton = maskedDisplay.locator('button[mattooltip="Reveal secret value"]'); + // Button visibility depends on user permissions + // Just verify the component structure + await expect(maskedDisplay.locator('.value-actions')).toBeVisible(); + } + }); + + test('copy button is disabled when value is masked', async ({ page }) => { + await page.goto(findingsUrl); + + const maskedDisplay = page.locator('app-masked-value-display').first(); + if (await maskedDisplay.isVisible()) { + const copyButton = maskedDisplay.locator('button[mattooltip*="Copy"]'); + if (await copyButton.isVisible()) { + // Copy should be disabled when not revealed + await expect(copyButton).toBeDisabled(); + } + } + }); + }); + + test.describe('Accessibility', () => { + test('settings page is keyboard navigable', async ({ page }) => { + await page.goto(settingsUrl); + + // Tab through elements + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + + // Verify focus is visible + const focusedElement = await page.locator(':focus'); + await expect(focusedElement).toBeVisible(); + }); + + test('tabs can be selected with keyboard', async ({ page }) => { + await page.goto(settingsUrl); + + // Focus tab list + const tabList = page.locator('mat-tab-group'); + await tabList.focus(); + + // Navigate tabs with arrow keys + await page.keyboard.press('ArrowRight'); + + // Verify tab changed + const activeTab = page.locator('[role="tab"][aria-selected="true"]'); + await expect(activeTab).toContainText('Exceptions'); + }); + + test('dialogs trap focus', async ({ page }) => { + await page.goto(settingsUrl); + await page.locator('role=tab', { hasText: 'Exceptions' }).click(); + await page.locator('button', { hasText: 'Add Exception' }).click(); + + // Tab through dialog elements + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + + // Focus should stay within dialog + const focusedElement = await page.locator(':focus'); + const dialogCard = page.locator('.dialog-card'); + await expect(dialogCard.locator(':focus')).toBeVisible(); + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/components/alerts/alert-destination-config.component.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/alerts/alert-destination-config.component.ts new file mode 100644 index 000000000..85b0bb639 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/alerts/alert-destination-config.component.ts @@ -0,0 +1,576 @@ +/** + * Alert Destination Config Component. + * + * Configures alert destinations for secret detection findings. + * + * @sprint SPRINT_20260104_008_FE (Secret Detection UI) + * @task SDU-008 - Implement alert destination configuration + * @task SDU-011 - Add channel test functionality + */ + +import { Component, Input, Output, EventEmitter, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { inject } from '@angular/core'; + +import { SecretAlertSettings, SecretAlertDestination } from '../../models'; +import { SecretDetectionSettingsService } from '../../services'; + +@Component({ + selector: 'app-alert-destination-config', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatCheckboxModule, + MatSlideToggleModule, + MatExpansionModule, + MatChipsModule, + MatTooltipModule, + MatProgressSpinnerModule, + MatSnackBarModule, + ], + template: ` +
+
+
+

Alert Destinations

+

+ Configure where and how secret detection alerts are sent. +

+
+ + + {{ alertsEnabled() ? 'Alerts Enabled' : 'Alerts Disabled' }} + +
+ + @if (alertsEnabled()) { +
+

Global Settings

+ +
+ + Minimum Severity + + Low + Medium + High + Critical + + Only alert for findings at or above this severity + + + + Rate Limit (per hour) + + Maximum alerts per hour per destination + + + + Deduplication Window (minutes) + + Suppress duplicate alerts within this window + +
+
+ +
+
+

Destinations

+ +
+ + + @for (dest of destinations(); track dest.id; let i = $index) { + + + + + {{ getDestinationIcon(dest.type) }} + + {{ dest.name || getDestinationTypeName(dest.type) }} + + + + {{ dest.enabled ? 'Active' : 'Inactive' }} + + + + +
+ + Name + + + + + Type + + Slack + Microsoft Teams + Email + Webhook + PagerDuty + + + + @switch (dest.type) { + @case ('slack') { + + Webhook URL + + + + Channel + + + } + @case ('teams') { + + Webhook URL + + + } + @case ('email') { + + Recipients (comma-separated) + + + + Subject Prefix + + + } + @case ('webhook') { + + URL + + + + Authentication Header + + + } + @case ('pagerduty') { + + Integration Key + + + + Service Name + + + } + } + + + Severity Filter + + Critical + High + Medium + Low + + Leave empty for all severities + + + +
+
+ } +
+ + @if (destinations().length === 0) { + + + notifications_off +

No Destinations Configured

+

Add a destination to start receiving alerts.

+
+
+ } +
+ } +
+ `, + styles: [` + .alert-config { + display: flex; + flex-direction: column; + gap: 24px; + } + + .config-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + } + + .header-content h2 { + margin: 0; + font-size: 18px; + font-weight: 500; + } + + .header-content .description { + color: var(--mat-foreground-secondary-text); + margin: 4px 0 0; + } + + .global-settings h3, + .destinations-section h3 { + font-size: 16px; + font-weight: 500; + margin: 0 0 16px; + } + + .settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + } + + .destinations-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + .destinations-header h3 { + margin: 0; + } + + mat-expansion-panel-header mat-icon { + margin-right: 8px; + } + + .destination-form { + display: flex; + flex-direction: column; + gap: 16px; + padding-top: 16px; + } + + .destination-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 8px; + border-top: 1px solid var(--mat-foreground-divider); + } + + .footer-actions { + display: flex; + align-items: center; + gap: 8px; + } + + .footer-actions mat-spinner { + margin-right: 4px; + } + + .status-enabled { + background-color: #28a745 !important; + color: white !important; + } + + .status-disabled { + background-color: #6c757d !important; + color: white !important; + } + + .empty-state { + text-align: center; + padding: 32px; + } + + .empty-state mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: var(--mat-foreground-hint-text); + } + + .empty-state h3 { + margin: 12px 0 8px; + } + + .empty-state p { + color: var(--mat-foreground-secondary-text); + margin: 0; + } + `] +}) +export class AlertDestinationConfigComponent { + private readonly settingsService = inject(SecretDetectionSettingsService); + private readonly snackBar = inject(MatSnackBar); + + @Input() settings: SecretAlertSettings = { enabled: false, destinations: [] }; + @Input() tenantId = ''; + @Output() settingsChange = new EventEmitter(); + + readonly alertsEnabled = signal(false); + readonly destinations = signal([]); + readonly testingDestination = signal(null); + + ngOnChanges(): void { + this.alertsEnabled.set(this.settings.enabled ?? false); + this.destinations.set([...(this.settings.destinations ?? [])]); + } + + onAlertsToggle(enabled: boolean): void { + this.alertsEnabled.set(enabled); + this.emitChange(); + } + + onMinSeverityChange(severity: string): void { + this.settings = { ...this.settings, minimumSeverity: severity }; + this.emitChange(); + } + + onRateLimitChange(event: Event): void { + const input = event.target as HTMLInputElement; + const value = parseInt(input.value, 10); + if (!isNaN(value) && value > 0) { + this.settings = { ...this.settings, rateLimitPerHour: value }; + this.emitChange(); + } + } + + onDedupeWindowChange(event: Event): void { + const input = event.target as HTMLInputElement; + const value = parseInt(input.value, 10); + if (!isNaN(value) && value > 0) { + this.settings = { ...this.settings, deduplicationWindowMinutes: value }; + this.emitChange(); + } + } + + addDestination(): void { + const newDest: SecretAlertDestination = { + id: crypto.randomUUID(), + name: '', + type: 'webhook', + enabled: true, + config: {}, + }; + this.destinations.update(dests => [...dests, newDest]); + this.emitChange(); + } + + removeDestination(index: number): void { + this.destinations.update(dests => dests.filter((_, i) => i !== index)); + this.emitChange(); + } + + onDestNameChange(index: number, event: Event): void { + const input = event.target as HTMLInputElement; + this.updateDestination(index, { name: input.value }); + } + + onDestTypeChange(index: number, type: string): void { + this.updateDestination(index, { type, config: {} }); + } + + onDestConfigChange(index: number, key: string, event: Event): void { + const input = event.target as HTMLInputElement; + const current = this.destinations()[index]; + this.updateDestination(index, { + config: { ...(current.config ?? {}), [key]: input.value }, + }); + } + + onDestSeverityChange(index: number, severities: string[]): void { + this.updateDestination(index, { severityFilter: severities }); + } + + onDestEnabledChange(index: number, enabled: boolean): void { + this.updateDestination(index, { enabled }); + } + + private updateDestination(index: number, updates: Partial): void { + this.destinations.update(dests => + dests.map((d, i) => i === index ? { ...d, ...updates } : d) + ); + this.emitChange(); + } + + private emitChange(): void { + this.settingsChange.emit({ + ...this.settings, + enabled: this.alertsEnabled(), + destinations: this.destinations(), + }); + } + + getDestinationIcon(type: string): string { + const iconMap: Record = { + slack: 'chat', + teams: 'groups', + email: 'email', + webhook: 'webhook', + pagerduty: 'warning', + }; + return iconMap[type] ?? 'notifications'; + } + + getDestinationColor(type: string): string { + const colorMap: Record = { + slack: '#4A154B', + teams: '#6264A7', + email: '#1976d2', + webhook: '#757575', + pagerduty: '#06AC38', + }; + return colorMap[type] ?? '#757575'; + } + + getDestinationTypeName(type: string): string { + const nameMap: Record = { + slack: 'Slack', + teams: 'Microsoft Teams', + email: 'Email', + webhook: 'Webhook', + pagerduty: 'PagerDuty', + }; + return nameMap[type] ?? type; + } + + /** + * Tests an alert destination by sending a test notification. + * @sprint SDU-011 - Add channel test functionality + */ + testDestination(destination: SecretAlertDestination): void { + if (!this.tenantId || !destination.id) { + this.snackBar.open('Unable to test: missing tenant or destination ID', 'Close', { + duration: 3000, + panelClass: 'snackbar-error', + }); + return; + } + + this.testingDestination.set(destination.id); + + this.settingsService.testAlertDestination(this.tenantId, destination.id).subscribe({ + next: (result) => { + this.testingDestination.set(null); + if (result.success) { + this.snackBar.open('Test alert sent successfully!', 'Close', { + duration: 3000, + panelClass: 'snackbar-success', + }); + } else { + this.snackBar.open(`Test failed: ${result.message}`, 'Close', { + duration: 5000, + panelClass: 'snackbar-error', + }); + } + }, + error: () => { + this.testingDestination.set(null); + this.snackBar.open('Failed to send test alert', 'Close', { + duration: 5000, + panelClass: 'snackbar-error', + }); + }, + }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/components/alerts/index.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/alerts/index.ts new file mode 100644 index 000000000..93adbcc33 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/alerts/index.ts @@ -0,0 +1,4 @@ +/** + * Barrel export for alert components. + */ +export * from './alert-destination-config.component'; diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/components/exceptions/exception-manager.component.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/exceptions/exception-manager.component.ts new file mode 100644 index 000000000..d9b4cbc4e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/exceptions/exception-manager.component.ts @@ -0,0 +1,502 @@ +/** + * Exception Manager Component. + * + * Manages secret detection exception patterns (allowlist entries). + * + * @sprint SPRINT_20260104_008_FE (Secret Detection UI) + * @task SDU-007 - Implement exception manager + */ + +import { Component, Input, inject, OnInit, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatTableModule } from '@angular/material/table'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDialogModule, MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatCardModule } from '@angular/material/card'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; + +import { SecretExceptionService } from '../../services'; +import { SecretExceptionPattern } from '../../models'; + +@Component({ + selector: 'app-exception-manager', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatTableModule, + MatButtonModule, + MatIconModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatCheckboxModule, + MatChipsModule, + MatTooltipModule, + MatProgressSpinnerModule, + MatCardModule, + MatSnackBarModule, + ], + template: ` +
+
+

Exception Patterns

+

+ Define patterns to exclude known false positives or intentional test secrets. +

+ +
+ + @if (loading()) { +
+ +
+ } + + @if (exceptions().length > 0) { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name +
+ {{ exception.name }} + @if (exception.description) { + {{ exception.description }} + } +
+
Match Type + {{ exception.matchType | titlecase }} + Pattern + {{ exception.pattern }} + Scope + @if (exception.ruleIds?.length) { + + @for (ruleId of exception.ruleIds.slice(0, 2); track ruleId) { + {{ ruleId }} + } + @if (exception.ruleIds.length > 2) { + +{{ exception.ruleIds.length - 2 }} more + } + + } @else { + All rules + } + Status + + {{ exception.enabled ? 'Enabled' : 'Disabled' }} + + + + + +
+ } @else if (!loading()) { + + + block +

No Exceptions Defined

+

Create exception patterns to suppress known false positives.

+
+
+ } +
+ + + @if (showDialog()) { +
+ + + {{ editingException() ? 'Edit' : 'Create' }} Exception + + +
+ + Name + + + + + Description + + + + + Match Type + + Literal String + Regular Expression + Glob Pattern + Prefix Match + Suffix Match + + + + + Pattern + + + @switch (exceptionForm.get('matchType')?.value) { + @case ('regex') { Use a valid regular expression } + @case ('glob') { Use * and ? wildcards } + @default { Enter the exact text to match } + } + + + + + Target + + Secret Value + File Path + Both Value and Path + + + + Enabled + +
+ + +
+
+
+
+
+ } + `, + styles: [` + .exception-manager { + display: flex; + flex-direction: column; + gap: 16px; + } + + .manager-header { + display: flex; + flex-direction: column; + gap: 8px; + } + + .manager-header h2 { + margin: 0; + font-size: 18px; + font-weight: 500; + } + + .manager-header .description { + color: var(--mat-foreground-secondary-text); + margin: 0 0 8px 0; + } + + .manager-header button { + align-self: flex-start; + } + + .loading { + display: flex; + justify-content: center; + padding: 32px; + } + + table { + width: 100%; + } + + .exception-name { + display: flex; + flex-direction: column; + gap: 2px; + } + + .exception-name .description { + font-size: 12px; + color: var(--mat-foreground-secondary-text); + } + + .pattern-value { + font-size: 13px; + padding: 2px 6px; + background: var(--mat-background-card); + border: 1px solid var(--mat-foreground-divider); + border-radius: 4px; + } + + .scope-all { + font-style: italic; + color: var(--mat-foreground-secondary-text); + } + + .status-enabled { + background-color: #28a745 !important; + color: white !important; + } + + .status-disabled { + background-color: #6c757d !important; + color: white !important; + } + + .empty-state { + text-align: center; + padding: 32px; + } + + .empty-state mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: var(--mat-foreground-hint-text); + } + + .empty-state h3 { + margin: 12px 0 8px; + } + + .empty-state p { + color: var(--mat-foreground-secondary-text); + margin: 0; + } + + .dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + } + + .dialog-card { + width: 500px; + max-width: 90vw; + max-height: 90vh; + overflow-y: auto; + } + + .dialog-card form { + display: flex; + flex-direction: column; + gap: 16px; + } + + .dialog-card mat-form-field { + width: 100%; + } + + .form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; + } + `] +}) +export class ExceptionManagerComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly exceptionService = inject(SecretExceptionService); + private readonly snackBar = inject(MatSnackBar); + + @Input() exceptions: SecretExceptionPattern[] = []; + @Input() tenantId = ''; + + readonly loading = this.exceptionService.loading; + readonly showDialog = signal(false); + readonly editingException = signal(null); + readonly saving = signal(false); + + readonly displayedColumns = ['name', 'matchType', 'pattern', 'scope', 'enabled', 'actions']; + + exceptionForm: FormGroup = this.fb.group({ + name: ['', [Validators.required, Validators.maxLength(100)]], + description: ['', Validators.maxLength(500)], + matchType: ['literal', Validators.required], + pattern: ['', Validators.required], + target: ['value', Validators.required], + enabled: [true], + }); + + ngOnInit(): void { + if (this.tenantId) { + this.loadExceptions(); + } + } + + loadExceptions(): void { + this.exceptionService.loadExceptions(this.tenantId).subscribe(); + } + + openCreateDialog(): void { + this.editingException.set(null); + this.exceptionForm.reset({ + matchType: 'literal', + target: 'value', + enabled: true, + }); + this.showDialog.set(true); + } + + openEditDialog(exception: SecretExceptionPattern): void { + this.editingException.set(exception); + this.exceptionForm.patchValue({ + name: exception.name, + description: exception.description ?? '', + matchType: exception.matchType, + pattern: exception.pattern, + target: exception.target ?? 'value', + enabled: exception.enabled, + }); + this.showDialog.set(true); + } + + closeDialog(): void { + this.showDialog.set(false); + this.editingException.set(null); + } + + saveException(): void { + if (this.exceptionForm.invalid) return; + + this.saving.set(true); + const formValue = this.exceptionForm.value; + const existing = this.editingException(); + + const payload: Partial = { + name: formValue.name, + description: formValue.description || undefined, + matchType: formValue.matchType, + pattern: formValue.pattern, + target: formValue.target, + enabled: formValue.enabled, + }; + + const operation = existing + ? this.exceptionService.updateException(existing.id, payload) + : this.exceptionService.createException(this.tenantId, payload); + + operation.subscribe({ + next: () => { + this.saving.set(false); + this.closeDialog(); + this.showSuccess(existing ? 'Exception updated' : 'Exception created'); + this.loadExceptions(); + }, + error: () => { + this.saving.set(false); + this.showError('Failed to save exception'); + }, + }); + } + + toggleEnabled(exception: SecretExceptionPattern): void { + this.exceptionService.updateException(exception.id, { + enabled: !exception.enabled, + }).subscribe({ + next: () => { + this.loadExceptions(); + }, + error: () => this.showError('Failed to update exception'), + }); + } + + deleteException(exception: SecretExceptionPattern): void { + if (!confirm(`Delete exception "${exception.name}"?`)) return; + + this.exceptionService.deleteException(exception.id).subscribe({ + next: () => { + this.showSuccess('Exception deleted'); + this.loadExceptions(); + }, + error: () => this.showError('Failed to delete exception'), + }); + } + + private showSuccess(message: string): void { + this.snackBar.open(message, 'Close', { + duration: 3000, + panelClass: 'snackbar-success', + }); + } + + private showError(message: string): void { + this.snackBar.open(message, 'Close', { + duration: 5000, + panelClass: 'snackbar-error', + }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/components/exceptions/index.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/exceptions/index.ts new file mode 100644 index 000000000..4eccafbba --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/exceptions/index.ts @@ -0,0 +1,4 @@ +/** + * Barrel export for exception components. + */ +export * from './exception-manager.component'; diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/components/findings/index.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/findings/index.ts new file mode 100644 index 000000000..b7fe7376e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/findings/index.ts @@ -0,0 +1,5 @@ +/** + * Barrel export for findings components. + */ +export * from './secret-findings-list.component'; +export * from './masked-value-display.component'; diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/components/findings/masked-value-display.component.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/findings/masked-value-display.component.ts new file mode 100644 index 000000000..e26cd7478 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/findings/masked-value-display.component.ts @@ -0,0 +1,175 @@ +/** + * Masked Value Display Component. + * + * Displays secret values with masking and optional reveal functionality. + * + * @sprint SPRINT_20260104_008_FE (Secret Detection UI) + * @task SDU-006 - Implement masked value display + */ + +import { Component, Input, Output, EventEmitter, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { Clipboard } from '@angular/cdk/clipboard'; + +import { SecretFindingsService } from '../../services'; +import { inject } from '@angular/core'; + +@Component({ + selector: 'app-masked-value-display', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatIconModule, + MatTooltipModule, + MatProgressSpinnerModule, + ], + template: ` +
+ + {{ displayValue() }} + + +
+ @if (canReveal && !isRevealed()) { + @if (revealing()) { + + } @else { + + } + } + + @if (isRevealed()) { + + } + + +
+
+ `, + styles: [` + .value-display { + display: flex; + align-items: center; + gap: 8px; + } + + .value-text { + font-family: 'Fira Code', 'Consolas', monospace; + font-size: 13px; + padding: 4px 8px; + background: var(--mat-background-card); + border: 1px solid var(--mat-foreground-divider); + border-radius: 4px; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--mat-foreground-secondary-text); + } + + .value-text.revealed { + color: var(--mat-foreground-text); + background-color: var(--mat-warn-50); + border-color: var(--mat-warn-200); + } + + .value-actions { + display: flex; + align-items: center; + gap: 4px; + } + + .value-actions button { + width: 28px; + height: 28px; + line-height: 28px; + } + + .value-actions mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + + mat-spinner { + margin: 5px; + } + `] +}) +export class MaskedValueDisplayComponent { + private readonly findingsService = inject(SecretFindingsService); + private readonly clipboard = inject(Clipboard); + + @Input() value = ''; + @Input() findingId = ''; + @Input() canReveal = false; + + @Output() revealed = new EventEmitter(); + + readonly isRevealed = signal(false); + readonly revealedValue = signal(null); + readonly revealing = signal(false); + readonly copied = signal(false); + + readonly displayValue = () => { + if (this.isRevealed() && this.revealedValue()) { + return this.revealedValue()!; + } + return this.value || '********'; + }; + + reveal(): void { + if (!this.canReveal || !this.findingId) return; + + this.revealing.set(true); + + this.findingsService.revealValue(this.findingId).subscribe({ + next: (value) => { + this.revealedValue.set(value); + this.isRevealed.set(true); + this.revealing.set(false); + this.revealed.emit(value); + + // Auto-hide after 30 seconds for security + setTimeout(() => this.hide(), 30000); + }, + error: () => { + this.revealing.set(false); + }, + }); + } + + hide(): void { + this.isRevealed.set(false); + this.revealedValue.set(null); + } + + copyToClipboard(): void { + const valueToCopy = this.revealedValue() ?? this.value; + if (valueToCopy) { + this.clipboard.copy(valueToCopy); + this.copied.set(true); + + setTimeout(() => this.copied.set(false), 2000); + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/components/findings/secret-findings-list.component.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/findings/secret-findings-list.component.ts new file mode 100644 index 000000000..79d838416 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/findings/secret-findings-list.component.ts @@ -0,0 +1,553 @@ +/** + * Secret Findings List Component. + * + * Displays paginated list of detected secrets with filtering and sorting. + * + * @sprint SPRINT_20260104_008_FE (Secret Detection UI) + * @task SDU-005 - Implement findings list with pagination + */ + +import { Component, inject, OnInit, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterModule, ActivatedRoute, Router } from '@angular/router'; +import { MatTableModule } from '@angular/material/table'; +import { MatSortModule, Sort } from '@angular/material/sort'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatCardModule } from '@angular/material/card'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatMenuModule } from '@angular/material/menu'; + +import { SecretFindingsService } from '../../services'; +import { SecretFinding } from '../../models'; +import { MaskedValueDisplayComponent } from './masked-value-display.component'; + +@Component({ + selector: 'app-secret-findings-list', + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule, + MatTableModule, + MatSortModule, + MatPaginatorModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatButtonModule, + MatIconModule, + MatChipsModule, + MatProgressSpinnerModule, + MatCardModule, + MatTooltipModule, + MatMenuModule, + MaskedValueDisplayComponent, + ], + template: ` +
+
+

Secret Findings

+
+ @if (pagination()) { + {{ pagination()!.totalItems }} findings total + } +
+
+ + + +
+ + Search + + search + + + + Severity + + Critical + High + Medium + Low + + + + + Status + + All + Active + Resolved + Excepted + Suppressed + + + + +
+
+
+ + @if (loading()) { +
+ +
+ } + + @if (error()) { + + + error + {{ error() }} + + + + } + + @if (findings().length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Severity + + {{ finding.severity | titlecase }} + + Type +
+ {{ finding.ruleName ?? finding.ruleId }} + @if (finding.category) { + {{ finding.category }} + } +
+
Location +
+ + {{ truncatePath(finding.filePath) }} + + @if (finding.lineNumber) { + Line {{ finding.lineNumber }} + } +
+
Value + + + Status + + {{ finding.status | titlecase }} + + Detected + {{ finding.detectedAt | date:'short' }} + + + + + @if (finding.status === 'active') { + + + } + +
+
+ + + + } @else if (!loading()) { + + + verified_user +

No Secrets Found

+

No secret findings match your current filters.

+
+
+ } +
+ `, + styles: [` + .findings-container { + max-width: 1400px; + margin: 0 auto; + padding: 24px; + } + + .findings-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + } + + .findings-header h1 { + margin: 0; + font-size: 24px; + font-weight: 500; + } + + .header-stats { + color: var(--mat-foreground-secondary-text); + } + + .filters-card { + margin-bottom: 24px; + } + + .filters { + display: flex; + gap: 16px; + flex-wrap: wrap; + align-items: flex-start; + } + + .filters mat-form-field { + min-width: 200px; + } + + .table-container { + overflow-x: auto; + margin-bottom: 16px; + } + + table { + width: 100%; + } + + .clickable-row { + cursor: pointer; + } + + .clickable-row:hover { + background-color: var(--mat-background-hover); + } + + .rule-info { + display: flex; + flex-direction: column; + gap: 2px; + } + + .rule-name { + font-weight: 500; + } + + .rule-category { + font-size: 12px; + color: var(--mat-foreground-secondary-text); + } + + .location-info { + display: flex; + flex-direction: column; + gap: 2px; + } + + .file-path { + font-size: 13px; + max-width: 250px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .line-number { + font-size: 12px; + color: var(--mat-foreground-secondary-text); + } + + .severity-critical { + background-color: #dc3545 !important; + color: white !important; + } + + .severity-high { + background-color: #fd7e14 !important; + color: white !important; + } + + .severity-medium { + background-color: #ffc107 !important; + color: black !important; + } + + .severity-low { + background-color: #6c757d !important; + color: white !important; + } + + .status-active { + background-color: #dc3545 !important; + color: white !important; + } + + .status-resolved { + background-color: #28a745 !important; + color: white !important; + } + + .status-excepted { + background-color: #17a2b8 !important; + color: white !important; + } + + .status-suppressed { + background-color: #6c757d !important; + color: white !important; + } + + .loading-overlay { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + } + + .error-card { + background-color: var(--mat-warn-50); + margin-bottom: 16px; + } + + .error-card mat-card-content { + display: flex; + align-items: center; + gap: 12px; + } + + .empty-state { + text-align: center; + padding: 48px; + } + + .empty-state mat-icon { + font-size: 64px; + width: 64px; + height: 64px; + color: var(--mat-foreground-hint-text); + } + + .empty-state h2 { + margin: 16px 0 8px; + } + + .empty-state p { + color: var(--mat-foreground-secondary-text); + } + `] +}) +export class SecretFindingsListComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly findingsService = inject(SecretFindingsService); + + readonly tenantId = signal(''); + + // Expose service signals + readonly findings = this.findingsService.findings; + readonly pagination = this.findingsService.pagination; + readonly loading = this.findingsService.loading; + readonly error = this.findingsService.error; + + // Local filter state + readonly searchQuery = signal(''); + readonly severityFilter = signal([]); + readonly statusFilter = signal(''); + readonly sortField = signal('detectedAt'); + readonly sortDirection = signal<'asc' | 'desc'>('desc'); + + readonly displayedColumns = [ + 'severity', + 'ruleId', + 'filePath', + 'maskedValue', + 'status', + 'detectedAt', + 'actions', + ]; + + ngOnInit(): void { + const tenantId = this.route.snapshot.paramMap.get('tenantId') ?? ''; + this.tenantId.set(tenantId); + + if (tenantId) { + this.reload(); + } + } + + reload(): void { + const tid = this.tenantId(); + if (tid) { + this.findingsService.loadFindings(tid, { + page: 1, + pageSize: 25, + search: this.searchQuery(), + severity: this.severityFilter(), + status: this.statusFilter(), + sortBy: this.sortField(), + sortDirection: this.sortDirection(), + }).subscribe(); + } + } + + onSearchChange(event: Event): void { + const input = event.target as HTMLInputElement; + this.searchQuery.set(input.value); + this.reloadWithDebounce(); + } + + onSeverityChange(values: string[]): void { + this.severityFilter.set(values); + this.reload(); + } + + onStatusChange(value: string): void { + this.statusFilter.set(value); + this.reload(); + } + + onSortChange(sort: Sort): void { + this.sortField.set(sort.active); + this.sortDirection.set(sort.direction as 'asc' | 'desc' || 'desc'); + this.reload(); + } + + onPageChange(event: PageEvent): void { + const tid = this.tenantId(); + if (tid) { + this.findingsService.loadFindings(tid, { + page: event.pageIndex + 1, + pageSize: event.pageSize, + search: this.searchQuery(), + severity: this.severityFilter(), + status: this.statusFilter(), + sortBy: this.sortField(), + sortDirection: this.sortDirection(), + }).subscribe(); + } + } + + resetFilters(): void { + this.searchQuery.set(''); + this.severityFilter.set([]); + this.statusFilter.set(''); + this.reload(); + } + + viewDetails(finding: SecretFinding): void { + this.router.navigate(['findings', finding.id], { relativeTo: this.route }); + } + + createException(finding: SecretFinding): void { + this.router.navigate(['exceptions', 'new'], { + relativeTo: this.route, + queryParams: { findingId: finding.id }, + }); + } + + markResolved(finding: SecretFinding): void { + this.findingsService.updateStatus(finding.id, 'resolved').subscribe({ + next: () => this.reload(), + }); + } + + truncatePath(path: string): string { + const maxLen = 40; + if (path.length <= maxLen) return path; + + const parts = path.split('/'); + if (parts.length <= 2) return path; + + return `.../${parts.slice(-2).join('/')}`; + } + + private debounceTimer?: ReturnType; + + private reloadWithDebounce(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => this.reload(), 300); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/components/index.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/index.ts new file mode 100644 index 000000000..183260e93 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/index.ts @@ -0,0 +1,15 @@ +/** + * Barrel export for all components in the secret detection feature. + */ + +// Settings components +export * from './settings'; + +// Findings components +export * from './findings'; + +// Exceptions components +export * from './exceptions'; + +// Alert components +export * from './alerts'; diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/components/settings/index.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/settings/index.ts new file mode 100644 index 000000000..45662db48 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/settings/index.ts @@ -0,0 +1,6 @@ +/** + * Barrel export for settings components. + */ +export * from './secret-detection-settings.component'; +export * from './revelation-policy-selector.component'; +export * from './rule-category-toggles.component'; diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/components/settings/revelation-policy-selector.component.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/settings/revelation-policy-selector.component.ts new file mode 100644 index 000000000..deb8d6837 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/settings/revelation-policy-selector.component.ts @@ -0,0 +1,324 @@ +/** + * Revelation Policy Selector Component. + * + * Configures how detected secrets are revealed/masked in different contexts. + * + * @sprint SPRINT_20260104_008_FE (Secret Detection UI) + * @task SDU-003 - Implement revelation policy selector + */ + +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSliderModule } from '@angular/material/slider'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +import { RevelationPolicyConfig } from '../../models'; + +type RevelationMode = 'masked' | 'partial' | 'full' | 'redacted'; + +@Component({ + selector: 'app-revelation-policy-selector', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatRadioModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatSliderModule, + MatCardModule, + MatIconModule, + MatTooltipModule, + ], + template: ` +
+ + + + + Fully Masked + info + + +

All secret values are replaced with asterisks or a placeholder.

+
+ {{ getMaskedPreview() }} +
+ + @if (config.mode === 'masked') { +
+ + Mask Character + + + + + Mask Length + + 4 characters + 8 characters + 16 characters + Match original length + + +
+ } +
+
+ + + + + + Partially Revealed + info + + +

Show a portion of the secret to aid identification while hiding the full value.

+
+ {{ getPartialPreview() }} +
+ + @if (config.mode === 'partial') { +
+
+ + + + +
+ +
+ + + + +
+
+ } +
+
+ + + + + + Full Revelation + warning + + +

+ Warning: Secrets are shown in full. Use only in secure, + controlled environments. Requires elevated permissions. +

+
+ {{ getFullPreview() }} +
+ + @if (config.mode === 'full') { +
+ + Require Permission + + Administrator + Security Lead + Auditor + + +
+ } +
+
+ + + + + + Redacted + info + + +

Secret values are not stored or displayed. Only the location and type are recorded.

+
+ {{ getRedactedPreview() }} +
+
+
+
+ `, + styles: [` + .policy-selector { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + } + + .policy-option { + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; + border: 2px solid transparent; + } + + .policy-option:hover { + border-color: var(--mat-primary-100); + } + + .policy-option.selected { + border-color: var(--mat-primary-500); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + } + + .policy-option mat-card-header { + display: flex; + align-items: center; + gap: 8px; + } + + .policy-option mat-card-title { + flex: 1; + font-size: 16px; + margin: 0; + } + + .policy-option p { + font-size: 14px; + color: var(--mat-foreground-secondary-text); + margin: 0 0 12px 0; + } + + .preview { + background: var(--mat-background-card); + border: 1px solid var(--mat-foreground-divider); + border-radius: 4px; + padding: 8px 12px; + font-family: monospace; + font-size: 13px; + margin-bottom: 16px; + } + + .preview code { + color: var(--mat-foreground-text); + } + + .option-controls { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 8px; + border-top: 1px solid var(--mat-foreground-divider); + } + + .slider-control { + display: flex; + flex-direction: column; + gap: 4px; + } + + .slider-control label { + font-size: 13px; + color: var(--mat-foreground-secondary-text); + } + + .warning-text { + color: var(--mat-warn-500) !important; + } + + mat-form-field { + width: 100%; + } + `] +}) +export class RevelationPolicySelectorComponent { + @Input() config: RevelationPolicyConfig = { mode: 'masked' }; + @Output() configChange = new EventEmitter(); + + private readonly sampleSecret = 'ghp_abc123XYZ789secret'; + + setMode(mode: RevelationMode): void { + this.emitChange({ ...this.config, mode }); + } + + onMaskCharChange(event: Event): void { + const input = event.target as HTMLInputElement; + const maskChar = input.value || '*'; + this.emitChange({ ...this.config, maskChar }); + } + + onMaskLengthChange(length: number): void { + this.emitChange({ ...this.config, maskLength: length }); + } + + onRevealFirstChange(value: number): void { + this.emitChange({ ...this.config, revealFirst: value }); + } + + onRevealLastChange(value: number): void { + this.emitChange({ ...this.config, revealLast: value }); + } + + onPermissionChange(permission: string): void { + this.emitChange({ ...this.config, requiredPermission: permission }); + } + + private emitChange(newConfig: RevelationPolicyConfig): void { + this.config = newConfig; + this.configChange.emit(newConfig); + } + + getMaskedPreview(): string { + const char = this.config.maskChar ?? '*'; + const len = this.config.maskLength ?? 8; + const actualLen = len === 0 ? this.sampleSecret.length : len; + return char.repeat(actualLen); + } + + getPartialPreview(): string { + const first = this.config.revealFirst ?? 4; + const last = this.config.revealLast ?? 0; + const masked = '*'.repeat(8); + + const prefix = this.sampleSecret.substring(0, first); + const suffix = last > 0 ? this.sampleSecret.substring(this.sampleSecret.length - last) : ''; + + return `${prefix}${masked}${suffix}`; + } + + getFullPreview(): string { + return this.sampleSecret; + } + + getRedactedPreview(): string { + return '[REDACTED]'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/components/settings/rule-category-toggles.component.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/settings/rule-category-toggles.component.ts new file mode 100644 index 000000000..3090305c7 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/settings/rule-category-toggles.component.ts @@ -0,0 +1,335 @@ +/** + * Rule Category Toggles Component. + * + * Displays available secret detection rule categories with toggle controls. + * + * @sprint SPRINT_20260104_008_FE (Secret Detection UI) + * @task SDU-004 - Implement rule category toggles + */ + +import { Component, Input, Output, EventEmitter, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatBadgeModule } from '@angular/material/badge'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +import { SecretRuleCategory } from '../../models'; + +@Component({ + selector: 'app-rule-category-toggles', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatCheckboxModule, + MatExpansionModule, + MatChipsModule, + MatIconModule, + MatButtonModule, + MatBadgeModule, + MatTooltipModule, + ], + template: ` +
+
+
+ {{ selectedCount() }} of {{ categories.length }} categories selected +
+
+ + +
+
+ + + @for (group of groupedCategories(); track group.groupName) { + + + + + + + {{ getGroupIcon(group.groupName) }} + + {{ group.groupName }} + + + {{ getGroupSelectedCount(group.groupName) }} / {{ group.categories.length }} enabled + + + +
+ @for (category of group.categories; track category.id) { +
+ +
+ {{ category.name }} + {{ category.description }} +
+
+ +
+ + + {{ category.ruleCount }} rules + + @if (category.severity) { + + {{ category.severity }} + + } + +
+
+ } +
+
+ } +
+ + @if (categories.length === 0) { +
+ category +

No rule categories available.

+
+ } +
+ `, + styles: [` + .category-toggles { + display: flex; + flex-direction: column; + gap: 16px; + } + + .toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + } + + .selection-info { + font-size: 14px; + color: var(--mat-foreground-secondary-text); + } + + .toolbar-actions { + display: flex; + gap: 8px; + } + + mat-expansion-panel-header mat-checkbox { + margin-right: 12px; + } + + mat-expansion-panel-header mat-icon { + margin-right: 8px; + } + + .category-list { + display: flex; + flex-direction: column; + gap: 12px; + padding: 8px 0; + } + + .category-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 8px 12px; + border-radius: 4px; + background: var(--mat-background-card); + border: 1px solid var(--mat-foreground-divider); + } + + .category-info { + display: flex; + flex-direction: column; + gap: 4px; + } + + .category-name { + font-weight: 500; + } + + .category-description { + font-size: 13px; + color: var(--mat-foreground-secondary-text); + } + + .category-meta { + flex-shrink: 0; + } + + .severity-critical { + background-color: #dc3545 !important; + color: white !important; + } + + .severity-high { + background-color: #fd7e14 !important; + color: white !important; + } + + .severity-medium { + background-color: #ffc107 !important; + color: black !important; + } + + .severity-low { + background-color: #6c757d !important; + color: white !important; + } + + .empty-state { + text-align: center; + padding: 32px; + color: var(--mat-foreground-hint-text); + } + + .empty-state mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + } + `] +}) +export class RuleCategoryTogglesComponent { + @Input() categories: SecretRuleCategory[] = []; + @Input() selected: string[] = []; + @Output() selectionChange = new EventEmitter(); + + private readonly selectedSet = signal(new Set()); + + readonly selectedCount = computed(() => this.selectedSet().size); + + readonly groupedCategories = computed(() => { + const groups = new Map(); + + for (const cat of this.categories) { + const groupName = cat.group ?? 'Other'; + const existing = groups.get(groupName) ?? []; + existing.push(cat); + groups.set(groupName, existing); + } + + return Array.from(groups.entries()) + .map(([groupName, categories]) => ({ groupName, categories })) + .sort((a, b) => a.groupName.localeCompare(b.groupName)); + }); + + ngOnChanges(): void { + this.selectedSet.set(new Set(this.selected)); + } + + isSelected(categoryId: string): boolean { + return this.selectedSet().has(categoryId); + } + + isGroupSelected(groupName: string): boolean { + const group = this.groupedCategories().find(g => g.groupName === groupName); + if (!group) return false; + return group.categories.every(c => this.isSelected(c.id)); + } + + isGroupPartial(groupName: string): boolean { + const group = this.groupedCategories().find(g => g.groupName === groupName); + if (!group) return false; + + const selectedCount = group.categories.filter(c => this.isSelected(c.id)).length; + return selectedCount > 0 && selectedCount < group.categories.length; + } + + getGroupSelectedCount(groupName: string): number { + const group = this.groupedCategories().find(g => g.groupName === groupName); + if (!group) return 0; + return group.categories.filter(c => this.isSelected(c.id)).length; + } + + toggleCategory(categoryId: string, checked: boolean): void { + const current = new Set(this.selectedSet()); + if (checked) { + current.add(categoryId); + } else { + current.delete(categoryId); + } + this.selectedSet.set(current); + this.emitSelection(); + } + + toggleGroup(groupName: string, checked: boolean): void { + const group = this.groupedCategories().find(g => g.groupName === groupName); + if (!group) return; + + const current = new Set(this.selectedSet()); + for (const cat of group.categories) { + if (checked) { + current.add(cat.id); + } else { + current.delete(cat.id); + } + } + this.selectedSet.set(current); + this.emitSelection(); + } + + selectAll(): void { + const all = new Set(this.categories.map(c => c.id)); + this.selectedSet.set(all); + this.emitSelection(); + } + + clearAll(): void { + this.selectedSet.set(new Set()); + this.emitSelection(); + } + + private emitSelection(): void { + this.selectionChange.emit(Array.from(this.selectedSet())); + } + + getGroupIcon(groupName: string): string { + const iconMap: Record = { + 'API Keys': 'vpn_key', + 'Tokens': 'token', + 'Certificates': 'verified_user', + 'Passwords': 'password', + 'Private Keys': 'key', + 'Credentials': 'lock', + 'Cloud': 'cloud', + 'Database': 'storage', + 'Other': 'category', + }; + return iconMap[groupName] ?? 'category'; + } + + getGroupColor(groupName: string): string { + const colorMap: Record = { + 'API Keys': '#1976d2', + 'Tokens': '#7b1fa2', + 'Certificates': '#388e3c', + 'Passwords': '#d32f2f', + 'Private Keys': '#f57c00', + 'Credentials': '#455a64', + 'Cloud': '#0288d1', + 'Database': '#5d4037', + 'Other': '#757575', + }; + return colorMap[groupName] ?? '#757575'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/components/settings/secret-detection-settings.component.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/settings/secret-detection-settings.component.ts new file mode 100644 index 000000000..418b87aed --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/components/settings/secret-detection-settings.component.ts @@ -0,0 +1,328 @@ +/** + * Secret Detection Settings Page Component. + * + * Main settings page for configuring secret detection per tenant. + * + * @sprint SPRINT_20260104_008_FE (Secret Detection UI) + * @task SDU-002 - Build settings page component + */ + +import { Component, inject, OnInit, computed, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { ActivatedRoute } from '@angular/router'; + +import { SecretDetectionSettingsService } from '../../services'; +import { RevelationPolicySelectorComponent } from '../settings/revelation-policy-selector.component'; +import { RuleCategoryTogglesComponent } from '../settings/rule-category-toggles.component'; +import { ExceptionManagerComponent } from '../exceptions/exception-manager.component'; +import { AlertDestinationConfigComponent } from '../alerts/alert-destination-config.component'; +import { RevelationPolicyConfig, SecretAlertSettings } from '../../models'; + +@Component({ + selector: 'app-secret-detection-settings', + standalone: true, + imports: [ + CommonModule, + MatTabsModule, + MatSlideToggleModule, + MatProgressSpinnerModule, + MatCardModule, + MatIconModule, + MatButtonModule, + MatSnackBarModule, + RevelationPolicySelectorComponent, + RuleCategoryTogglesComponent, + ExceptionManagerComponent, + AlertDestinationConfigComponent, + ], + template: ` +
+
+
+ security +

Secret Detection

+
+ +
+ @if (loading()) { + + } + + + {{ isEnabled() ? 'Enabled' : 'Disabled' }} + +
+
+ + @if (error()) { + + + error + {{ error() }} + + + + } + + @if (settings(); as s) { + + +
+
+

Revelation Policy

+

+ Control how detected secrets are displayed in different contexts. +

+ + +
+ +
+

Rule Categories

+

+ Select which types of secrets to detect. +

+ + +
+
+
+ + +
+ + +
+
+ + +
+ + +
+
+
+ } @else if (!loading()) { + + + settings +

No Settings Found

+

Secret detection has not been configured for this tenant.

+ +
+
+ } +
+ `, + styles: [` + .settings-container { + max-width: 1200px; + margin: 0 auto; + padding: 24px; + } + + .settings-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + } + + .title-section { + display: flex; + align-items: center; + gap: 12px; + } + + .title-section mat-icon { + font-size: 32px; + width: 32px; + height: 32px; + color: var(--mat-primary-500); + } + + .title-section h1 { + margin: 0; + font-size: 24px; + font-weight: 500; + } + + .header-actions { + display: flex; + align-items: center; + gap: 16px; + } + + .tab-content { + padding: 24px 0; + } + + .section { + margin-bottom: 32px; + } + + .section h2 { + font-size: 18px; + font-weight: 500; + margin-bottom: 8px; + } + + .section-description { + color: var(--mat-foreground-secondary-text); + margin-bottom: 16px; + } + + .error-card { + background-color: var(--mat-warn-50); + margin-bottom: 16px; + } + + .error-card mat-card-content { + display: flex; + align-items: center; + gap: 12px; + } + + .empty-state { + text-align: center; + padding: 48px; + } + + .empty-state mat-icon { + font-size: 64px; + width: 64px; + height: 64px; + color: var(--mat-foreground-hint-text); + } + + .empty-state h2 { + margin: 16px 0 8px; + } + + .empty-state p { + color: var(--mat-foreground-secondary-text); + margin-bottom: 24px; + } + `] +}) +export class SecretDetectionSettingsComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly settingsService = inject(SecretDetectionSettingsService); + private readonly snackBar = inject(MatSnackBar); + + // Get tenant ID from route + readonly tenantId = signal(''); + + // Expose service signals + readonly settings = this.settingsService.settings; + readonly categories = this.settingsService.categories; + readonly loading = this.settingsService.loading; + readonly error = this.settingsService.error; + readonly isEnabled = this.settingsService.isEnabled; + + ngOnInit(): void { + const tenantId = this.route.snapshot.paramMap.get('tenantId') ?? ''; + this.tenantId.set(tenantId); + + if (tenantId) { + this.reload(); + } + } + + reload(): void { + const tid = this.tenantId(); + if (tid) { + this.settingsService.loadSettings(tid).subscribe(); + this.settingsService.loadCategories().subscribe(); + } + } + + initializeSettings(): void { + const tid = this.tenantId(); + if (tid) { + this.settingsService.createSettings(tid).subscribe({ + next: () => this.showSuccess('Settings initialized'), + error: () => this.showError('Failed to initialize settings'), + }); + } + } + + onEnabledToggle(enabled: boolean): void { + const tid = this.tenantId(); + if (tid) { + this.settingsService.toggleEnabled(tid, enabled).subscribe({ + next: () => this.showSuccess(enabled ? 'Secret detection enabled' : 'Secret detection disabled'), + error: () => this.showError('Failed to update setting'), + }); + } + } + + onPolicyChange(policy: RevelationPolicyConfig): void { + const tid = this.tenantId(); + if (tid) { + this.settingsService.updateSettings(tid, { revelationPolicy: policy }).subscribe({ + next: () => this.showSuccess('Revelation policy updated'), + error: () => this.showError('Failed to update policy'), + }); + } + } + + onCategoriesChange(categoryIds: string[]): void { + const tid = this.tenantId(); + if (tid) { + this.settingsService.updateCategories(tid, categoryIds).subscribe({ + next: () => this.showSuccess('Rule categories updated'), + error: () => this.showError('Failed to update categories'), + }); + } + } + + onAlertSettingsChange(alertSettings: SecretAlertSettings): void { + const tid = this.tenantId(); + if (tid) { + this.settingsService.updateSettings(tid, { alertSettings }).subscribe({ + next: () => this.showSuccess('Alert settings updated'), + error: () => this.showError('Failed to update alert settings'), + }); + } + } + + private showSuccess(message: string): void { + this.snackBar.open(message, 'Close', { + duration: 3000, + panelClass: 'snackbar-success', + }); + } + + private showError(message: string): void { + this.snackBar.open(message, 'Close', { + duration: 5000, + panelClass: 'snackbar-error', + }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/index.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/index.ts new file mode 100644 index 000000000..b09d20024 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/index.ts @@ -0,0 +1,20 @@ +/** + * Secret Detection Feature Module. + * + * Main barrel export for the secret detection feature. + * + * @sprint SPRINT_20260104_008_FE (Secret Detection UI) + * @task SDU-001 - Feature module structure + */ + +// Models +export * from './models'; + +// Services +export * from './services'; + +// Components +export * from './components'; + +// Routes +export * from './secret-detection.routes'; diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/models/index.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/models/index.ts new file mode 100644 index 000000000..b5bdc297d --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/models/index.ts @@ -0,0 +1,7 @@ +/** + * Models barrel export. + * + * @sprint SPRINT_20260104_008_FE + * @task SDU-001 + */ +export * from './secret-detection.models'; diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/models/secret-detection.models.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/models/secret-detection.models.ts new file mode 100644 index 000000000..c455d6266 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/models/secret-detection.models.ts @@ -0,0 +1,144 @@ +/** + * Secret Detection domain models. + * + * @sprint SPRINT_20260104_008_FE (Secret Detection UI) + * @task SDU-001 - Create secret-detection feature module + */ + +/** + * Per-tenant secret detection configuration. + */ +export interface SecretDetectionSettings { + tenantId: string; + enabled: boolean; + revelationPolicy: RevelationPolicyConfig; + enabledRuleCategories: string[]; + exceptions: SecretExceptionPattern[]; + alertSettings: SecretAlertSettings; + updatedAt: string; + updatedBy: string; +} + +/** + * Revelation policy for how secrets are displayed. + */ +export type SecretRevelationPolicy = 'FullMask' | 'PartialReveal' | 'FullReveal'; + +/** + * Configuration for revelation policies by context. + */ +export interface RevelationPolicyConfig { + defaultPolicy: SecretRevelationPolicy; + exportPolicy: SecretRevelationPolicy; + logPolicy: SecretRevelationPolicy; + fullRevealRoles: string[]; + partialRevealChars: number; +} + +/** + * Exception pattern for allowlisting false positives. + */ +export interface SecretExceptionPattern { + id: string; + pattern: string; + reason: string; + createdBy: string; + createdAt: string; + expiresAt?: string; + ruleIds?: string[]; + pathFilter?: string; +} + +/** + * Alert configuration for secret findings. + */ +export interface SecretAlertSettings { + enabled: boolean; + minimumAlertSeverity: SecretSeverity; + destinations: SecretAlertDestination[]; + maxAlertsPerScan: number; + deduplicationWindowHours: number; + includeFilePath: boolean; + includeMaskedValue: boolean; + includeImageRef: boolean; + alertMessagePrefix?: string; +} + +/** + * Alert destination configuration. + */ +export interface SecretAlertDestination { + id: string; + name: string; + channelType: AlertChannelType; + channelId: string; + severityFilter?: SecretSeverity[]; + ruleCategoryFilter?: string[]; +} + +/** + * Alert channel types. + */ +export type AlertChannelType = 'Slack' | 'Teams' | 'Email' | 'Webhook' | 'PagerDuty'; + +/** + * Severity levels for secret findings. + */ +export type SecretSeverity = 'Low' | 'Medium' | 'High' | 'Critical'; + +/** + * Secret finding from a scan. + */ +export interface SecretFinding { + id: string; + scanId: string; + imageRef: string; + severity: SecretSeverity; + ruleId: string; + ruleName: string; + ruleCategory: string; + filePath: string; + lineNumber: number; + maskedValue: string; + detectedAt: string; + status: SecretFindingStatus; + excepted: boolean; + exceptionId?: string; +} + +/** + * Status of a secret finding. + */ +export type SecretFindingStatus = 'New' | 'Acknowledged' | 'Resolved' | 'FalsePositive'; + +/** + * Available rule categories for secret detection. + */ +export interface SecretRuleCategory { + id: string; + name: string; + description: string; + ruleCount: number; + enabled: boolean; +} + +/** + * DTO for creating an exception pattern. + */ +export interface CreateExceptionRequest { + pattern: string; + reason: string; + expiresAt?: string; + ruleIds?: string[]; + pathFilter?: string; +} + +/** + * DTO for updating settings. + */ +export interface UpdateSettingsRequest { + enabled?: boolean; + revelationPolicy?: Partial; + enabledRuleCategories?: string[]; + alertSettings?: Partial; +} diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-detection.routes.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-detection.routes.ts new file mode 100644 index 000000000..bc1b6ca71 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-detection.routes.ts @@ -0,0 +1,58 @@ +/** + * Secret Detection Feature Routes. + * + * Defines routing configuration for the secret detection feature module. + * + * @sprint SPRINT_20260104_008_FE (Secret Detection UI) + * @task SDU-001 - Feature module structure + */ + +import { Routes } from '@angular/router'; + +export const SECRET_DETECTION_ROUTES: Routes = [ + { + path: '', + children: [ + { + path: '', + redirectTo: 'settings', + pathMatch: 'full', + }, + { + path: 'settings', + loadComponent: () => + import('./components/settings/secret-detection-settings.component') + .then(m => m.SecretDetectionSettingsComponent), + title: 'Secret Detection Settings', + }, + { + path: 'findings', + loadComponent: () => + import('./components/findings/secret-findings-list.component') + .then(m => m.SecretFindingsListComponent), + title: 'Secret Findings', + }, + { + path: 'findings/:findingId', + loadComponent: () => + import('./components/findings/secret-findings-list.component') + .then(m => m.SecretFindingsListComponent), + title: 'Finding Details', + }, + { + path: 'exceptions', + loadComponent: () => + import('./components/exceptions/exception-manager.component') + .then(m => m.ExceptionManagerComponent), + title: 'Secret Exceptions', + }, + { + path: 'exceptions/new', + loadComponent: () => + import('./components/exceptions/exception-manager.component') + .then(m => m.ExceptionManagerComponent), + title: 'New Exception', + }, + ], + }, +]; diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/services/index.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/services/index.ts new file mode 100644 index 000000000..cf4de069f --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/services/index.ts @@ -0,0 +1,9 @@ +/** + * Services barrel export. + * + * @sprint SPRINT_20260104_008_FE + * @task SDU-001 + */ +export * from './secret-detection-settings.service'; +export * from './secret-exception.service'; +export * from './secret-findings.service'; diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/services/secret-detection-settings.service.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/services/secret-detection-settings.service.ts new file mode 100644 index 000000000..99206f882 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/services/secret-detection-settings.service.ts @@ -0,0 +1,177 @@ +/** + * Secret Detection Settings API Service. + * + * @sprint SPRINT_20260104_008_FE (Secret Detection UI) + * @task SDU-001 - Create secret-detection feature module + */ + +import { Injectable, inject, signal, computed } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { catchError, map, tap } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { + SecretDetectionSettings, + SecretExceptionPattern, + SecretRuleCategory, + CreateExceptionRequest, + UpdateSettingsRequest, + SecretFinding, +} from '../models'; + +/** + * API service for secret detection configuration. + * Communicates with Scanner WebService endpoints. + */ +@Injectable({ providedIn: 'root' }) +export class SecretDetectionSettingsService { + private readonly http = inject(HttpClient); + private readonly baseUrl = '/api/v1/secrets/config'; + + // State signals + private readonly _settings = signal(null); + private readonly _categories = signal([]); + private readonly _loading = signal(false); + private readonly _error = signal(null); + + // Public readonly signals + readonly settings = this._settings.asReadonly(); + readonly categories = this._categories.asReadonly(); + readonly loading = this._loading.asReadonly(); + readonly error = this._error.asReadonly(); + + // Computed values + readonly isEnabled = computed(() => this._settings()?.enabled ?? false); + readonly enabledCategories = computed(() => + this._settings()?.enabledRuleCategories ?? [] + ); + readonly exceptions = computed(() => + this._settings()?.exceptions ?? [] + ); + readonly alertSettings = computed(() => + this._settings()?.alertSettings ?? null + ); + + /** + * Loads settings for a tenant. + */ + loadSettings(tenantId: string): Observable { + this._loading.set(true); + this._error.set(null); + + return this.http.get( + `${this.baseUrl}/settings/${tenantId}` + ).pipe( + tap(settings => { + this._settings.set(settings); + this._loading.set(false); + }), + catchError(err => { + this._error.set(err.message || 'Failed to load settings'); + this._loading.set(false); + throw err; + }) + ); + } + + /** + * Creates default settings for a new tenant. + */ + createSettings(tenantId: string): Observable { + this._loading.set(true); + this._error.set(null); + + return this.http.post( + `${this.baseUrl}/settings/${tenantId}`, + {} + ).pipe( + tap(settings => { + this._settings.set(settings); + this._loading.set(false); + }), + catchError(err => { + this._error.set(err.message || 'Failed to create settings'); + this._loading.set(false); + throw err; + }) + ); + } + + /** + * Updates settings for a tenant. + */ + updateSettings( + tenantId: string, + request: UpdateSettingsRequest + ): Observable { + this._loading.set(true); + this._error.set(null); + + return this.http.put( + `${this.baseUrl}/settings/${tenantId}`, + request + ).pipe( + tap(settings => { + this._settings.set(settings); + this._loading.set(false); + }), + catchError(err => { + this._error.set(err.message || 'Failed to update settings'); + this._loading.set(false); + throw err; + }) + ); + } + + /** + * Toggles secret detection on/off. + */ + toggleEnabled(tenantId: string, enabled: boolean): Observable { + return this.updateSettings(tenantId, { enabled }); + } + + /** + * Loads available rule categories. + */ + loadCategories(): Observable { + return this.http.get( + `${this.baseUrl}/rules/categories` + ).pipe( + tap(categories => this._categories.set(categories)), + catchError(err => { + this._error.set(err.message || 'Failed to load categories'); + return of([]); + }) + ); + } + + /** + * Updates enabled rule categories. + */ + updateCategories( + tenantId: string, + categoryIds: string[] + ): Observable { + return this.updateSettings(tenantId, { enabledRuleCategories: categoryIds }); + } + + /** + * Sends a test alert to verify destination configuration. + * @sprint SDU-011 - Add channel test functionality + */ + testAlertDestination( + tenantId: string, + destinationId: string + ): Observable<{ success: boolean; message: string }> { + return this.http.post<{ success: boolean; message: string }>( + `${this.baseUrl}/settings/${tenantId}/alerts/test`, + { destinationId } + ).pipe( + catchError(err => { + return of({ + success: false, + message: err.error?.message || err.message || 'Test failed', + }); + }) + ); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/services/secret-exception.service.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/services/secret-exception.service.ts new file mode 100644 index 000000000..5d998ac37 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/services/secret-exception.service.ts @@ -0,0 +1,152 @@ +/** + * Secret Exception API Service. + * + * @sprint SPRINT_20260104_008_FE (Secret Detection UI) + * @task SDU-008, SDU-009 - Exception manager + */ + +import { Injectable, inject, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { catchError, tap } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { + SecretExceptionPattern, + CreateExceptionRequest, +} from '../models'; + +/** + * API service for secret exception patterns. + */ +@Injectable({ providedIn: 'root' }) +export class SecretExceptionService { + private readonly http = inject(HttpClient); + private readonly baseUrl = '/api/v1/secrets/config/exceptions'; + + private readonly _exceptions = signal([]); + private readonly _loading = signal(false); + private readonly _error = signal(null); + + readonly exceptions = this._exceptions.asReadonly(); + readonly loading = this._loading.asReadonly(); + readonly error = this._error.asReadonly(); + + /** + * Lists all exception patterns for a tenant. + */ + listExceptions(tenantId: string): Observable { + this._loading.set(true); + this._error.set(null); + + return this.http.get<{ items: SecretExceptionPattern[] }>( + `${this.baseUrl}/${tenantId}` + ).pipe( + tap(response => { + this._exceptions.set(response.items); + this._loading.set(false); + }), + catchError(err => { + this._error.set(err.message || 'Failed to load exceptions'); + this._loading.set(false); + return of({ items: [] }); + }), + // Transform response + tap(() => {}), + // Return just the items array + tap(response => this._exceptions.set(response.items)) + ); + } + + /** + * Creates a new exception pattern. + */ + createException( + tenantId: string, + request: CreateExceptionRequest + ): Observable { + this._loading.set(true); + this._error.set(null); + + return this.http.post( + `${this.baseUrl}/${tenantId}`, + request + ).pipe( + tap(exception => { + // Add to local state + this._exceptions.update(current => [...current, exception]); + this._loading.set(false); + }), + catchError(err => { + this._error.set(err.message || 'Failed to create exception'); + this._loading.set(false); + throw err; + }) + ); + } + + /** + * Updates an existing exception pattern. + */ + updateException( + tenantId: string, + exceptionId: string, + request: Partial + ): Observable { + this._loading.set(true); + this._error.set(null); + + return this.http.put( + `${this.baseUrl}/${tenantId}/${exceptionId}`, + request + ).pipe( + tap(updated => { + // Update in local state + this._exceptions.update(current => + current.map(e => e.id === exceptionId ? updated : e) + ); + this._loading.set(false); + }), + catchError(err => { + this._error.set(err.message || 'Failed to update exception'); + this._loading.set(false); + throw err; + }) + ); + } + + /** + * Deletes an exception pattern. + */ + deleteException(tenantId: string, exceptionId: string): Observable { + this._loading.set(true); + this._error.set(null); + + return this.http.delete( + `${this.baseUrl}/${tenantId}/${exceptionId}` + ).pipe( + tap(() => { + // Remove from local state + this._exceptions.update(current => + current.filter(e => e.id !== exceptionId) + ); + this._loading.set(false); + }), + catchError(err => { + this._error.set(err.message || 'Failed to delete exception'); + this._loading.set(false); + throw err; + }) + ); + } + + /** + * Validates an exception pattern without saving. + */ + validatePattern(pattern: string): Observable<{ valid: boolean; error?: string }> { + return this.http.post<{ valid: boolean; error?: string }>( + `${this.baseUrl}/validate`, + { pattern } + ).pipe( + catchError(() => of({ valid: false, error: 'Validation service unavailable' })) + ); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/services/secret-findings.service.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/services/secret-findings.service.ts new file mode 100644 index 000000000..71402c790 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/services/secret-findings.service.ts @@ -0,0 +1,173 @@ +/** + * Secret Findings API Service. + * + * @sprint SPRINT_20260104_008_FE (Secret Detection UI) + * @task SDU-005 - Create findings list component + */ + +import { Injectable, inject, signal, computed } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { catchError, tap } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { + SecretFinding, + SecretSeverity, + SecretFindingStatus, +} from '../models'; + +/** + * Query parameters for listing findings. + */ +export interface FindingsQuery { + scanId?: string; + imageRef?: string; + severity?: SecretSeverity[]; + status?: SecretFindingStatus[]; + ruleCategory?: string[]; + excepted?: boolean; + page?: number; + pageSize?: number; +} + +/** + * Paginated response for findings. + */ +export interface FindingsResponse { + items: SecretFinding[]; + total: number; + page: number; + pageSize: number; +} + +/** + * API service for secret findings. + */ +@Injectable({ providedIn: 'root' }) +export class SecretFindingsService { + private readonly http = inject(HttpClient); + private readonly baseUrl = '/api/v1/secrets/findings'; + + private readonly _findings = signal([]); + private readonly _selectedFinding = signal(null); + private readonly _loading = signal(false); + private readonly _error = signal(null); + private readonly _total = signal(0); + private readonly _page = signal(1); + private readonly _pageSize = signal(25); + + readonly findings = this._findings.asReadonly(); + readonly selectedFinding = this._selectedFinding.asReadonly(); + readonly loading = this._loading.asReadonly(); + readonly error = this._error.asReadonly(); + readonly total = this._total.asReadonly(); + readonly page = this._page.asReadonly(); + readonly pageSize = this._pageSize.asReadonly(); + + // Computed statistics + readonly stats = computed(() => { + const findings = this._findings(); + return { + total: findings.length, + critical: findings.filter(f => f.severity === 'Critical').length, + high: findings.filter(f => f.severity === 'High').length, + medium: findings.filter(f => f.severity === 'Medium').length, + low: findings.filter(f => f.severity === 'Low').length, + excepted: findings.filter(f => f.excepted).length, + }; + }); + + /** + * Lists findings with optional filters. + */ + listFindings(tenantId: string, query?: FindingsQuery): Observable { + this._loading.set(true); + this._error.set(null); + + let params = new HttpParams(); + if (query?.scanId) params = params.set('scanId', query.scanId); + if (query?.imageRef) params = params.set('imageRef', query.imageRef); + if (query?.severity?.length) params = params.set('severity', query.severity.join(',')); + if (query?.status?.length) params = params.set('status', query.status.join(',')); + if (query?.ruleCategory?.length) params = params.set('ruleCategory', query.ruleCategory.join(',')); + if (query?.excepted !== undefined) params = params.set('excepted', String(query.excepted)); + if (query?.page) params = params.set('page', String(query.page)); + if (query?.pageSize) params = params.set('pageSize', String(query.pageSize)); + + return this.http.get( + `${this.baseUrl}/${tenantId}`, + { params } + ).pipe( + tap(response => { + this._findings.set(response.items); + this._total.set(response.total); + this._page.set(response.page); + this._pageSize.set(response.pageSize); + this._loading.set(false); + }), + catchError(err => { + this._error.set(err.message || 'Failed to load findings'); + this._loading.set(false); + return of({ items: [], total: 0, page: 1, pageSize: 25 }); + }) + ); + } + + /** + * Gets a single finding by ID. + */ + getFinding(tenantId: string, findingId: string): Observable { + this._loading.set(true); + + return this.http.get( + `${this.baseUrl}/${tenantId}/${findingId}` + ).pipe( + tap(finding => { + this._selectedFinding.set(finding); + this._loading.set(false); + }), + catchError(err => { + this._error.set(err.message || 'Failed to load finding'); + this._loading.set(false); + throw err; + }) + ); + } + + /** + * Updates finding status. + */ + updateStatus( + tenantId: string, + findingId: string, + status: SecretFindingStatus + ): Observable { + return this.http.patch( + `${this.baseUrl}/${tenantId}/${findingId}/status`, + { status } + ).pipe( + tap(updated => { + // Update in local state + this._findings.update(current => + current.map(f => f.id === findingId ? updated : f) + ); + if (this._selectedFinding()?.id === findingId) { + this._selectedFinding.set(updated); + } + }) + ); + } + + /** + * Selects a finding for detail view. + */ + selectFinding(finding: SecretFinding | null): void { + this._selectedFinding.set(finding); + } + + /** + * Clears current selection. + */ + clearSelection(): void { + this._selectedFinding.set(null); + } +}