Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Secrets/Rules/SecretRule.cs
StellaOps Bot 3098e84de4 save progress
2026-01-04 14:54:52 +02:00

192 lines
5.4 KiB
C#

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