feat(secrets): implement ISecretEvidenceProvider and SecretEvidenceContext for secret leak evaluation

This commit is contained in:
StellaOps Bot
2026-01-04 15:12:28 +02:00
parent 61098b0509
commit 1f33143bd1
2 changed files with 398 additions and 0 deletions

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public interface ISecretEvidenceProvider
{
/// <summary>
/// Gets all secret findings for the current evaluation context.
/// </summary>
/// <returns>A read-only list of secret leak findings.</returns>
IReadOnlyList<SecretFinding> GetFindings();
/// <summary>
/// Gets the active rule bundle metadata, if available.
/// </summary>
/// <returns>Bundle metadata, or null if no bundle is loaded.</returns>
SecretBundleMetadata? GetBundleMetadata();
/// <summary>
/// Checks if masking was successfully applied to all findings.
/// </summary>
/// <returns>True if all findings have been properly masked.</returns>
bool IsMaskingApplied();
}
/// <summary>
/// A single secret leak finding for policy evaluation.
/// This is a policy-side representation of secret detection results.
/// </summary>
public sealed record SecretFinding
{
/// <summary>
/// The rule ID that produced this finding (e.g., "stellaops.secrets.aws-access-key").
/// </summary>
public required string RuleId { get; init; }
/// <summary>
/// The rule version.
/// </summary>
public required string RuleVersion { get; init; }
/// <summary>
/// The severity of the finding: "low", "medium", "high", or "critical".
/// </summary>
public required string Severity { get; init; }
/// <summary>
/// The confidence level: "low", "medium", or "high".
/// </summary>
public required string Confidence { get; init; }
/// <summary>
/// The file path where the secret was found (relative to scan root).
/// </summary>
public required string FilePath { get; init; }
/// <summary>
/// The 1-based line number.
/// </summary>
public required int LineNumber { get; init; }
/// <summary>
/// The masked payload (e.g., "AKIA****B7"). Never contains the actual secret.
/// </summary>
public required string Mask { get; init; }
/// <summary>
/// The bundle ID that contained the rule.
/// </summary>
public required string BundleId { get; init; }
/// <summary>
/// The bundle version.
/// </summary>
public required string BundleVersion { get; init; }
/// <summary>
/// When this finding was detected.
/// </summary>
public required DateTimeOffset DetectedAt { get; init; }
/// <summary>
/// Whether masking was successfully applied to this finding.
/// </summary>
public bool MaskApplied { get; init; } = true;
/// <summary>
/// Additional metadata for the finding.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Metadata about the active secret detection rule bundle.
/// </summary>
public sealed record SecretBundleMetadata(
string BundleId,
string Version,
DateTimeOffset SignedAt,
int RuleCount,
string? SignerKeyId = null);

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class SecretEvidenceContext
{
private readonly ISecretEvidenceProvider _provider;
private IReadOnlyList<SecretFinding>? _cachedFindings;
/// <summary>
/// Creates a new secret evidence context.
/// </summary>
/// <param name="provider">The evidence provider.</param>
public SecretEvidenceContext(ISecretEvidenceProvider provider)
{
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
}
/// <summary>
/// Gets all secret findings.
/// </summary>
public IReadOnlyList<SecretFinding> Findings =>
_cachedFindings ??= _provider.GetFindings();
/// <summary>
/// Gets the active bundle metadata.
/// </summary>
public SecretBundleMetadata? Bundle => _provider.GetBundleMetadata();
/// <summary>
/// Gets whether masking was successfully applied to all findings.
/// </summary>
public bool MaskingApplied => _provider.IsMaskingApplied();
/// <summary>
/// Checks if any finding exists.
/// </summary>
public bool HasAnyFinding => Findings.Count > 0;
/// <summary>
/// Gets the count of all findings.
/// </summary>
public int FindingCount => Findings.Count;
/// <summary>
/// Checks if any finding matches the specified severity.
/// </summary>
/// <param name="severity">The severity to match ("low", "medium", "high", "critical").</param>
/// <returns>True if any finding matches.</returns>
public bool HasFindingWithSeverity(string severity)
{
return Findings.Any(f =>
f.Severity.Equals(severity, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Checks if any finding matches the specified confidence.
/// </summary>
/// <param name="confidence">The confidence to match ("low", "medium", "high").</param>
/// <returns>True if any finding matches.</returns>
public bool HasFindingWithConfidence(string confidence)
{
return Findings.Any(f =>
f.Confidence.Equals(confidence, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Checks if any finding matches the specified rule ID pattern.
/// </summary>
/// <param name="ruleIdPattern">The rule ID or pattern (supports * suffix glob).</param>
/// <returns>True if any finding matches.</returns>
public bool HasFindingWithRuleId(string ruleIdPattern)
{
return Findings.Any(f => MatchesRuleId(f.RuleId, ruleIdPattern));
}
/// <summary>
/// Checks if any finding matches all specified criteria.
/// </summary>
/// <param name="ruleIdPattern">Optional rule ID pattern.</param>
/// <param name="severity">Optional severity filter.</param>
/// <param name="confidence">Optional confidence filter.</param>
/// <returns>True if any finding matches all specified criteria.</returns>
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));
}
/// <summary>
/// Gets the count of findings matching the optional rule ID pattern.
/// </summary>
/// <param name="ruleIdPattern">Optional rule ID pattern.</param>
/// <returns>The count of matching findings.</returns>
public int GetMatchCount(string? ruleIdPattern = null)
{
if (string.IsNullOrEmpty(ruleIdPattern))
{
return Findings.Count;
}
return Findings.Count(f => MatchesRuleId(f.RuleId, ruleIdPattern));
}
/// <summary>
/// Checks if the bundle version meets or exceeds the required version.
/// </summary>
/// <param name="requiredVersion">The minimum required version (YYYY.MM format).</param>
/// <returns>True if the bundle version meets the requirement.</returns>
public bool BundleVersionMeetsRequirement(string requiredVersion)
{
var bundle = Bundle;
if (bundle is null)
{
return false;
}
return string.Compare(bundle.Version, requiredVersion, StringComparison.Ordinal) >= 0;
}
/// <summary>
/// Checks if all findings are in paths matching the allowlist patterns.
/// </summary>
/// <param name="patterns">Glob patterns for allowed paths.</param>
/// <returns>True if all findings are in allowed paths, or no findings exist.</returns>
public bool AllFindingsInAllowlist(IReadOnlyList<string> 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;
}
}