finish secrets finding work and audit remarks work save
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# AWS Configuration File
|
||||
# This file contains test AWS credentials for integration testing
|
||||
|
||||
[default]
|
||||
aws_access_key_id = AKIAIOSFODNN7EXAMPLE
|
||||
aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
|
||||
# Another profile with different credentials
|
||||
[production]
|
||||
aws_access_key_id = AKIAI44QH8DHBEXAMPLE
|
||||
aws_secret_access_key = je7MtGbClwBF/2Zp9Utk/h3yCo8nvbEXAMPLEKEY
|
||||
|
||||
# This should NOT be detected (invalid format)
|
||||
fake_key = NOT_A_REAL_AWS_KEY
|
||||
@@ -0,0 +1,23 @@
|
||||
# GitHub Configuration
|
||||
# Contains test GitHub tokens for integration testing
|
||||
|
||||
# Personal Access Token (classic)
|
||||
GITHUB_TOKEN=ghp_1234567890123456789012345678901234567890
|
||||
|
||||
# Fine-grained PAT
|
||||
github_pat_11AAAAAA_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789012345678901234567890
|
||||
|
||||
# GitHub App installation token
|
||||
ghs_abc123def456ghi789jkl012mno345pqr678stu90
|
||||
|
||||
# OAuth Access Token
|
||||
gho_1234567890abcdefghijklmnopqrstuvwxyz0123
|
||||
|
||||
# GitHub Actions token (starts with ghs_)
|
||||
ACTIONS_TOKEN=ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Fake token that should NOT match (too short)
|
||||
fake_token=gh_tooshort
|
||||
|
||||
# Comment with token pattern that should NOT match
|
||||
# See documentation at ghp_notarealtoken for examples
|
||||
@@ -0,0 +1,27 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEowIBAAKCAQEA2Z3qX2BTLS4e0rqg/uvSsRA7jlbdFvjDsGvDlIMLvmkuESwL
|
||||
gWVPemCwQQEuJoBCPcaSRxsC0+BiQhTCLZdPkpZ0YETLG3vYxfOqhLdLxP0PVpNo
|
||||
sVRQcVmXuxCpgJpvxc/CIwPdPfA1RFVZmJQEzvLEpCheKlCJIPhZ0xSqR0AuY9rq
|
||||
qcVAIBfLnMo7EFCgDBwU/B5GdLXo8FX3ZeHCVGZKuhYHhgG0VQKvtbZdyLBLdC2x
|
||||
OjvJXDBxPLzwrAvvTkbIMRy4MptE3fS3pBoXKvnA3cXLjyOCqXYabtQ7AwXG7sOl
|
||||
6b3t6gDqtC3VmGHsLH3fLrqMpaKimHq14JeZJwIDAQABAoIBAEK6XHTgHpL0gTSy
|
||||
IL4NzfBQDqOK5MzIJmhDOB8sToNDX/ZjY14NVfPOS0zXQBVlLk1kp0zquNaQkCrP
|
||||
n42vF8G0/HYqVLeApGLF3LECqHdp9o7SbKkJRndC0M7IOC1NTQj9cRTFyK6R3cD3
|
||||
rLaCNbpvoSN5x3ohCdzxnkdBwCGvZ7USkgpZZTjF/1AyB7akzoBLLzMAzkVD8yMS
|
||||
KBheyGi9JHAB9pYLxQDnNCGdGNL36yPcVewvQvJKMc0FD4FJtShVDUOxf3wAJ2m8
|
||||
mQa3VJDDtfq1/nVN8NN0DC+PLyU9CqtFO3nDgVZt6IoYosgn0LoxEMNYjJrtB3nW
|
||||
dW3nLwECgYEA8X6x0qXVRLxLWC7lPzoLOP3rMi1vLBYV4BjLERkskfN1PLBLDCYO
|
||||
MEwI8JFGlLNvOhP2C3hIv2FAqfnW8dLh0GAZrTfhsLkLA0TA3ORwvY0PfIXrp/39
|
||||
4IzxVeQ6hFs0Np1D3j7F4KFKA2pDO2B5nhFVZMglH0yE7bJKb+e4Z5cCgYEA5rCO
|
||||
cVvwKnfwi5qZKNl3zw8Xk5J13WK4B5XlUuCpNWVVk1sVd6BLl0R3RHF8J6AQq9hN
|
||||
z6sbDoBtKxoZl6RfmJLdmZGdVCtKlhBoKlaO4u5lffKdP+S0vS8PVo6DeAcuIy3Y
|
||||
ZKPhFHef0PCAQD2wCmamL1eKsOFFCqr5wCAXwQECgYAhJfyHswqp9AfgWHGExYOh
|
||||
a1wViqHJVb1LDdLhJy/F5MgK3jA6h3B33WZBlGqkHetCPCaQ7PhOratFQ2wVBpWW
|
||||
UGlcWoZpCzAaBRuN2re+8SoOFnqJJDdzYR0DPwUYjPRQyYmFy1jDLVT8X5W0O/1A
|
||||
h7zaEQntGsr9fXVMxwqjZwKBgGX1kIQRgtYk9VzEJDrPNHjAZXBvuPf4T4AOxpBN
|
||||
5e95PE+fN4LEpnCLr6VGJhGFaCs0xPPT3vCshL3uf9zD/HNM7Rl/0m/X4Fe0aMqv
|
||||
3Dnb/FbPFDoLHu3y9KRuygaFJHeXgZT5CBB4F7cOtCB3A0xVz5xVNlBUcB6fzJFv
|
||||
AYABAoGBAKc0geMzI/XuMRUL5X5lxjnkMiLuVmy5gjbmJGygXcLPQXg5nIW3HDAA
|
||||
nT0q9j1M0yLZyRy7kCBFkxE3rXXOYhhzJPJj/K1I5Yxo8aO3daCf4W/CPDZ/VnDA
|
||||
lsj/0vBMtZ3iGVewAiGnEPRIYhMv6zOO1QfOJlH+VnJS6EYc0fQT
|
||||
-----END RSA PRIVATE KEY-----
|
||||
@@ -0,0 +1,6 @@
|
||||
{"id":"stellaops.secrets.aws-access-key","version":"2026.01.0","name":"AWS Access Key ID","description":"Detects AWS Access Key IDs starting with AKIA","type":"Regex","pattern":"AKIA[0-9A-Z]{16}","severity":"Critical","confidence":"High","maskingHint":"prefix:4,suffix:4","keywords":["AKIA"],"filePatterns":["**/*"],"minLength":20,"maxLength":20,"enabled":true}
|
||||
{"id":"stellaops.secrets.aws-secret-key","version":"2026.01.0","name":"AWS Secret Access Key","description":"Detects AWS Secret Access Keys (high-entropy 40-char strings)","type":"Composite","pattern":"(?:[A-Za-z0-9+/]{40})","severity":"Critical","confidence":"Medium","maskingHint":"prefix:4,suffix:4","keywords":["aws","secret","key"],"filePatterns":["**/*"],"minLength":40,"maxLength":40,"entropyThreshold":4.0,"enabled":true}
|
||||
{"id":"stellaops.secrets.github-pat","version":"2026.01.0","name":"GitHub Personal Access Token","description":"Detects GitHub Personal Access Tokens (classic and fine-grained)","type":"Regex","pattern":"ghp_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255}","severity":"Critical","confidence":"High","maskingHint":"prefix:4,suffix:4","keywords":["ghp_","github_pat_"],"filePatterns":["**/*"],"minLength":40,"maxLength":260,"enabled":true}
|
||||
{"id":"stellaops.secrets.github-app","version":"2026.01.0","name":"GitHub App Token","description":"Detects GitHub App tokens (ghs_, gho_)","type":"Regex","pattern":"gh[so]_[A-Za-z0-9]{36,255}","severity":"High","confidence":"High","maskingHint":"prefix:4,suffix:4","keywords":["ghs_","gho_"],"filePatterns":["**/*"],"minLength":40,"maxLength":260,"enabled":true}
|
||||
{"id":"stellaops.secrets.private-key-rsa","version":"2026.01.0","name":"RSA Private Key","description":"Detects PEM-encoded RSA private keys","type":"Regex","pattern":"-----BEGIN RSA PRIVATE KEY-----[\\s\\S]*?-----END RSA PRIVATE KEY-----","severity":"Critical","confidence":"High","maskingHint":"prefix:30,suffix:0","keywords":["-----BEGIN RSA PRIVATE KEY-----"],"filePatterns":["**/*.pem","**/*.key","**/*"],"minLength":100,"maxLength":10000,"enabled":true}
|
||||
{"id":"stellaops.secrets.generic-high-entropy","version":"2026.01.0","name":"Generic High-Entropy String","description":"Detects high-entropy strings that may be secrets","type":"Entropy","pattern":"[A-Za-z0-9+/=_-]{20,}","severity":"Medium","confidence":"Low","maskingHint":"prefix:4,suffix:4","keywords":[],"filePatterns":["**/*"],"minLength":20,"maxLength":500,"entropyThreshold":4.5,"enabled":true}
|
||||
@@ -0,0 +1,331 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the Secrets Analyzer.
|
||||
/// These tests verify end-to-end secret detection, masking, and evidence emission.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class SecretsAnalyzerIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly string _fixturesPath;
|
||||
private SecretRuleset? _ruleset;
|
||||
|
||||
public SecretsAnalyzerIntegrationTests()
|
||||
{
|
||||
_fixturesPath = Path.Combine(AppContext.BaseDirectory, "Fixtures");
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
// Load test ruleset
|
||||
var rulesetPath = Path.Combine(_fixturesPath, "test-ruleset.jsonl");
|
||||
if (File.Exists(rulesetPath))
|
||||
{
|
||||
var loader = new RulesetLoader(
|
||||
NullLogger<RulesetLoader>.Instance,
|
||||
_timeProvider);
|
||||
|
||||
await using var stream = File.OpenRead(rulesetPath);
|
||||
_ruleset = await loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test-bundle",
|
||||
"2026.01.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task FullScan_DetectsAwsAccessKeys()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true);
|
||||
var analyzer = CreateAnalyzer(options);
|
||||
analyzer.SetRuleset(_ruleset!);
|
||||
|
||||
var filePath = Path.Combine(_fixturesPath, "aws-access-key.txt");
|
||||
var content = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
var matches = await DetectAllSecretsAsync(analyzer, content, filePath);
|
||||
|
||||
// Assert
|
||||
matches.Should().NotBeEmpty();
|
||||
var awsKeyMatches = matches.Where(m => m.Rule.Id == "stellaops.secrets.aws-access-key").ToList();
|
||||
awsKeyMatches.Should().HaveCount(2, "there are 2 AKIA keys in the fixture");
|
||||
|
||||
// Verify masking
|
||||
foreach (var match in awsKeyMatches)
|
||||
{
|
||||
var masked = new PayloadMasker().Mask(match.RawMatch.Span, match.Rule.MaskingHint);
|
||||
masked.Should().Contain("****", "secrets must be masked");
|
||||
masked.Should().StartWith("AKIA", "prefix should be preserved");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullScan_DetectsGitHubTokens()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true);
|
||||
var analyzer = CreateAnalyzer(options);
|
||||
analyzer.SetRuleset(_ruleset!);
|
||||
|
||||
var filePath = Path.Combine(_fixturesPath, "github-token.txt");
|
||||
var content = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
var matches = await DetectAllSecretsAsync(analyzer, content, filePath);
|
||||
|
||||
// Assert
|
||||
matches.Should().NotBeEmpty();
|
||||
|
||||
// Should detect ghp_ tokens
|
||||
var patMatches = matches.Where(m => m.Rule.Id == "stellaops.secrets.github-pat").ToList();
|
||||
patMatches.Should().NotBeEmpty("should detect GitHub PAT tokens");
|
||||
|
||||
// Should detect ghs_ / gho_ tokens
|
||||
var appMatches = matches.Where(m => m.Rule.Id == "stellaops.secrets.github-app").ToList();
|
||||
appMatches.Should().NotBeEmpty("should detect GitHub app tokens");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullScan_DetectsPrivateKeys()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true);
|
||||
var analyzer = CreateAnalyzer(options);
|
||||
analyzer.SetRuleset(_ruleset!);
|
||||
|
||||
var filePath = Path.Combine(_fixturesPath, "private-key.pem");
|
||||
var content = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
var matches = await DetectAllSecretsAsync(analyzer, content, filePath);
|
||||
|
||||
// Assert
|
||||
matches.Should().NotBeEmpty();
|
||||
var keyMatches = matches.Where(m => m.Rule.Id == "stellaops.secrets.private-key-rsa").ToList();
|
||||
keyMatches.Should().HaveCount(1, "there is 1 RSA key in the fixture");
|
||||
|
||||
// Verify the key is masked
|
||||
var match = keyMatches[0];
|
||||
var masked = new PayloadMasker().Mask(match.RawMatch.Span, match.Rule.MaskingHint);
|
||||
masked.Should().NotContain("MIIEowIBAAKCAQEA", "private key content must not be exposed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FeatureFlag_WhenDisabled_NoDetection()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: false);
|
||||
var analyzer = CreateAnalyzer(options);
|
||||
analyzer.SetRuleset(_ruleset!);
|
||||
|
||||
var content = Encoding.UTF8.GetBytes("AKIAIOSFODNN7EXAMPLE");
|
||||
|
||||
// Act
|
||||
var matches = await DetectAllSecretsAsync(analyzer, content, "test.txt");
|
||||
|
||||
// Assert
|
||||
matches.Should().BeEmpty("analyzer is disabled via feature flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaxFindings_CircuitBreaker_LimitsResults()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true, maxFindings: 2);
|
||||
var analyzer = CreateAnalyzer(options);
|
||||
analyzer.SetRuleset(_ruleset!);
|
||||
|
||||
// Create content with many secrets
|
||||
var content = Encoding.UTF8.GetBytes(
|
||||
"AKIAIOSFODNN7EXAMPLE\n" +
|
||||
"AKIABCDEFGHIJKLMNOP1\n" +
|
||||
"AKIAZYXWVUTSRQPONML2\n" +
|
||||
"AKIAQWERTYUIOPASDFGH\n" +
|
||||
"AKIAMNBVCXZLKJHGFDSA");
|
||||
|
||||
// Act
|
||||
var matches = await DetectAllSecretsAsync(analyzer, content, "test.txt");
|
||||
|
||||
// Assert
|
||||
matches.Should().HaveCountLessThanOrEqualTo(2, "max findings limit should be respected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Masking_NeverExposesFullSecret()
|
||||
{
|
||||
// Arrange
|
||||
var masker = new PayloadMasker();
|
||||
var testCases = new[]
|
||||
{
|
||||
"AKIAIOSFODNN7EXAMPLE",
|
||||
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"ghp_1234567890123456789012345678901234567890",
|
||||
"ghs_abc123def456ghi789jkl012mno345pqr678stu90"
|
||||
};
|
||||
|
||||
foreach (var secret in testCases)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(secret);
|
||||
|
||||
// Act
|
||||
var masked = masker.Mask(bytes);
|
||||
|
||||
// Assert
|
||||
masked.Should().Contain("****", $"secret '{secret[..4]}...' must be masked");
|
||||
masked.Length.Should().BeLessThan(secret.Length, "masked output should be shorter");
|
||||
|
||||
// Ensure no more than 6 characters are exposed total
|
||||
var exposedChars = masked.Replace("*", "").Length;
|
||||
exposedChars.Should().BeLessThanOrEqualTo(6, "at most 6 characters should be exposed");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Evidence_ContainsRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true);
|
||||
var analyzer = CreateAnalyzer(options);
|
||||
analyzer.SetRuleset(_ruleset!);
|
||||
|
||||
var content = Encoding.UTF8.GetBytes("api_key = AKIAIOSFODNN7EXAMPLE");
|
||||
var filePath = "config/secrets.txt";
|
||||
|
||||
// Act
|
||||
var matches = await DetectAllSecretsAsync(analyzer, content, filePath);
|
||||
|
||||
// Assert
|
||||
matches.Should().NotBeEmpty();
|
||||
var match = matches[0];
|
||||
|
||||
// Create evidence from match
|
||||
var masker = new PayloadMasker();
|
||||
var evidence = new SecretLeakEvidence
|
||||
{
|
||||
DetectorId = "secrets-integration-test",
|
||||
RuleId = match.Rule.Id,
|
||||
RuleVersion = match.Rule.Version,
|
||||
Severity = match.Rule.Severity,
|
||||
Confidence = match.Rule.Confidence,
|
||||
FilePath = match.FilePath,
|
||||
LineNumber = match.LineNumber,
|
||||
ColumnNumber = match.ColumnStart,
|
||||
Mask = masker.Mask(match.RawMatch.Span, match.Rule.MaskingHint),
|
||||
BundleId = _ruleset!.Id,
|
||||
BundleVersion = _ruleset.Version,
|
||||
DetectedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
evidence.RuleId.Should().NotBeNullOrEmpty();
|
||||
evidence.RuleVersion.Should().NotBeNullOrEmpty();
|
||||
evidence.FilePath.Should().Be(filePath);
|
||||
evidence.LineNumber.Should().BeGreaterThan(0);
|
||||
evidence.Mask.Should().Contain("****");
|
||||
evidence.BundleId.Should().Be("test-bundle");
|
||||
evidence.DetectedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Determinism_SameInput_SameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true);
|
||||
var content = Encoding.UTF8.GetBytes(
|
||||
"AKIAIOSFODNN7EXAMPLE\n" +
|
||||
"ghp_1234567890123456789012345678901234567890");
|
||||
|
||||
// Act - Run twice
|
||||
var analyzer1 = CreateAnalyzer(options);
|
||||
analyzer1.SetRuleset(_ruleset!);
|
||||
var matches1 = await DetectAllSecretsAsync(analyzer1, content, "test.txt");
|
||||
|
||||
var analyzer2 = CreateAnalyzer(options);
|
||||
analyzer2.SetRuleset(_ruleset!);
|
||||
var matches2 = await DetectAllSecretsAsync(analyzer2, content, "test.txt");
|
||||
|
||||
// Assert - Results should be identical
|
||||
matches1.Should().HaveCount(matches2.Count);
|
||||
|
||||
for (int i = 0; i < matches1.Count; i++)
|
||||
{
|
||||
matches1[i].Rule.Id.Should().Be(matches2[i].Rule.Id);
|
||||
matches1[i].LineNumber.Should().Be(matches2[i].LineNumber);
|
||||
matches1[i].ColumnStart.Should().Be(matches2[i].ColumnStart);
|
||||
}
|
||||
}
|
||||
|
||||
private SecretsAnalyzer CreateAnalyzer(SecretsAnalyzerOptions options)
|
||||
{
|
||||
var optionsWrapper = Options.Create(options);
|
||||
var regexDetector = new RegexDetector(NullLogger<RegexDetector>.Instance);
|
||||
var entropyDetector = new EntropyDetector(NullLogger<EntropyDetector>.Instance);
|
||||
var compositeDetector = new CompositeSecretDetector(
|
||||
regexDetector,
|
||||
entropyDetector,
|
||||
NullLogger<CompositeSecretDetector>.Instance);
|
||||
var masker = new PayloadMasker();
|
||||
|
||||
return new SecretsAnalyzer(
|
||||
optionsWrapper,
|
||||
compositeDetector,
|
||||
masker,
|
||||
NullLogger<SecretsAnalyzer>.Instance,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
private static SecretsAnalyzerOptions CreateOptions(bool enabled, int maxFindings = 1000)
|
||||
{
|
||||
return new SecretsAnalyzerOptions
|
||||
{
|
||||
Enabled = enabled,
|
||||
MaxFindingsPerScan = maxFindings,
|
||||
MinConfidence = SecretConfidence.Low,
|
||||
EnableEntropyDetection = true,
|
||||
EntropyThreshold = 4.5
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SecretMatch>> DetectAllSecretsAsync(
|
||||
SecretsAnalyzer analyzer,
|
||||
byte[] content,
|
||||
string filePath)
|
||||
{
|
||||
if (!analyzer.IsEnabled || analyzer.Ruleset is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var allMatches = new List<SecretMatch>();
|
||||
var detector = new CompositeSecretDetector(
|
||||
new RegexDetector(NullLogger<RegexDetector>.Instance),
|
||||
new EntropyDetector(NullLogger<EntropyDetector>.Instance),
|
||||
NullLogger<CompositeSecretDetector>.Instance);
|
||||
|
||||
foreach (var rule in analyzer.Ruleset.Rules.Where(r => r.Enabled))
|
||||
{
|
||||
var matches = await detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
filePath,
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
allMatches.AddRange(matches);
|
||||
}
|
||||
|
||||
return allMatches;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretAlertEmitterTests.cs
|
||||
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
|
||||
// Task: SDA-009 - Add integration tests
|
||||
// Description: Unit tests for SecretAlertEmitter.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Scanner.Core.Secrets.Alerts;
|
||||
using StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Secrets.Alerts;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SecretAlertEmitter"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretAlertEmitterTests
|
||||
{
|
||||
private readonly Mock<ISecretAlertRouter> _routerMock = new();
|
||||
private readonly Mock<ISecretAlertDeduplicator> _deduplicatorMock = new();
|
||||
private readonly Mock<ISecretAlertChannelSender> _channelSenderMock = new();
|
||||
private readonly Mock<ISecretAlertSettingsProvider> _settingsProviderMock = new();
|
||||
private readonly ILogger<SecretAlertEmitter> _logger = NullLogger<SecretAlertEmitter>.Instance;
|
||||
|
||||
private static readonly Guid TestTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly Guid TestScanId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
private static readonly DateTimeOffset TestTimestamp = new(2026, 1, 4, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_SkipsWhenAlertingDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var alert = CreateTestAlert();
|
||||
var settings = new SecretAlertSettings { Enabled = false };
|
||||
_settingsProviderMock
|
||||
.Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(settings);
|
||||
|
||||
var emitter = CreateEmitter();
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.WasEmitted.Should().BeFalse();
|
||||
result.SkipReason.Should().Be(AlertSkipReason.AlertingDisabled);
|
||||
_channelSenderMock.Verify(
|
||||
s => s.SendAsync(It.IsAny<SecretFindingAlertEvent>(), It.IsAny<SecretAlertDestination>(), It.IsAny<SecretAlertSettings>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_SkipsWhenSettingsNull()
|
||||
{
|
||||
// Arrange
|
||||
var alert = CreateTestAlert();
|
||||
_settingsProviderMock
|
||||
.Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SecretAlertSettings?)null);
|
||||
|
||||
var emitter = CreateEmitter();
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.WasEmitted.Should().BeFalse();
|
||||
result.SkipReason.Should().Be(AlertSkipReason.AlertingDisabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_SkipsWhenBelowSeverityThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var alert = CreateTestAlert(SecretSeverity.Low);
|
||||
var settings = new SecretAlertSettings
|
||||
{
|
||||
Enabled = true,
|
||||
MinimumAlertSeverity = SecretSeverity.High
|
||||
};
|
||||
_settingsProviderMock
|
||||
.Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(settings);
|
||||
_routerMock
|
||||
.Setup(r => r.MeetsSeverityThreshold(SecretSeverity.Low, SecretSeverity.High))
|
||||
.Returns(false);
|
||||
|
||||
var emitter = CreateEmitter();
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.WasEmitted.Should().BeFalse();
|
||||
result.SkipReason.Should().Be(AlertSkipReason.BelowSeverityThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_SkipsWhenNoMatchingDestinations()
|
||||
{
|
||||
// Arrange
|
||||
var alert = CreateTestAlert();
|
||||
var settings = CreateEnabledSettings();
|
||||
_settingsProviderMock
|
||||
.Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(settings);
|
||||
_routerMock
|
||||
.Setup(r => r.MeetsSeverityThreshold(It.IsAny<SecretSeverity>(), It.IsAny<SecretSeverity>()))
|
||||
.Returns(true);
|
||||
_routerMock
|
||||
.Setup(r => r.RouteAlert(alert, settings))
|
||||
.Returns([]);
|
||||
|
||||
var emitter = CreateEmitter();
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.WasEmitted.Should().BeFalse();
|
||||
result.SkipReason.Should().Be(AlertSkipReason.NoMatchingDestinations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_SkipsWhenRateLimitExceeded()
|
||||
{
|
||||
// Arrange
|
||||
var alert = CreateTestAlert();
|
||||
var settings = CreateEnabledSettings();
|
||||
var destination = CreateDestination();
|
||||
|
||||
_settingsProviderMock
|
||||
.Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(settings);
|
||||
_routerMock
|
||||
.Setup(r => r.MeetsSeverityThreshold(It.IsAny<SecretSeverity>(), It.IsAny<SecretSeverity>()))
|
||||
.Returns(true);
|
||||
_routerMock
|
||||
.Setup(r => r.RouteAlert(alert, settings))
|
||||
.Returns([destination]);
|
||||
_deduplicatorMock
|
||||
.Setup(d => d.IsUnderRateLimitAsync(TestScanId, settings.MaxAlertsPerScan, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var emitter = CreateEmitter();
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.WasEmitted.Should().BeFalse();
|
||||
result.SkipReason.Should().Be(AlertSkipReason.RateLimitExceeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_SkipsWhenDeduplicated()
|
||||
{
|
||||
// Arrange
|
||||
var alert = CreateTestAlert();
|
||||
var settings = CreateEnabledSettings();
|
||||
var destination = CreateDestination();
|
||||
|
||||
_settingsProviderMock
|
||||
.Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(settings);
|
||||
_routerMock
|
||||
.Setup(r => r.MeetsSeverityThreshold(It.IsAny<SecretSeverity>(), It.IsAny<SecretSeverity>()))
|
||||
.Returns(true);
|
||||
_routerMock
|
||||
.Setup(r => r.RouteAlert(alert, settings))
|
||||
.Returns([destination]);
|
||||
_deduplicatorMock
|
||||
.Setup(d => d.IsUnderRateLimitAsync(It.IsAny<Guid>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_deduplicatorMock
|
||||
.Setup(d => d.ShouldAlertAsync(alert.DeduplicationKey, settings.DeduplicationWindow, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var emitter = CreateEmitter();
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.WasEmitted.Should().BeFalse();
|
||||
result.SkipReason.Should().Be(AlertSkipReason.Deduplicated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_EmitsToAllDestinations()
|
||||
{
|
||||
// Arrange
|
||||
var alert = CreateTestAlert();
|
||||
var settings = CreateEnabledSettings();
|
||||
var dest1 = CreateDestination("slack-channel");
|
||||
var dest2 = CreateDestination("webhook");
|
||||
|
||||
_settingsProviderMock
|
||||
.Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(settings);
|
||||
_routerMock
|
||||
.Setup(r => r.MeetsSeverityThreshold(It.IsAny<SecretSeverity>(), It.IsAny<SecretSeverity>()))
|
||||
.Returns(true);
|
||||
_routerMock
|
||||
.Setup(r => r.RouteAlert(alert, settings))
|
||||
.Returns([dest1, dest2]);
|
||||
_deduplicatorMock
|
||||
.Setup(d => d.IsUnderRateLimitAsync(It.IsAny<Guid>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_deduplicatorMock
|
||||
.Setup(d => d.ShouldAlertAsync(It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var emitter = CreateEmitter();
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.WasEmitted.Should().BeTrue();
|
||||
result.Channels.Should().HaveCount(2);
|
||||
_channelSenderMock.Verify(
|
||||
s => s.SendAsync(alert, dest1, settings, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
_channelSenderMock.Verify(
|
||||
s => s.SendAsync(alert, dest2, settings, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_RecordsDeduplicationAndRateLimit()
|
||||
{
|
||||
// Arrange
|
||||
var alert = CreateTestAlert();
|
||||
var settings = CreateEnabledSettings();
|
||||
var destination = CreateDestination();
|
||||
|
||||
_settingsProviderMock
|
||||
.Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(settings);
|
||||
_routerMock
|
||||
.Setup(r => r.MeetsSeverityThreshold(It.IsAny<SecretSeverity>(), It.IsAny<SecretSeverity>()))
|
||||
.Returns(true);
|
||||
_routerMock
|
||||
.Setup(r => r.RouteAlert(alert, settings))
|
||||
.Returns([destination]);
|
||||
_deduplicatorMock
|
||||
.Setup(d => d.IsUnderRateLimitAsync(It.IsAny<Guid>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_deduplicatorMock
|
||||
.Setup(d => d.ShouldAlertAsync(It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var emitter = CreateEmitter();
|
||||
|
||||
// Act
|
||||
await emitter.EmitAsync(alert, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
_deduplicatorMock.Verify(
|
||||
d => d.RecordAlertSentAsync(alert.DeduplicationKey, settings.DeduplicationWindow, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
_deduplicatorMock.Verify(
|
||||
d => d.IncrementScanAlertCountAsync(alert.ScanId, It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_ContinuesOnChannelSendFailure()
|
||||
{
|
||||
// Arrange
|
||||
var alert = CreateTestAlert();
|
||||
var settings = CreateEnabledSettings();
|
||||
var dest1 = CreateDestination("failing");
|
||||
var dest2 = CreateDestination("working");
|
||||
|
||||
_settingsProviderMock
|
||||
.Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(settings);
|
||||
_routerMock
|
||||
.Setup(r => r.MeetsSeverityThreshold(It.IsAny<SecretSeverity>(), It.IsAny<SecretSeverity>()))
|
||||
.Returns(true);
|
||||
_routerMock
|
||||
.Setup(r => r.RouteAlert(alert, settings))
|
||||
.Returns([dest1, dest2]);
|
||||
_deduplicatorMock
|
||||
.Setup(d => d.IsUnderRateLimitAsync(It.IsAny<Guid>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_deduplicatorMock
|
||||
.Setup(d => d.ShouldAlertAsync(It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_channelSenderMock
|
||||
.Setup(s => s.SendAsync(alert, dest1, settings, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Channel failed"));
|
||||
|
||||
var emitter = CreateEmitter();
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(alert, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - Should still succeed with partial delivery
|
||||
result.WasEmitted.Should().BeTrue();
|
||||
result.Channels.Should().HaveCount(1);
|
||||
result.Channels.Should().Contain(c => c.Contains("working"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitBatchAsync_ProcessesAllAlerts()
|
||||
{
|
||||
// Arrange
|
||||
var alerts = new[]
|
||||
{
|
||||
CreateTestAlert(),
|
||||
CreateTestAlert() with { EventId = Guid.NewGuid() },
|
||||
CreateTestAlert() with { EventId = Guid.NewGuid() }
|
||||
};
|
||||
var settings = CreateEnabledSettings();
|
||||
var destination = CreateDestination();
|
||||
|
||||
_settingsProviderMock
|
||||
.Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(settings);
|
||||
_routerMock
|
||||
.Setup(r => r.MeetsSeverityThreshold(It.IsAny<SecretSeverity>(), It.IsAny<SecretSeverity>()))
|
||||
.Returns(true);
|
||||
_routerMock
|
||||
.Setup(r => r.RouteAlert(It.IsAny<SecretFindingAlertEvent>(), settings))
|
||||
.Returns([destination]);
|
||||
_deduplicatorMock
|
||||
.Setup(d => d.IsUnderRateLimitAsync(It.IsAny<Guid>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_deduplicatorMock
|
||||
.Setup(d => d.ShouldAlertAsync(It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var emitter = CreateEmitter();
|
||||
|
||||
// Act
|
||||
var results = await emitter.EmitBatchAsync(alerts, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(3);
|
||||
results.Should().AllSatisfy(r => r.WasEmitted.Should().BeTrue());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitBatchAsync_StopsOnRateLimit()
|
||||
{
|
||||
// Arrange
|
||||
var alerts = new[]
|
||||
{
|
||||
CreateTestAlert(),
|
||||
CreateTestAlert() with { EventId = Guid.NewGuid() },
|
||||
CreateTestAlert() with { EventId = Guid.NewGuid() }
|
||||
};
|
||||
var settings = CreateEnabledSettings();
|
||||
var destination = CreateDestination();
|
||||
|
||||
_settingsProviderMock
|
||||
.Setup(p => p.GetAlertSettingsAsync(TestTenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(settings);
|
||||
_routerMock
|
||||
.Setup(r => r.MeetsSeverityThreshold(It.IsAny<SecretSeverity>(), It.IsAny<SecretSeverity>()))
|
||||
.Returns(true);
|
||||
_routerMock
|
||||
.Setup(r => r.RouteAlert(It.IsAny<SecretFindingAlertEvent>(), settings))
|
||||
.Returns([destination]);
|
||||
|
||||
// First call returns true, subsequent calls return false (rate limit hit)
|
||||
var callCount = 0;
|
||||
_deduplicatorMock
|
||||
.Setup(d => d.IsUnderRateLimitAsync(It.IsAny<Guid>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => ++callCount <= 1);
|
||||
_deduplicatorMock
|
||||
.Setup(d => d.ShouldAlertAsync(It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var emitter = CreateEmitter();
|
||||
|
||||
// Act
|
||||
var results = await emitter.EmitBatchAsync(alerts, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(3);
|
||||
results[0].WasEmitted.Should().BeTrue();
|
||||
results[1].SkipReason.Should().Be(AlertSkipReason.RateLimitExceeded);
|
||||
results[2].SkipReason.Should().Be(AlertSkipReason.RateLimitExceeded);
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private SecretAlertEmitter CreateEmitter() => new(
|
||||
_routerMock.Object,
|
||||
_deduplicatorMock.Object,
|
||||
_channelSenderMock.Object,
|
||||
_settingsProviderMock.Object,
|
||||
_logger);
|
||||
|
||||
private static SecretFindingAlertEvent CreateTestAlert(SecretSeverity severity = SecretSeverity.High) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
TenantId = TestTenantId,
|
||||
ScanId = TestScanId,
|
||||
ImageRef = "registry.example.com/app:v1.0.0",
|
||||
Severity = severity,
|
||||
RuleId = "test-rule-001",
|
||||
RuleName = "Test Rule",
|
||||
RuleCategory = "test_category",
|
||||
FilePath = "/app/config.yaml",
|
||||
LineNumber = 42,
|
||||
MaskedValue = "****masked****",
|
||||
DetectedAt = TestTimestamp,
|
||||
ScanTriggeredBy = "test-user"
|
||||
};
|
||||
|
||||
private static SecretAlertSettings CreateEnabledSettings() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
MinimumAlertSeverity = SecretSeverity.Low,
|
||||
MaxAlertsPerScan = 10,
|
||||
DeduplicationWindow = TimeSpan.FromHours(24),
|
||||
Destinations = []
|
||||
};
|
||||
|
||||
private static SecretAlertDestination CreateDestination(string name = "test") => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
ChannelType = AlertChannelType.Webhook,
|
||||
ChannelId = "https://webhook.example.com/alert"
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretAlertRouterTests.cs
|
||||
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
|
||||
// Task: SDA-009 - Add integration tests
|
||||
// Description: Unit tests for SecretAlertRouter.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Core.Secrets.Alerts;
|
||||
using StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Secrets.Alerts;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SecretAlertRouter"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretAlertRouterTests
|
||||
{
|
||||
private readonly SecretAlertRouter _router = new();
|
||||
|
||||
private static readonly Guid TestTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly DateTimeOffset TestTimestamp = new(2026, 1, 4, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
#region MeetsSeverityThreshold Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(SecretSeverity.Critical, SecretSeverity.Low, true)]
|
||||
[InlineData(SecretSeverity.Critical, SecretSeverity.Medium, true)]
|
||||
[InlineData(SecretSeverity.Critical, SecretSeverity.High, true)]
|
||||
[InlineData(SecretSeverity.Critical, SecretSeverity.Critical, true)]
|
||||
[InlineData(SecretSeverity.High, SecretSeverity.Low, true)]
|
||||
[InlineData(SecretSeverity.High, SecretSeverity.Medium, true)]
|
||||
[InlineData(SecretSeverity.High, SecretSeverity.High, true)]
|
||||
[InlineData(SecretSeverity.High, SecretSeverity.Critical, false)]
|
||||
[InlineData(SecretSeverity.Medium, SecretSeverity.Low, true)]
|
||||
[InlineData(SecretSeverity.Medium, SecretSeverity.Medium, true)]
|
||||
[InlineData(SecretSeverity.Medium, SecretSeverity.High, false)]
|
||||
[InlineData(SecretSeverity.Low, SecretSeverity.Low, true)]
|
||||
[InlineData(SecretSeverity.Low, SecretSeverity.Medium, false)]
|
||||
public void MeetsSeverityThreshold_CorrectlyComparesSeverities(
|
||||
SecretSeverity findingSeverity,
|
||||
SecretSeverity minimumSeverity,
|
||||
bool expected)
|
||||
{
|
||||
// Act
|
||||
var result = _router.MeetsSeverityThreshold(findingSeverity, minimumSeverity);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RouteAlert Tests
|
||||
|
||||
[Fact]
|
||||
public void RouteAlert_ReturnsEmpty_WhenAlertingDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var alert = CreateTestAlert(SecretSeverity.Critical);
|
||||
var settings = new SecretAlertSettings
|
||||
{
|
||||
Enabled = false,
|
||||
Destinations = [CreateDestination(Guid.NewGuid())]
|
||||
};
|
||||
|
||||
// Act
|
||||
var destinations = _router.RouteAlert(alert, settings);
|
||||
|
||||
// Assert
|
||||
destinations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteAlert_ReturnsEmpty_WhenBelowSeverityThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var alert = CreateTestAlert(SecretSeverity.Low);
|
||||
var settings = new SecretAlertSettings
|
||||
{
|
||||
Enabled = true,
|
||||
MinimumAlertSeverity = SecretSeverity.High,
|
||||
Destinations = [CreateDestination(Guid.NewGuid())]
|
||||
};
|
||||
|
||||
// Act
|
||||
var destinations = _router.RouteAlert(alert, settings);
|
||||
|
||||
// Assert
|
||||
destinations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteAlert_ReturnsAllMatchingDestinations_WhenNoFilters()
|
||||
{
|
||||
// Arrange
|
||||
var dest1 = CreateDestination(Guid.NewGuid(), "slack-security");
|
||||
var dest2 = CreateDestination(Guid.NewGuid(), "teams-devops");
|
||||
var alert = CreateTestAlert(SecretSeverity.High);
|
||||
var settings = new SecretAlertSettings
|
||||
{
|
||||
Enabled = true,
|
||||
MinimumAlertSeverity = SecretSeverity.Medium,
|
||||
Destinations = [dest1, dest2]
|
||||
};
|
||||
|
||||
// Act
|
||||
var destinations = _router.RouteAlert(alert, settings);
|
||||
|
||||
// Assert
|
||||
destinations.Should().HaveCount(2);
|
||||
destinations.Should().Contain(dest1);
|
||||
destinations.Should().Contain(dest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteAlert_FiltersDestinationsBySeverity()
|
||||
{
|
||||
// Arrange
|
||||
var criticalOnly = CreateDestination(
|
||||
Guid.NewGuid(),
|
||||
"pagerduty",
|
||||
severityFilter: [SecretSeverity.Critical]);
|
||||
var highAndAbove = CreateDestination(
|
||||
Guid.NewGuid(),
|
||||
"slack-security",
|
||||
severityFilter: [SecretSeverity.Critical, SecretSeverity.High]);
|
||||
var all = CreateDestination(Guid.NewGuid(), "webhook");
|
||||
|
||||
var alert = CreateTestAlert(SecretSeverity.High);
|
||||
var settings = new SecretAlertSettings
|
||||
{
|
||||
Enabled = true,
|
||||
MinimumAlertSeverity = SecretSeverity.Low,
|
||||
Destinations = [criticalOnly, highAndAbove, all]
|
||||
};
|
||||
|
||||
// Act
|
||||
var destinations = _router.RouteAlert(alert, settings);
|
||||
|
||||
// Assert
|
||||
destinations.Should().HaveCount(2);
|
||||
destinations.Should().NotContain(criticalOnly);
|
||||
destinations.Should().Contain(highAndAbove);
|
||||
destinations.Should().Contain(all);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteAlert_FiltersDestinationsByCategory()
|
||||
{
|
||||
// Arrange
|
||||
var cloudOnly = CreateDestination(
|
||||
Guid.NewGuid(),
|
||||
"slack-cloud",
|
||||
categoryFilter: ["cloud_credentials"]);
|
||||
var apiKeysOnly = CreateDestination(
|
||||
Guid.NewGuid(),
|
||||
"teams-api",
|
||||
categoryFilter: ["api_keys"]);
|
||||
var all = CreateDestination(Guid.NewGuid(), "webhook");
|
||||
|
||||
var alert = CreateTestAlert(SecretSeverity.High) with { RuleCategory = "cloud_credentials" };
|
||||
var settings = new SecretAlertSettings
|
||||
{
|
||||
Enabled = true,
|
||||
MinimumAlertSeverity = SecretSeverity.Low,
|
||||
Destinations = [cloudOnly, apiKeysOnly, all]
|
||||
};
|
||||
|
||||
// Act
|
||||
var destinations = _router.RouteAlert(alert, settings);
|
||||
|
||||
// Assert
|
||||
destinations.Should().HaveCount(2);
|
||||
destinations.Should().Contain(cloudOnly);
|
||||
destinations.Should().NotContain(apiKeysOnly);
|
||||
destinations.Should().Contain(all);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteAlert_CombinesSeverityAndCategoryFilters()
|
||||
{
|
||||
// Arrange
|
||||
var criticalCloud = CreateDestination(
|
||||
Guid.NewGuid(),
|
||||
"pagerduty",
|
||||
severityFilter: [SecretSeverity.Critical],
|
||||
categoryFilter: ["cloud_credentials"]);
|
||||
var highCloud = CreateDestination(
|
||||
Guid.NewGuid(),
|
||||
"slack",
|
||||
severityFilter: [SecretSeverity.High, SecretSeverity.Critical],
|
||||
categoryFilter: ["cloud_credentials", "api_keys"]);
|
||||
|
||||
// Alert is High + cloud_credentials
|
||||
var alert = CreateTestAlert(SecretSeverity.High) with { RuleCategory = "cloud_credentials" };
|
||||
var settings = new SecretAlertSettings
|
||||
{
|
||||
Enabled = true,
|
||||
MinimumAlertSeverity = SecretSeverity.Low,
|
||||
Destinations = [criticalCloud, highCloud]
|
||||
};
|
||||
|
||||
// Act
|
||||
var destinations = _router.RouteAlert(alert, settings);
|
||||
|
||||
// Assert - criticalCloud excluded (severity), highCloud included
|
||||
destinations.Should().HaveCount(1);
|
||||
destinations.Should().Contain(highCloud);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteAlert_CategoryFilterIsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var dest = CreateDestination(
|
||||
Guid.NewGuid(),
|
||||
"slack",
|
||||
categoryFilter: ["CLOUD_CREDENTIALS"]);
|
||||
|
||||
var alert = CreateTestAlert(SecretSeverity.High) with { RuleCategory = "cloud_credentials" };
|
||||
var settings = new SecretAlertSettings
|
||||
{
|
||||
Enabled = true,
|
||||
MinimumAlertSeverity = SecretSeverity.Low,
|
||||
Destinations = [dest]
|
||||
};
|
||||
|
||||
// Act
|
||||
var destinations = _router.RouteAlert(alert, settings);
|
||||
|
||||
// Assert
|
||||
destinations.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AlertPriority Extension Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(SecretSeverity.Critical, AlertPriority.P1Immediate)]
|
||||
[InlineData(SecretSeverity.High, AlertPriority.P2Urgent)]
|
||||
[InlineData(SecretSeverity.Medium, AlertPriority.P3Normal)]
|
||||
[InlineData(SecretSeverity.Low, AlertPriority.P4Info)]
|
||||
public void GetDefaultPriority_MapsCorrectly(SecretSeverity severity, AlertPriority expected)
|
||||
{
|
||||
// Act
|
||||
var priority = severity.GetDefaultPriority();
|
||||
|
||||
// Assert
|
||||
priority.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SecretSeverity.Critical, true)]
|
||||
[InlineData(SecretSeverity.High, false)]
|
||||
[InlineData(SecretSeverity.Medium, false)]
|
||||
[InlineData(SecretSeverity.Low, false)]
|
||||
public void ShouldPage_OnlyCritical(SecretSeverity severity, bool expected)
|
||||
{
|
||||
// Act
|
||||
var shouldPage = severity.ShouldPage();
|
||||
|
||||
// Assert
|
||||
shouldPage.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static SecretFindingAlertEvent CreateTestAlert(SecretSeverity severity) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
TenantId = TestTenantId,
|
||||
ScanId = Guid.NewGuid(),
|
||||
ImageRef = "registry.example.com/app:v1.0.0",
|
||||
Severity = severity,
|
||||
RuleId = "test-rule-001",
|
||||
RuleName = "Test Rule",
|
||||
RuleCategory = "test_category",
|
||||
FilePath = "/app/config.yaml",
|
||||
LineNumber = 42,
|
||||
MaskedValue = "****masked****",
|
||||
DetectedAt = TestTimestamp,
|
||||
ScanTriggeredBy = "test-user"
|
||||
};
|
||||
|
||||
private static SecretAlertDestination CreateDestination(
|
||||
Guid id,
|
||||
string name = "test-destination",
|
||||
IReadOnlyList<SecretSeverity>? severityFilter = null,
|
||||
IReadOnlyList<string>? categoryFilter = null) => new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
ChannelType = AlertChannelType.Webhook,
|
||||
ChannelId = "https://webhook.example.com/alert",
|
||||
SeverityFilter = severityFilter,
|
||||
RuleCategoryFilter = categoryFilter
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretFindingAlertEventTests.cs
|
||||
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
|
||||
// Task: SDA-009 - Add integration tests
|
||||
// Description: Unit tests for SecretFindingAlertEvent.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Core.Secrets.Alerts;
|
||||
using StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Secrets.Alerts;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SecretFindingAlertEvent"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretFindingAlertEventTests
|
||||
{
|
||||
private static readonly Guid TestTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly Guid TestScanId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
private static readonly Guid TestEventId = Guid.Parse("33333333-3333-3333-3333-333333333333");
|
||||
private static readonly DateTimeOffset TestTimestamp = new(2026, 1, 4, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void DeduplicationKey_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var alert = CreateTestAlert();
|
||||
|
||||
// Act
|
||||
var key1 = alert.DeduplicationKey;
|
||||
var key2 = alert.DeduplicationKey;
|
||||
|
||||
// Assert
|
||||
key1.Should().Be(key2);
|
||||
key1.Should().Contain(TestTenantId.ToString());
|
||||
key1.Should().Contain("test-rule-001");
|
||||
key1.Should().Contain("/app/config.yaml");
|
||||
key1.Should().Contain("42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeduplicationKey_DiffersBySameFieldCombination()
|
||||
{
|
||||
// Arrange
|
||||
var alert1 = CreateTestAlert();
|
||||
var alert2 = CreateTestAlert() with { FilePath = "/different/path.txt" };
|
||||
var alert3 = CreateTestAlert() with { RuleId = "different-rule" };
|
||||
var alert4 = CreateTestAlert() with { LineNumber = 100 };
|
||||
|
||||
// Act & Assert
|
||||
alert1.DeduplicationKey.Should().NotBe(alert2.DeduplicationKey);
|
||||
alert1.DeduplicationKey.Should().NotBe(alert3.DeduplicationKey);
|
||||
alert1.DeduplicationKey.Should().NotBe(alert4.DeduplicationKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeduplicationKeyWithImage_IncludesImageRef()
|
||||
{
|
||||
// Arrange
|
||||
var alert = CreateTestAlert();
|
||||
|
||||
// Act
|
||||
var key = alert.DeduplicationKeyWithImage;
|
||||
|
||||
// Assert
|
||||
key.Should().Contain("registry.example.com/app:v1.0.0");
|
||||
key.Should().Contain(TestTenantId.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeduplicationKeyWithImage_DiffersByImage()
|
||||
{
|
||||
// Arrange
|
||||
var alert1 = CreateTestAlert();
|
||||
var alert2 = CreateTestAlert() with { ImageRef = "registry.example.com/app:v2.0.0" };
|
||||
|
||||
// Act & Assert
|
||||
alert1.DeduplicationKeyWithImage.Should().NotBe(alert2.DeduplicationKeyWithImage);
|
||||
// But standard key should be the same (doesn't include image)
|
||||
alert1.DeduplicationKey.Should().Be(alert2.DeduplicationKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_FromFindingInfo_SetsAllRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var finding = new SecretFindingInfo
|
||||
{
|
||||
Severity = SecretSeverity.High,
|
||||
RuleId = "aws-access-key",
|
||||
RuleName = "AWS Access Key ID",
|
||||
RuleCategory = "cloud_credentials",
|
||||
FilePath = "/app/secrets.env",
|
||||
LineNumber = 15,
|
||||
ImageDigest = "sha256:abc123",
|
||||
RemediationGuidance = "Rotate the AWS access key immediately"
|
||||
};
|
||||
|
||||
// Act
|
||||
var alert = SecretFindingAlertEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
scanId: TestScanId,
|
||||
imageRef: "myregistry.io/myapp:latest",
|
||||
finding: finding,
|
||||
maskedValue: "AKIA****WXYZ",
|
||||
scanTriggeredBy: "ci-pipeline",
|
||||
eventId: TestEventId,
|
||||
detectedAt: TestTimestamp);
|
||||
|
||||
// Assert
|
||||
alert.EventId.Should().Be(TestEventId);
|
||||
alert.TenantId.Should().Be(TestTenantId);
|
||||
alert.ScanId.Should().Be(TestScanId);
|
||||
alert.ImageRef.Should().Be("myregistry.io/myapp:latest");
|
||||
alert.Severity.Should().Be(SecretSeverity.High);
|
||||
alert.RuleId.Should().Be("aws-access-key");
|
||||
alert.RuleName.Should().Be("AWS Access Key ID");
|
||||
alert.RuleCategory.Should().Be("cloud_credentials");
|
||||
alert.FilePath.Should().Be("/app/secrets.env");
|
||||
alert.LineNumber.Should().Be(15);
|
||||
alert.MaskedValue.Should().Be("AKIA****WXYZ");
|
||||
alert.DetectedAt.Should().Be(TestTimestamp);
|
||||
alert.ScanTriggeredBy.Should().Be("ci-pipeline");
|
||||
alert.ImageDigest.Should().Be("sha256:abc123");
|
||||
alert.RemediationGuidance.Should().Be("Rotate the AWS access key immediately");
|
||||
alert.FindingUrl.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ThrowsOnNullImageRef()
|
||||
{
|
||||
// Arrange
|
||||
var finding = CreateTestFindingInfo();
|
||||
|
||||
// Act & Assert
|
||||
var act = () => SecretFindingAlertEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
scanId: TestScanId,
|
||||
imageRef: null!,
|
||||
finding: finding,
|
||||
maskedValue: "masked",
|
||||
scanTriggeredBy: "user",
|
||||
eventId: TestEventId,
|
||||
detectedAt: TestTimestamp);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ThrowsOnNullFinding()
|
||||
{
|
||||
// Act & Assert
|
||||
var act = () => SecretFindingAlertEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
scanId: TestScanId,
|
||||
imageRef: "registry/app:v1",
|
||||
finding: null!,
|
||||
maskedValue: "masked",
|
||||
scanTriggeredBy: "user",
|
||||
eventId: TestEventId,
|
||||
detectedAt: TestTimestamp);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ThrowsOnEmptyMaskedValue()
|
||||
{
|
||||
// Arrange
|
||||
var finding = CreateTestFindingInfo();
|
||||
|
||||
// Act & Assert
|
||||
var act = () => SecretFindingAlertEvent.Create(
|
||||
tenantId: TestTenantId,
|
||||
scanId: TestScanId,
|
||||
imageRef: "registry/app:v1",
|
||||
finding: finding,
|
||||
maskedValue: "",
|
||||
scanTriggeredBy: "user",
|
||||
eventId: TestEventId,
|
||||
detectedAt: TestTimestamp);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
private static SecretFindingAlertEvent CreateTestAlert() => new()
|
||||
{
|
||||
EventId = TestEventId,
|
||||
TenantId = TestTenantId,
|
||||
ScanId = TestScanId,
|
||||
ImageRef = "registry.example.com/app:v1.0.0",
|
||||
Severity = SecretSeverity.High,
|
||||
RuleId = "test-rule-001",
|
||||
RuleName = "Test Rule",
|
||||
RuleCategory = "test_category",
|
||||
FilePath = "/app/config.yaml",
|
||||
LineNumber = 42,
|
||||
MaskedValue = "****masked****",
|
||||
DetectedAt = TestTimestamp,
|
||||
ScanTriggeredBy = "test-user"
|
||||
};
|
||||
|
||||
private static SecretFindingInfo CreateTestFindingInfo() => new()
|
||||
{
|
||||
Severity = SecretSeverity.Medium,
|
||||
RuleId = "test-rule",
|
||||
RuleName = "Test Rule",
|
||||
RuleCategory = "test",
|
||||
FilePath = "/test/file.txt",
|
||||
LineNumber = 1
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RevelationPolicyConfigTests.cs
|
||||
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
|
||||
// Task: SDC-009 - Add unit and integration tests
|
||||
// Description: Tests for RevelationPolicyConfig validation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Secrets.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="RevelationPolicyConfig"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RevelationPolicyConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_HasSecureDefaults()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = RevelationPolicyConfig.Default;
|
||||
|
||||
// Assert
|
||||
config.DefaultPolicy.Should().Be(SecretRevelationPolicy.PartialReveal);
|
||||
config.ExportPolicy.Should().Be(SecretRevelationPolicy.FullMask);
|
||||
config.LogPolicy.Should().Be(SecretRevelationPolicy.FullMask);
|
||||
config.PartialRevealChars.Should().Be(4);
|
||||
config.MaxMaskChars.Should().Be(8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_RequiresSecurityAdminForFullReveal()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = RevelationPolicyConfig.Default;
|
||||
|
||||
// Assert
|
||||
config.FullRevealRoles.Should().Contain("security-admin");
|
||||
config.FullRevealRoles.Should().Contain("incident-responder");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidConfig_ReturnsEmptyErrorList()
|
||||
{
|
||||
// Arrange
|
||||
var config = new RevelationPolicyConfig
|
||||
{
|
||||
DefaultPolicy = SecretRevelationPolicy.PartialReveal,
|
||||
PartialRevealChars = 4,
|
||||
MaxMaskChars = 8,
|
||||
FullRevealRoles = ["admin"]
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = config.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(11)]
|
||||
public void Validate_InvalidPartialRevealChars_ReturnsError(int chars)
|
||||
{
|
||||
// Arrange
|
||||
var config = new RevelationPolicyConfig { PartialRevealChars = chars };
|
||||
|
||||
// Act
|
||||
var errors = config.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().Contain(e => e.Contains("PartialRevealChars", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(21)]
|
||||
public void Validate_InvalidMaxMaskChars_ReturnsError(int chars)
|
||||
{
|
||||
// Arrange
|
||||
var config = new RevelationPolicyConfig { MaxMaskChars = chars };
|
||||
|
||||
// Act
|
||||
var errors = config.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().Contain(e => e.Contains("MaxMaskChars", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_FullRevealWithNoRoles_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var config = new RevelationPolicyConfig
|
||||
{
|
||||
DefaultPolicy = SecretRevelationPolicy.FullReveal,
|
||||
FullRevealRoles = []
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = config.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().Contain(e => e.Contains("FullRevealRoles", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PartialRevealWithNoRoles_ReturnsNoError()
|
||||
{
|
||||
// Arrange
|
||||
var config = new RevelationPolicyConfig
|
||||
{
|
||||
DefaultPolicy = SecretRevelationPolicy.PartialReveal,
|
||||
PartialRevealChars = 4,
|
||||
MaxMaskChars = 8,
|
||||
FullRevealRoles = []
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = config.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(5)]
|
||||
[InlineData(10)]
|
||||
public void Validate_ValidPartialRevealChars_ReturnsNoError(int chars)
|
||||
{
|
||||
// Arrange
|
||||
var config = new RevelationPolicyConfig
|
||||
{
|
||||
PartialRevealChars = chars,
|
||||
MaxMaskChars = 8
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = config.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(10)]
|
||||
[InlineData(20)]
|
||||
public void Validate_ValidMaxMaskChars_ReturnsNoError(int chars)
|
||||
{
|
||||
// Arrange
|
||||
var config = new RevelationPolicyConfig
|
||||
{
|
||||
PartialRevealChars = 4,
|
||||
MaxMaskChars = chars
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = config.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultRequireExplicitReveal_IsFalse()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RevelationPolicyConfig();
|
||||
|
||||
// Assert
|
||||
config.RequireExplicitReveal.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretDetectionSettingsTests.cs
|
||||
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
|
||||
// Task: SDC-009 - Add unit and integration tests
|
||||
// Description: Tests for SecretDetectionSettings validation and defaults.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Secrets.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SecretDetectionSettings"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretDetectionSettingsTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2026, 1, 4, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Defaults_AreSecure()
|
||||
{
|
||||
// Arrange & Act
|
||||
var settings = new SecretDetectionSettings
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
UpdatedAt = FixedTime,
|
||||
UpdatedBy = "test-user"
|
||||
};
|
||||
|
||||
// Assert
|
||||
settings.Enabled.Should().BeFalse("secret detection should be opt-in");
|
||||
settings.RequireSignedRuleBundles.Should().BeTrue("bundles must be signed by default");
|
||||
settings.ScanBinaryFiles.Should().BeFalse("binary scanning should be opt-in");
|
||||
settings.MaxFileSizeBytes.Should().Be(10 * 1024 * 1024, "10 MB limit expected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_ExcludeCommonBinaryExtensions()
|
||||
{
|
||||
// Arrange & Act
|
||||
var settings = new SecretDetectionSettings
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
UpdatedAt = FixedTime,
|
||||
UpdatedBy = "test-user"
|
||||
};
|
||||
|
||||
// Assert
|
||||
settings.ExcludedFileExtensions.Should().Contain(".exe");
|
||||
settings.ExcludedFileExtensions.Should().Contain(".dll");
|
||||
settings.ExcludedFileExtensions.Should().Contain(".png");
|
||||
settings.ExcludedFileExtensions.Should().Contain(".woff");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_ExcludeNodeModulesAndVendor()
|
||||
{
|
||||
// Arrange & Act
|
||||
var settings = new SecretDetectionSettings
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
UpdatedAt = FixedTime,
|
||||
UpdatedBy = "test-user"
|
||||
};
|
||||
|
||||
// Assert
|
||||
settings.ExcludedPaths.Should().Contain("**/node_modules/**");
|
||||
settings.ExcludedPaths.Should().Contain("**/vendor/**");
|
||||
settings.ExcludedPaths.Should().Contain("**/.git/**");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidSettings_ReturnsEmptyErrorList()
|
||||
{
|
||||
// Arrange
|
||||
var settings = new SecretDetectionSettings
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
Enabled = true,
|
||||
UpdatedAt = FixedTime,
|
||||
UpdatedBy = "test-user"
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = settings.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NegativeMaxFileSize_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var settings = new SecretDetectionSettings
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
MaxFileSizeBytes = -1,
|
||||
UpdatedAt = FixedTime,
|
||||
UpdatedBy = "test-user"
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = settings.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().Contain(e => e.Contains("MaxFileSizeBytes", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ZeroMaxFileSize_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var settings = new SecretDetectionSettings
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
MaxFileSizeBytes = 0,
|
||||
UpdatedAt = FixedTime,
|
||||
UpdatedBy = "test-user"
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = settings.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().Contain(e => e.Contains("MaxFileSizeBytes", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Version_DefaultsToOne()
|
||||
{
|
||||
// Arrange & Act
|
||||
var settings = new SecretDetectionSettings
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
UpdatedAt = FixedTime,
|
||||
UpdatedBy = "test-user"
|
||||
};
|
||||
|
||||
// Assert
|
||||
settings.Version.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultRevelationPolicy_UsesPartialReveal()
|
||||
{
|
||||
// Arrange & Act
|
||||
var settings = new SecretDetectionSettings
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
UpdatedAt = FixedTime,
|
||||
UpdatedBy = "test-user"
|
||||
};
|
||||
|
||||
// Assert
|
||||
settings.RevelationPolicy.DefaultPolicy.Should().Be(SecretRevelationPolicy.PartialReveal);
|
||||
settings.RevelationPolicy.ExportPolicy.Should().Be(SecretRevelationPolicy.FullMask);
|
||||
settings.RevelationPolicy.LogPolicy.Should().Be(SecretRevelationPolicy.FullMask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultAlertSettings_AreDisabled()
|
||||
{
|
||||
// Arrange & Act
|
||||
var settings = new SecretDetectionSettings
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
UpdatedAt = FixedTime,
|
||||
UpdatedBy = "test-user"
|
||||
};
|
||||
|
||||
// Assert
|
||||
settings.AlertSettings.Enabled.Should().BeFalse();
|
||||
settings.AlertSettings.MinimumAlertSeverity.Should().Be(SecretSeverity.High);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretExceptionMatcherTests.cs
|
||||
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
|
||||
// Task: SDC-009 - Add unit and integration tests
|
||||
// Description: Tests for SecretExceptionMatcher functionality.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Secrets.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SecretExceptionMatcher"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretExceptionMatcherTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2026, 1, 4, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly FakeTimeProvider _timeProvider = new(FixedTime);
|
||||
|
||||
[Fact]
|
||||
public void Empty_MatchesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = SecretExceptionMatcher.Empty;
|
||||
|
||||
// Act
|
||||
var result = matcher.Match("AKIAIOSFODNN7EXAMPLE", "stellaops.secrets.aws", "/config/secrets.txt");
|
||||
|
||||
// Assert
|
||||
result.IsExcepted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_SimpleValuePattern_ReturnsExcepted()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(valuePattern: @"^AKIA[A-Z0-9]{16}$");
|
||||
var matcher = new SecretExceptionMatcher([pattern], _timeProvider);
|
||||
|
||||
// Act
|
||||
var result = matcher.Match("AKIAIOSFODNN7EXAMPLE", "stellaops.secrets.aws", "/config/test.txt");
|
||||
|
||||
// Assert
|
||||
result.IsExcepted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_NonMatchingPattern_ReturnsNotExcepted()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(valuePattern: @"^test_\d+$");
|
||||
var matcher = new SecretExceptionMatcher([pattern], _timeProvider);
|
||||
|
||||
// Act
|
||||
var result = matcher.Match("AKIAIOSFODNN7EXAMPLE", "stellaops.secrets.aws", "/config/test.txt");
|
||||
|
||||
// Assert
|
||||
result.IsExcepted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_ExpiredPattern_ReturnsNotExcepted()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(
|
||||
valuePattern: @".*",
|
||||
expiresAt: FixedTime.AddDays(-1));
|
||||
var matcher = new SecretExceptionMatcher([pattern], _timeProvider);
|
||||
|
||||
// Act
|
||||
var result = matcher.Match("any-value", "any-rule", "/any/path.txt");
|
||||
|
||||
// Assert
|
||||
result.IsExcepted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_InactivePattern_ReturnsNotExcepted()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(valuePattern: @".*") with { IsActive = false };
|
||||
var matcher = new SecretExceptionMatcher([pattern], _timeProvider);
|
||||
|
||||
// Act
|
||||
var result = matcher.Match("any-value", "any-rule", "/any/path.txt");
|
||||
|
||||
// Assert
|
||||
result.IsExcepted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_WithApplicableRuleIds_MatchesSpecificRules()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(
|
||||
valuePattern: @".*",
|
||||
applicableRuleIds: ["stellaops.secrets.aws-access-key"]);
|
||||
var matcher = new SecretExceptionMatcher([pattern], _timeProvider);
|
||||
|
||||
// Act
|
||||
var awsResult = matcher.Match("AKIAIOSFODNN7EXAMPLE", "stellaops.secrets.aws-access-key", "/test.txt");
|
||||
var githubResult = matcher.Match("ghp_1234", "stellaops.secrets.github-pat", "/test.txt");
|
||||
|
||||
// Assert
|
||||
awsResult.IsExcepted.Should().BeTrue();
|
||||
githubResult.IsExcepted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_WithFilePathGlob_MatchesSpecificPaths()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(
|
||||
valuePattern: @".*",
|
||||
filePathGlob: "**/test/**");
|
||||
var matcher = new SecretExceptionMatcher([pattern], _timeProvider);
|
||||
|
||||
// Act
|
||||
var testResult = matcher.Match("secret", "any-rule", "/project/test/fixtures/data.txt");
|
||||
var srcResult = matcher.Match("secret", "any-rule", "/project/src/config.txt");
|
||||
|
||||
// Assert
|
||||
testResult.IsExcepted.Should().BeTrue();
|
||||
srcResult.IsExcepted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_FirstMatchingPatternWins()
|
||||
{
|
||||
// Arrange
|
||||
var pattern1 = CreatePattern(valuePattern: @"^AKIA.*");
|
||||
var pattern2 = CreatePattern(valuePattern: @"^ASIA.*");
|
||||
var matcher = new SecretExceptionMatcher([pattern1, pattern2], _timeProvider);
|
||||
|
||||
// Act
|
||||
var akiaResult = matcher.Match("AKIAIOSFODNN7EXAMPLE", "any-rule", "/test.txt");
|
||||
var asiaResult = matcher.Match("ASIAXYZ123456789000", "any-rule", "/test.txt");
|
||||
|
||||
// Assert
|
||||
akiaResult.IsExcepted.Should().BeTrue();
|
||||
asiaResult.IsExcepted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_BothRuleAndPathMustMatch()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(
|
||||
valuePattern: @".*",
|
||||
applicableRuleIds: ["stellaops.secrets.aws-access-key"],
|
||||
filePathGlob: "**/test/**");
|
||||
var matcher = new SecretExceptionMatcher([pattern], _timeProvider);
|
||||
|
||||
// Act - matches rule but not path
|
||||
var wrongPath = matcher.Match("secret", "stellaops.secrets.aws-access-key", "/src/config.txt");
|
||||
// Act - matches path but not rule
|
||||
var wrongRule = matcher.Match("secret", "stellaops.secrets.github-pat", "/test/data.txt");
|
||||
// Act - matches both
|
||||
var bothMatch = matcher.Match("secret", "stellaops.secrets.aws-access-key", "/test/data.txt");
|
||||
|
||||
// Assert
|
||||
wrongPath.IsExcepted.Should().BeFalse();
|
||||
wrongRule.IsExcepted.Should().BeFalse();
|
||||
bothMatch.IsExcepted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_ReturnsMatchedException()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(valuePattern: @"^AKIA.*");
|
||||
var matcher = new SecretExceptionMatcher([pattern], _timeProvider);
|
||||
|
||||
// Act
|
||||
var result = matcher.Match("AKIAIOSFODNN7EXAMPLE", "any-rule", "/test.txt");
|
||||
|
||||
// Assert
|
||||
result.IsExcepted.Should().BeTrue();
|
||||
result.MatchedException.Should().NotBeNull();
|
||||
result.MatchedException!.Id.Should().Be(pattern.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_NoMatch_ReturnsNullMatchedException()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(valuePattern: @"^AKIA.*");
|
||||
var matcher = new SecretExceptionMatcher([pattern], _timeProvider);
|
||||
|
||||
// Act
|
||||
var result = matcher.Match("ghp_1234", "any-rule", "/test.txt");
|
||||
|
||||
// Assert
|
||||
result.IsExcepted.Should().BeFalse();
|
||||
result.MatchedException.Should().BeNull();
|
||||
}
|
||||
|
||||
private static SecretExceptionPattern CreatePattern(
|
||||
string valuePattern = @".*",
|
||||
DateTimeOffset? expiresAt = null,
|
||||
IReadOnlyList<string>? applicableRuleIds = null,
|
||||
string? filePathGlob = null)
|
||||
{
|
||||
return new SecretExceptionPattern
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Exception",
|
||||
Description = "Test exception for unit tests",
|
||||
ValuePattern = valuePattern,
|
||||
ApplicableRuleIds = applicableRuleIds ?? [],
|
||||
FilePathGlob = filePathGlob,
|
||||
Justification = "Required for testing",
|
||||
ExpiresAt = expiresAt,
|
||||
CreatedAt = FixedTime.AddDays(-7),
|
||||
CreatedBy = "test-user"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretExceptionPatternTests.cs
|
||||
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
|
||||
// Task: SDC-009 - Add unit and integration tests
|
||||
// Description: Tests for SecretExceptionPattern model.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Secrets.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SecretExceptionPattern"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretExceptionPatternTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2026, 1, 4, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void IsExpired_NoExpiration_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(expiresAt: null);
|
||||
|
||||
// Act & Assert
|
||||
pattern.IsExpired(FixedTime).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExpired_FutureExpiration_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(expiresAt: FixedTime.AddDays(30));
|
||||
|
||||
// Act & Assert
|
||||
pattern.IsExpired(FixedTime).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExpired_PastExpiration_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(expiresAt: FixedTime.AddDays(-1));
|
||||
|
||||
// Act & Assert
|
||||
pattern.IsExpired(FixedTime).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExpired_ExactExpiration_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(expiresAt: FixedTime);
|
||||
|
||||
// Act & Assert
|
||||
pattern.IsExpired(FixedTime.AddMilliseconds(1)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidPattern_ReturnsEmptyErrorList()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern();
|
||||
|
||||
// Act
|
||||
var errors = pattern.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyName_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern() with { Name = "" };
|
||||
|
||||
// Act
|
||||
var errors = pattern.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().Contain(e => e.Contains("Name", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyValuePattern_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern() with { ValuePattern = "" };
|
||||
|
||||
// Act
|
||||
var errors = pattern.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().Contain(e => e.Contains("ValuePattern", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidRegexPattern_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern() with { ValuePattern = "[invalid(" };
|
||||
|
||||
// Act
|
||||
var errors = pattern.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().Contain(e => e.Contains("regex", StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Contains("pattern", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyJustification_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern() with { Justification = "" };
|
||||
|
||||
// Act
|
||||
var errors = pattern.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().Contain(e => e.Contains("Justification", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PastExpiration_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var pattern = CreatePattern(expiresAt: FixedTime.AddDays(-1));
|
||||
|
||||
// Act
|
||||
var errors = pattern.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().Contain(e => e.Contains("expir", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultActiveState_IsTrue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var pattern = CreatePattern();
|
||||
|
||||
// Assert
|
||||
pattern.IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultMatchCount_IsZero()
|
||||
{
|
||||
// Arrange & Act
|
||||
var pattern = CreatePattern();
|
||||
|
||||
// Assert
|
||||
pattern.MatchCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultApplicableRuleIds_IsEmpty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var pattern = CreatePattern();
|
||||
|
||||
// Assert
|
||||
pattern.ApplicableRuleIds.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private static SecretExceptionPattern CreatePattern(DateTimeOffset? expiresAt = null)
|
||||
{
|
||||
return new SecretExceptionPattern
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Exception",
|
||||
Description = "Test exception for unit tests",
|
||||
ValuePattern = @"^test_\d+$",
|
||||
Justification = "Required for testing",
|
||||
ExpiresAt = expiresAt,
|
||||
CreatedAt = FixedTime.AddDays(-7),
|
||||
CreatedBy = "test-user"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretMaskerTests.cs
|
||||
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
|
||||
// Task: SDC-009 - Add unit and integration tests
|
||||
// Description: Tests for SecretMasker utility.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
using StellaOps.Scanner.Core.Secrets.Masking;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests.Secrets.Masking;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SecretMasker"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretMaskerTests
|
||||
{
|
||||
private const string AwsAccessKey = "AKIAIOSFODNN7EXAMPLE";
|
||||
private const string GithubToken = "ghp_1234567890123456789012345678901234567890";
|
||||
private const string ShortSecret = "abc";
|
||||
|
||||
[Fact]
|
||||
public void Mask_FullMask_ReturnsRedactedPlaceholder()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = SecretMasker.Mask(AwsAccessKey, SecretRevelationPolicy.FullMask);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(SecretMasker.RedactedPlaceholder);
|
||||
result.Should().NotContain("AKIA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_FullReveal_ReturnsOriginalValue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = SecretMasker.Mask(AwsAccessKey, SecretRevelationPolicy.FullReveal);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(AwsAccessKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_PartialReveal_ShowsPrefixAndSuffix()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = SecretMasker.Mask(AwsAccessKey, SecretRevelationPolicy.PartialReveal, partialChars: 4);
|
||||
|
||||
// Assert
|
||||
result.Should().StartWith("AKIA");
|
||||
result.Should().EndWith("MPLE");
|
||||
result.Should().Contain("*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_PartialReveal_LimitsMiddleMaskLength()
|
||||
{
|
||||
// Arrange
|
||||
var longSecret = "A" + new string('B', 100) + "Z";
|
||||
|
||||
// Act
|
||||
var result = SecretMasker.Mask(longSecret, SecretRevelationPolicy.PartialReveal, partialChars: 4, maxMaskChars: 8);
|
||||
|
||||
// Assert
|
||||
var maskCount = result.Count(c => c == SecretMasker.MaskChar);
|
||||
maskCount.Should().Be(8, "mask length should be limited to maxMaskChars");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_PartialReveal_ShortSecret_FullyMasks()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = SecretMasker.Mask(ShortSecret, SecretRevelationPolicy.PartialReveal, partialChars: 4);
|
||||
|
||||
// Assert
|
||||
result.Should().Be("***");
|
||||
result.Should().NotContain("a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_NullOrEmpty_ReturnsRedactedPlaceholder()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
SecretMasker.Mask(null!, SecretRevelationPolicy.PartialReveal).Should().Be(SecretMasker.RedactedPlaceholder);
|
||||
SecretMasker.Mask("", SecretRevelationPolicy.PartialReveal).Should().Be(SecretMasker.RedactedPlaceholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_UnknownPolicy_DefaultsToFullMask()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = SecretMasker.Mask(AwsAccessKey, (SecretRevelationPolicy)999);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(SecretMasker.RedactedPlaceholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_WithConfig_UsesCorrectContext()
|
||||
{
|
||||
// Arrange
|
||||
var config = new RevelationPolicyConfig
|
||||
{
|
||||
DefaultPolicy = SecretRevelationPolicy.PartialReveal,
|
||||
ExportPolicy = SecretRevelationPolicy.FullMask,
|
||||
LogPolicy = SecretRevelationPolicy.FullMask,
|
||||
PartialRevealChars = 4
|
||||
};
|
||||
|
||||
// Act
|
||||
var defaultResult = SecretMasker.Mask(AwsAccessKey, config, MaskingContext.Default);
|
||||
var exportResult = SecretMasker.Mask(AwsAccessKey, config, MaskingContext.Export);
|
||||
var logResult = SecretMasker.Mask(AwsAccessKey, config, MaskingContext.Log);
|
||||
|
||||
// Assert
|
||||
defaultResult.Should().Contain("*").And.StartWith("AKIA");
|
||||
exportResult.Should().Be(SecretMasker.RedactedPlaceholder);
|
||||
logResult.Should().Be(SecretMasker.RedactedPlaceholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_LogContext_AlwaysFullyMasks()
|
||||
{
|
||||
// Arrange
|
||||
var config = new RevelationPolicyConfig
|
||||
{
|
||||
DefaultPolicy = SecretRevelationPolicy.FullReveal, // Even with full reveal
|
||||
LogPolicy = SecretRevelationPolicy.FullReveal // This should be ignored
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = SecretMasker.Mask(AwsAccessKey, config, MaskingContext.Log);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(SecretMasker.RedactedPlaceholder, "logs must always be fully masked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForLog_ReturnsSecretTypeAndLength()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = SecretMasker.ForLog("aws_access_key_id", AwsAccessKey.Length);
|
||||
|
||||
// Assert
|
||||
result.Should().Contain("aws_access_key_id");
|
||||
result.Should().Contain(AwsAccessKey.Length.ToString());
|
||||
result.Should().StartWith("[SECRET_DETECTED:");
|
||||
result.Should().NotContain("AKIA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsMasked_DetectsFullyMaskedPlaceholder()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
SecretMasker.IsMasked(SecretMasker.RedactedPlaceholder).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsMasked_DetectsPartiallyMaskedValues()
|
||||
{
|
||||
// Arrange
|
||||
var masked = SecretMasker.Mask(AwsAccessKey, SecretRevelationPolicy.PartialReveal);
|
||||
|
||||
// Act & Assert
|
||||
SecretMasker.IsMasked(masked).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsMasked_FalseForPlainText()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
SecretMasker.IsMasked(AwsAccessKey).Should().BeFalse();
|
||||
SecretMasker.IsMasked("").Should().BeFalse();
|
||||
SecretMasker.IsMasked(null!).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AKIAIOSFODNN7EXAMPLE", 4)]
|
||||
[InlineData("ghp_abcdefghij12345678901234567890123456", 3)]
|
||||
[InlineData("a", 4)] // Single char
|
||||
[InlineData("ab", 4)] // Two chars
|
||||
public void Mask_PartialReveal_VariousInputs(string input, int partialChars)
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = SecretMasker.Mask(input, SecretRevelationPolicy.PartialReveal, partialChars, maxMaskChars: 8);
|
||||
|
||||
// Assert
|
||||
result.Length.Should().BeLessThanOrEqualTo(input.Length);
|
||||
|
||||
if (input.Length > partialChars * 2)
|
||||
{
|
||||
result.Should().StartWith(input[..partialChars]);
|
||||
result.Should().EndWith(input[^partialChars..]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Short secrets should be fully masked
|
||||
result.All(c => c == SecretMasker.MaskChar).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_Deterministic_SameInputProducesSameOutput()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result1 = SecretMasker.Mask(GithubToken, SecretRevelationPolicy.PartialReveal);
|
||||
var result2 = SecretMasker.Mask(GithubToken, SecretRevelationPolicy.PartialReveal);
|
||||
var result3 = SecretMasker.Mask(GithubToken, SecretRevelationPolicy.PartialReveal);
|
||||
|
||||
// Assert
|
||||
result1.Should().Be(result2);
|
||||
result2.Should().Be(result3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskChar_IsAsterisk()
|
||||
{
|
||||
// Assert
|
||||
SecretMasker.MaskChar.Should().Be('*');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using Xunit;
|
||||
|
||||
@@ -7,6 +8,13 @@ namespace StellaOps.Scanner.Sources.Tests.Domain;
|
||||
|
||||
public class SbomSourceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public SbomSourceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
private static readonly JsonDocument SampleConfig = JsonDocument.Parse("""
|
||||
{
|
||||
"registryType": "Harbor",
|
||||
@@ -23,14 +31,15 @@ public class SbomSourceTests
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Zastava,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
createdBy: "user-1",
|
||||
timeProvider: _timeProvider);
|
||||
|
||||
// Assert
|
||||
source.SourceId.Should().NotBeEmpty();
|
||||
source.TenantId.Should().Be("tenant-1");
|
||||
source.Name.Should().Be("test-source");
|
||||
source.SourceType.Should().Be(SbomSourceType.Zastava);
|
||||
source.Status.Should().Be(SbomSourceStatus.Draft);
|
||||
source.Status.Should().Be(SbomSourceStatus.Pending);
|
||||
source.CreatedBy.Should().Be("user-1");
|
||||
source.Paused.Should().BeFalse();
|
||||
source.ConsecutiveFailures.Should().Be(0);
|
||||
@@ -46,16 +55,17 @@ public class SbomSourceTests
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1",
|
||||
timeProvider: _timeProvider,
|
||||
cronSchedule: "0 * * * *"); // Every hour
|
||||
|
||||
// Assert
|
||||
source.CronSchedule.Should().Be("0 * * * *");
|
||||
source.NextScheduledRun.Should().NotBeNull();
|
||||
source.NextScheduledRun.Should().BeAfter(DateTimeOffset.UtcNow);
|
||||
source.NextScheduledRun.Should().BeAfter(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithZastavaType_GeneratesWebhookEndpointAndSecret()
|
||||
public void Create_WithZastavaType_GeneratesWebhookEndpoint()
|
||||
{
|
||||
// Arrange & Act
|
||||
var source = SbomSource.Create(
|
||||
@@ -63,16 +73,16 @@ public class SbomSourceTests
|
||||
name: "webhook-source",
|
||||
sourceType: SbomSourceType.Zastava,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
createdBy: "user-1",
|
||||
timeProvider: _timeProvider);
|
||||
|
||||
// Assert
|
||||
source.WebhookEndpoint.Should().NotBeNullOrEmpty();
|
||||
source.WebhookSecret.Should().NotBeNullOrEmpty();
|
||||
source.WebhookSecret!.Length.Should().BeGreaterOrEqualTo(32);
|
||||
source.WebhookSecretRef.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_FromDraft_ChangesStatusToActive()
|
||||
public void Activate_FromPending_ChangesStatusToActive()
|
||||
{
|
||||
// Arrange
|
||||
var source = SbomSource.Create(
|
||||
@@ -80,10 +90,11 @@ public class SbomSourceTests
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
createdBy: "user-1",
|
||||
timeProvider: _timeProvider);
|
||||
|
||||
// Act
|
||||
source.Activate("activator");
|
||||
source.Activate("activator", _timeProvider);
|
||||
|
||||
// Assert
|
||||
source.Status.Should().Be(SbomSourceStatus.Active);
|
||||
@@ -99,11 +110,12 @@ public class SbomSourceTests
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
source.Activate("activator");
|
||||
createdBy: "user-1",
|
||||
timeProvider: _timeProvider);
|
||||
source.Activate("activator", _timeProvider);
|
||||
|
||||
// Act
|
||||
source.Pause("Maintenance window", "TICKET-123", "operator");
|
||||
source.Pause("Maintenance window", "TICKET-123", "operator", _timeProvider);
|
||||
|
||||
// Assert
|
||||
source.Paused.Should().BeTrue();
|
||||
@@ -121,12 +133,13 @@ public class SbomSourceTests
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
source.Activate("activator");
|
||||
source.Pause("Maintenance", null, "operator");
|
||||
createdBy: "user-1",
|
||||
timeProvider: _timeProvider);
|
||||
source.Activate("activator", _timeProvider);
|
||||
source.Pause("Maintenance", null, "operator", _timeProvider);
|
||||
|
||||
// Act
|
||||
source.Resume("operator");
|
||||
source.Resume("operator", _timeProvider);
|
||||
|
||||
// Assert
|
||||
source.Paused.Should().BeFalse();
|
||||
@@ -143,16 +156,18 @@ public class SbomSourceTests
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
source.Activate("activator");
|
||||
createdBy: "user-1",
|
||||
timeProvider: _timeProvider);
|
||||
source.Activate("activator", _timeProvider);
|
||||
|
||||
// Simulate some failures
|
||||
source.RecordFailedRun("Error 1");
|
||||
source.RecordFailedRun("Error 2");
|
||||
var runAt = _timeProvider.GetUtcNow();
|
||||
source.RecordFailedRun(runAt, "Error 1", _timeProvider);
|
||||
source.RecordFailedRun(runAt, "Error 2", _timeProvider);
|
||||
source.ConsecutiveFailures.Should().Be(2);
|
||||
|
||||
// Act
|
||||
source.RecordSuccessfulRun();
|
||||
source.RecordSuccessfulRun(runAt, _timeProvider);
|
||||
|
||||
// Assert
|
||||
source.ConsecutiveFailures.Should().Be(0);
|
||||
@@ -169,13 +184,15 @@ public class SbomSourceTests
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
source.Activate("activator");
|
||||
createdBy: "user-1",
|
||||
timeProvider: _timeProvider);
|
||||
source.Activate("activator", _timeProvider);
|
||||
|
||||
// Act - fail 5 times (threshold is 5)
|
||||
// Act - fail multiple times
|
||||
var runAt = _timeProvider.GetUtcNow();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
source.RecordFailedRun($"Error {i + 1}");
|
||||
source.RecordFailedRun(runAt, $"Error {i + 1}", _timeProvider);
|
||||
}
|
||||
|
||||
// Assert
|
||||
@@ -192,12 +209,13 @@ public class SbomSourceTests
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
createdBy: "user-1",
|
||||
timeProvider: _timeProvider);
|
||||
source.MaxScansPerHour = 10;
|
||||
source.Activate("activator");
|
||||
source.Activate("activator", _timeProvider);
|
||||
|
||||
// Act
|
||||
var isLimited = source.IsRateLimited();
|
||||
var isLimited = source.IsRateLimited(_timeProvider);
|
||||
|
||||
// Assert
|
||||
isLimited.Should().BeFalse();
|
||||
@@ -212,7 +230,8 @@ public class SbomSourceTests
|
||||
name: "test-source",
|
||||
sourceType: SbomSourceType.Docker,
|
||||
configuration: SampleConfig,
|
||||
createdBy: "user-1");
|
||||
createdBy: "user-1",
|
||||
timeProvider: _timeProvider);
|
||||
|
||||
var newConfig = JsonDocument.Parse("""
|
||||
{
|
||||
@@ -222,7 +241,7 @@ public class SbomSourceTests
|
||||
""");
|
||||
|
||||
// Act
|
||||
source.UpdateConfiguration(newConfig, "updater");
|
||||
source.UpdateConfiguration(newConfig, "updater", _timeProvider);
|
||||
|
||||
// Assert
|
||||
source.Configuration.RootElement.GetProperty("registryType").GetString()
|
||||
|
||||
Reference in New Issue
Block a user