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);
}
}