192 lines
5.4 KiB
C#
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);
|
|
}
|
|
}
|