From 1f33143bd1e6d62ac684483069c5798661cefd75 Mon Sep 17 00:00:00 2001 From: StellaOps Bot Date: Sun, 4 Jan 2026 15:12:28 +0200 Subject: [PATCH] feat(secrets): implement ISecretEvidenceProvider and SecretEvidenceContext for secret leak evaluation --- .../Secrets/ISecretEvidenceProvider.cs | 113 +++++++ .../Secrets/SecretEvidenceContext.cs | 285 ++++++++++++++++++ 2 files changed, 398 insertions(+) create mode 100644 src/Policy/__Libraries/StellaOps.Policy/Secrets/ISecretEvidenceProvider.cs create mode 100644 src/Policy/__Libraries/StellaOps.Policy/Secrets/SecretEvidenceContext.cs diff --git a/src/Policy/__Libraries/StellaOps.Policy/Secrets/ISecretEvidenceProvider.cs b/src/Policy/__Libraries/StellaOps.Policy/Secrets/ISecretEvidenceProvider.cs new file mode 100644 index 000000000..60a0dde62 --- /dev/null +++ b/src/Policy/__Libraries/StellaOps.Policy/Secrets/ISecretEvidenceProvider.cs @@ -0,0 +1,113 @@ +// ----------------------------------------------------------------------------- +// ISecretEvidenceProvider.cs +// Sprint: SPRINT_20260104_004_POLICY (Secret DSL Integration) +// Task: PSD-001 - Define ISecretEvidenceProvider interface +// ----------------------------------------------------------------------------- + +using System.Collections.Immutable; + +namespace StellaOps.Policy.Secrets; + +/// +/// Provides secret leak evidence for policy evaluation. +/// This interface abstracts the source of secret findings, allowing the policy +/// engine to evaluate secret-related rules without direct dependency on Scanner. +/// +public interface ISecretEvidenceProvider +{ + /// + /// Gets all secret findings for the current evaluation context. + /// + /// A read-only list of secret leak findings. + IReadOnlyList GetFindings(); + + /// + /// Gets the active rule bundle metadata, if available. + /// + /// Bundle metadata, or null if no bundle is loaded. + SecretBundleMetadata? GetBundleMetadata(); + + /// + /// Checks if masking was successfully applied to all findings. + /// + /// True if all findings have been properly masked. + bool IsMaskingApplied(); +} + +/// +/// A single secret leak finding for policy evaluation. +/// This is a policy-side representation of secret detection results. +/// +public sealed record SecretFinding +{ + /// + /// The rule ID that produced this finding (e.g., "stellaops.secrets.aws-access-key"). + /// + public required string RuleId { get; init; } + + /// + /// The rule version. + /// + public required string RuleVersion { get; init; } + + /// + /// The severity of the finding: "low", "medium", "high", or "critical". + /// + public required string Severity { get; init; } + + /// + /// The confidence level: "low", "medium", or "high". + /// + public required string Confidence { get; init; } + + /// + /// The file path where the secret was found (relative to scan root). + /// + public required string FilePath { get; init; } + + /// + /// The 1-based line number. + /// + public required int LineNumber { get; init; } + + /// + /// The masked payload (e.g., "AKIA****B7"). Never contains the actual secret. + /// + public required string Mask { get; init; } + + /// + /// The bundle ID that contained the rule. + /// + public required string BundleId { get; init; } + + /// + /// The bundle version. + /// + public required string BundleVersion { get; init; } + + /// + /// When this finding was detected. + /// + public required DateTimeOffset DetectedAt { get; init; } + + /// + /// Whether masking was successfully applied to this finding. + /// + public bool MaskApplied { get; init; } = true; + + /// + /// Additional metadata for the finding. + /// + public ImmutableDictionary Metadata { get; init; } = + ImmutableDictionary.Empty; +} + +/// +/// Metadata about the active secret detection rule bundle. +/// +public sealed record SecretBundleMetadata( + string BundleId, + string Version, + DateTimeOffset SignedAt, + int RuleCount, + string? SignerKeyId = null); diff --git a/src/Policy/__Libraries/StellaOps.Policy/Secrets/SecretEvidenceContext.cs b/src/Policy/__Libraries/StellaOps.Policy/Secrets/SecretEvidenceContext.cs new file mode 100644 index 000000000..036910fa9 --- /dev/null +++ b/src/Policy/__Libraries/StellaOps.Policy/Secrets/SecretEvidenceContext.cs @@ -0,0 +1,285 @@ +// ----------------------------------------------------------------------------- +// SecretEvidenceContext.cs +// Sprint: SPRINT_20260104_004_POLICY (Secret DSL Integration) +// Task: PSD-002 - Implement SecretEvidenceContext binding +// ----------------------------------------------------------------------------- + +using System.Collections.Immutable; + +namespace StellaOps.Policy.Secrets; + +/// +/// Provides access to secret leak evidence for policy evaluation. +/// This context wraps an ISecretEvidenceProvider and provides convenient +/// accessors for policy rules to query secret findings. +/// +public sealed class SecretEvidenceContext +{ + private readonly ISecretEvidenceProvider _provider; + private IReadOnlyList? _cachedFindings; + + /// + /// Creates a new secret evidence context. + /// + /// The evidence provider. + public SecretEvidenceContext(ISecretEvidenceProvider provider) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + /// + /// Gets all secret findings. + /// + public IReadOnlyList Findings => + _cachedFindings ??= _provider.GetFindings(); + + /// + /// Gets the active bundle metadata. + /// + public SecretBundleMetadata? Bundle => _provider.GetBundleMetadata(); + + /// + /// Gets whether masking was successfully applied to all findings. + /// + public bool MaskingApplied => _provider.IsMaskingApplied(); + + /// + /// Checks if any finding exists. + /// + public bool HasAnyFinding => Findings.Count > 0; + + /// + /// Gets the count of all findings. + /// + public int FindingCount => Findings.Count; + + /// + /// Checks if any finding matches the specified severity. + /// + /// The severity to match ("low", "medium", "high", "critical"). + /// True if any finding matches. + public bool HasFindingWithSeverity(string severity) + { + return Findings.Any(f => + f.Severity.Equals(severity, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Checks if any finding matches the specified confidence. + /// + /// The confidence to match ("low", "medium", "high"). + /// True if any finding matches. + public bool HasFindingWithConfidence(string confidence) + { + return Findings.Any(f => + f.Confidence.Equals(confidence, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Checks if any finding matches the specified rule ID pattern. + /// + /// The rule ID or pattern (supports * suffix glob). + /// True if any finding matches. + public bool HasFindingWithRuleId(string ruleIdPattern) + { + return Findings.Any(f => MatchesRuleId(f.RuleId, ruleIdPattern)); + } + + /// + /// Checks if any finding matches all specified criteria. + /// + /// Optional rule ID pattern. + /// Optional severity filter. + /// Optional confidence filter. + /// True if any finding matches all specified criteria. + public bool HasFinding( + string? ruleIdPattern = null, + string? severity = null, + string? confidence = null) + { + return Findings.Any(f => + MatchesRuleId(f.RuleId, ruleIdPattern) && + MatchesSeverity(f.Severity, severity) && + MatchesConfidence(f.Confidence, confidence)); + } + + /// + /// Gets the count of findings matching the optional rule ID pattern. + /// + /// Optional rule ID pattern. + /// The count of matching findings. + public int GetMatchCount(string? ruleIdPattern = null) + { + if (string.IsNullOrEmpty(ruleIdPattern)) + { + return Findings.Count; + } + + return Findings.Count(f => MatchesRuleId(f.RuleId, ruleIdPattern)); + } + + /// + /// Checks if the bundle version meets or exceeds the required version. + /// + /// The minimum required version (YYYY.MM format). + /// True if the bundle version meets the requirement. + public bool BundleVersionMeetsRequirement(string requiredVersion) + { + var bundle = Bundle; + if (bundle is null) + { + return false; + } + + return string.Compare(bundle.Version, requiredVersion, StringComparison.Ordinal) >= 0; + } + + /// + /// Checks if all findings are in paths matching the allowlist patterns. + /// + /// Glob patterns for allowed paths. + /// True if all findings are in allowed paths, or no findings exist. + public bool AllFindingsInAllowlist(IReadOnlyList patterns) + { + if (Findings.Count == 0) + { + return true; + } + + return Findings.All(f => patterns.Any(p => MatchesGlob(f.FilePath, p))); + } + + private static bool MatchesRuleId(string ruleId, string? pattern) + { + if (string.IsNullOrEmpty(pattern)) + { + return true; + } + + if (pattern.EndsWith('*')) + { + return ruleId.StartsWith(pattern[..^1], StringComparison.Ordinal); + } + + return ruleId.Equals(pattern, StringComparison.Ordinal); + } + + private static bool MatchesSeverity(string severity, string? filter) + { + if (string.IsNullOrEmpty(filter)) + { + return true; + } + + return severity.Equals(filter, StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesConfidence(string confidence, string? filter) + { + if (string.IsNullOrEmpty(filter)) + { + return true; + } + + return confidence.Equals(filter, StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesGlob(string path, string pattern) + { + // Simple glob matching supporting ** and * + // Normalize path separators + var normalizedPath = path.Replace('\\', '/'); + var normalizedPattern = pattern.Replace('\\', '/'); + + return MatchesGlobRecursive(normalizedPath, normalizedPattern); + } + + private static bool MatchesGlobRecursive(string path, string pattern) + { + if (pattern == "**") + { + return true; + } + + if (pattern.StartsWith("**/")) + { + var suffix = pattern[3..]; + // Match from any position + var segments = path.Split('/'); + for (var i = 0; i < segments.Length; i++) + { + var remaining = string.Join('/', segments.Skip(i)); + if (MatchesGlobRecursive(remaining, suffix)) + { + return true; + } + } + return false; + } + + if (pattern.Contains("/**/")) + { + var parts = pattern.Split("/**/", 2); + if (!MatchesGlobSegment(path.Split('/').FirstOrDefault() ?? "", parts[0])) + { + return false; + } + return MatchesGlobRecursive(path, "**/" + parts[1]); + } + + // Simple segment matching with * wildcard + return MatchesGlobSegment(path, pattern); + } + + private static bool MatchesGlobSegment(string path, string pattern) + { + if (!pattern.Contains('*')) + { + return path.Equals(pattern, StringComparison.OrdinalIgnoreCase); + } + + // Convert glob pattern to regex-like matching + var patternIndex = 0; + var pathIndex = 0; + + while (patternIndex < pattern.Length && pathIndex < path.Length) + { + if (pattern[patternIndex] == '*') + { + patternIndex++; + if (patternIndex >= pattern.Length) + { + return true; // Trailing * matches everything + } + + // Find next non-* character to match + while (pathIndex < path.Length) + { + if (MatchesGlobSegment(path[pathIndex..], pattern[patternIndex..])) + { + return true; + } + pathIndex++; + } + return false; + } + + if (!char.Equals(char.ToLowerInvariant(pattern[patternIndex]), + char.ToLowerInvariant(path[pathIndex]))) + { + return false; + } + + patternIndex++; + pathIndex++; + } + + // Handle remaining pattern (should be all *) + while (patternIndex < pattern.Length && pattern[patternIndex] == '*') + { + patternIndex++; + } + + return patternIndex >= pattern.Length && pathIndex >= path.Length; + } +}