finish secrets finding work and audit remarks work save

This commit is contained in:
StellaOps Bot
2026-01-04 21:48:13 +02:00
parent 75611a505f
commit 8862e112c4
157 changed files with 11702 additions and 416 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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-----

View File

@@ -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}

View File

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

View File

@@ -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
}

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

@@ -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"
};
}
}

View File

@@ -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"
};
}
}

View File

@@ -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('*');
}
}

View File

@@ -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()