feat(secrets): implement ISecretEvidenceProvider and SecretEvidenceContext for secret leak evaluation
This commit is contained in:
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user