namespace StellaOps.Scanner.Analyzers.Secrets; /// /// A single secret detection rule defining patterns and metadata for identifying secrets. /// public sealed record SecretRule { /// /// Unique rule identifier (e.g., "stellaops.secrets.aws-access-key"). /// public required string Id { get; init; } /// /// Rule version in SemVer format (e.g., "1.0.0"). /// public required string Version { get; init; } /// /// Human-readable rule name. /// public required string Name { get; init; } /// /// Detailed description of what this rule detects. /// public required string Description { get; init; } /// /// The detection strategy type. /// public required SecretRuleType Type { get; init; } /// /// The detection pattern (regex pattern for Regex type, entropy config for Entropy type). /// public required string Pattern { get; init; } /// /// Default severity for findings from this rule. /// public required SecretSeverity Severity { get; init; } /// /// Default confidence level for findings from this rule. /// public required SecretConfidence Confidence { get; init; } /// /// Optional masking hint (e.g., "prefix:4,suffix:2") for payload masking. /// public string? MaskingHint { get; init; } /// /// Pre-filter keywords for fast rejection of non-matching content. /// public ImmutableArray Keywords { get; init; } = []; /// /// Glob patterns for files this rule should be applied to. /// Empty means all text files. /// public ImmutableArray FilePatterns { get; init; } = []; /// /// Whether this rule is enabled. /// public bool Enabled { get; init; } = true; /// /// Minimum entropy threshold for entropy-based detection. /// Only used when Type is Entropy or Composite. /// public double EntropyThreshold { get; init; } = 4.5; /// /// Minimum string length for entropy-based detection. /// public int MinLength { get; init; } = 16; /// /// Maximum string length for detection (prevents matching entire files). /// public int MaxLength { get; init; } = 1000; /// /// Optional metadata for the rule. /// public ImmutableDictionary Metadata { get; init; } = ImmutableDictionary.Empty; /// /// The compiled regex pattern, created lazily. /// private Regex? _compiledPattern; /// /// Gets the compiled regex for this rule. Returns null if the pattern is invalid. /// public Regex? GetCompiledPattern() { if (Type == SecretRuleType.Entropy) { return null; } if (_compiledPattern is not null) { return _compiledPattern; } try { _compiledPattern = new Regex( Pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant, TimeSpan.FromSeconds(5)); return _compiledPattern; } catch (ArgumentException) { return null; } } /// /// Checks if the content might match this rule based on keywords. /// Returns true if no keywords are defined or if any keyword is found. /// public bool MightMatch(ReadOnlySpan content) { if (Keywords.IsDefaultOrEmpty) { return true; } foreach (var keyword in Keywords) { if (content.Contains(keyword.AsSpan(), StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } /// /// Checks if this rule should be applied to the given file path. /// public bool AppliesToFile(string filePath) { if (FilePatterns.IsDefaultOrEmpty) { return true; } var fileName = Path.GetFileName(filePath); foreach (var pattern in FilePatterns) { if (MatchesGlob(fileName, pattern) || MatchesGlob(filePath, pattern)) { return true; } } return false; } private static bool MatchesGlob(string path, string pattern) { // Simple glob matching for common patterns if (pattern.StartsWith("**", StringComparison.Ordinal)) { var suffix = pattern[2..].TrimStart('/').TrimStart('\\'); if (suffix.StartsWith("*.", StringComparison.Ordinal)) { var extension = suffix[1..]; return path.EndsWith(extension, StringComparison.OrdinalIgnoreCase); } return path.Contains(suffix, StringComparison.OrdinalIgnoreCase); } if (pattern.StartsWith("*.", StringComparison.Ordinal)) { var extension = pattern[1..]; return path.EndsWith(extension, StringComparison.OrdinalIgnoreCase); } return path.Equals(pattern, StringComparison.OrdinalIgnoreCase); } }