save progress
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleBuilderTests.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Task: RB-011 - Unit tests for bundle building.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests.Bundles;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class BundleBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly string _sourceDir;
|
||||
private readonly string _outputDir;
|
||||
private readonly BundleBuilder _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public BundleBuilderTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"bundle-test-{Guid.NewGuid():N}");
|
||||
_sourceDir = Path.Combine(_tempDir, "sources");
|
||||
_outputDir = Path.Combine(_tempDir, "output");
|
||||
|
||||
Directory.CreateDirectory(_sourceDir);
|
||||
Directory.CreateDirectory(_outputDir);
|
||||
|
||||
var validator = new RuleValidator(NullLogger<RuleValidator>.Instance);
|
||||
_sut = new BundleBuilder(validator, NullLogger<BundleBuilder>.Instance);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ValidRules_CreatesBundle()
|
||||
{
|
||||
// Arrange
|
||||
var rule1Path = CreateRuleFile("rule1.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test-rule1",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule 1",
|
||||
Description = "A test rule for validation",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[A-Z]{10}",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var rule2Path = CreateRuleFile("rule2.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test-rule2",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule 2",
|
||||
Description = "Another test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[0-9]{8}",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { rule1Path, rule2Path },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "test-bundle",
|
||||
Version = "2026.01",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var artifact = await _sut.BuildAsync(options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(artifact);
|
||||
Assert.Equal("test-bundle", artifact.Manifest.Id);
|
||||
Assert.Equal("2026.01", artifact.Manifest.Version);
|
||||
Assert.Equal(2, artifact.TotalRules);
|
||||
Assert.Equal(2, artifact.EnabledRules);
|
||||
Assert.True(File.Exists(artifact.ManifestPath));
|
||||
Assert.True(File.Exists(artifact.RulesPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_SortsRulesById()
|
||||
{
|
||||
// Arrange
|
||||
var zebraPath = CreateRuleFile("z-rule.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.zebra",
|
||||
Version = "1.0.0",
|
||||
Name = "Zebra Rule",
|
||||
Description = "Rule that should sort last",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "zebra",
|
||||
Severity = SecretSeverity.Low,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var alphaPath = CreateRuleFile("a-rule.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.alpha",
|
||||
Version = "1.0.0",
|
||||
Name = "Alpha Rule",
|
||||
Description = "Rule that should sort first",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "alpha",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { zebraPath, alphaPath },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "sorted-bundle",
|
||||
Version = "1.0.0",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var artifact = await _sut.BuildAsync(options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert - check the manifest rules array is sorted (manifest is already built)
|
||||
Assert.Equal(2, artifact.Manifest.Rules.Length);
|
||||
Assert.Equal("stellaops.secrets.alpha", artifact.Manifest.Rules[0].Id);
|
||||
Assert.Equal("stellaops.secrets.zebra", artifact.Manifest.Rules[1].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ComputesCorrectSha256()
|
||||
{
|
||||
// Arrange
|
||||
var rulePath = CreateRuleFile("rule.json", new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.hash-test",
|
||||
Version = "1.0.0",
|
||||
Name = "Hash Test",
|
||||
Description = "Rule for testing SHA-256 computation",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "test123",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { rulePath },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "hash-bundle",
|
||||
Version = "1.0.0",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var artifact = await _sut.BuildAsync(options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(artifact.RulesSha256);
|
||||
Assert.Matches("^[a-f0-9]{64}$", artifact.RulesSha256);
|
||||
|
||||
// Verify hash matches file content
|
||||
await using var stream = File.OpenRead(artifact.RulesPath);
|
||||
var hash = await System.Security.Cryptography.SHA256.HashDataAsync(stream, TestContext.Current.CancellationToken);
|
||||
var expectedHash = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
Assert.Equal(expectedHash, artifact.RulesSha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_InvalidRule_ThrowsException()
|
||||
{
|
||||
// Arrange - create an invalid rule (id not properly namespaced)
|
||||
var invalidRulePath = Path.Combine(_sourceDir, "invalid-rule.json");
|
||||
var invalidRuleJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
id = "invalid", // Not namespaced with stellaops.secrets
|
||||
version = "1.0.0",
|
||||
name = "Invalid Rule",
|
||||
description = "This rule has an invalid ID",
|
||||
type = "regex",
|
||||
pattern = "test",
|
||||
severity = "medium",
|
||||
confidence = "medium"
|
||||
}, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(invalidRulePath, invalidRuleJson, TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { invalidRulePath },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "invalid-bundle",
|
||||
Version = "1.0.0",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.BuildAsync(options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_EmptyRuleFiles_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = Array.Empty<string>(),
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "empty-bundle",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.BuildAsync(options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_NonexistentRuleFile_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var options = new BundleBuildOptions
|
||||
{
|
||||
RuleFiles = new[] { Path.Combine(_tempDir, "nonexistent.json") },
|
||||
OutputDirectory = _outputDir,
|
||||
BundleId = "missing-bundle",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.BuildAsync(options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private string CreateRuleFile(string filename, SecretRule rule)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(rule, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
var path = Path.Combine(_sourceDir, filename);
|
||||
File.WriteAllText(path, json);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleSignerTests.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Task: RB-011 - Unit tests for bundle signing.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests.Bundles;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class BundleSignerTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly BundleSigner _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public BundleSignerTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"signer-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
_sut = new BundleSigner(NullLogger<BundleSigner>.Instance);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ValidArtifact_CreatesDsseEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "test-key-001",
|
||||
SharedSecret = Convert.ToBase64String(new byte[32]), // 256-bit key
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.True(File.Exists(result.EnvelopePath));
|
||||
Assert.NotNull(result.Envelope);
|
||||
Assert.Single(result.Envelope.Signatures);
|
||||
Assert.Equal("test-key-001", result.Envelope.Signatures[0].KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_UpdatesManifestWithSignatureInfo()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "signer-key",
|
||||
SharedSecret = Convert.ToBase64String(new byte[32]),
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.UpdatedManifest.Signatures);
|
||||
Assert.Equal("signer-key", result.UpdatedManifest.Signatures.KeyId);
|
||||
Assert.Equal("secrets.ruleset.dsse.json", result.UpdatedManifest.Signatures.DsseEnvelope);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), result.UpdatedManifest.Signatures.SignedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_EnvelopeContainsBase64UrlPayload()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
SharedSecret = Convert.ToBase64String(new byte[32]),
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result.Envelope.Payload);
|
||||
// Base64url should not contain +, /, or =
|
||||
Assert.DoesNotContain("+", result.Envelope.Payload);
|
||||
Assert.DoesNotContain("/", result.Envelope.Payload);
|
||||
Assert.DoesNotContain("=", result.Envelope.Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithSecretFile_LoadsSecret()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var secretFile = Path.Combine(_tempDir, "secret.key");
|
||||
var secret = Convert.ToBase64String(new byte[32]);
|
||||
await File.WriteAllTextAsync(secretFile, secret, TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "file-key",
|
||||
SharedSecretFile = secretFile,
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Envelope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithoutSecret_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "no-secret-key",
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_UnsupportedAlgorithm_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
SharedSecret = Convert.ToBase64String(new byte[32]),
|
||||
Algorithm = "ES256", // Not supported
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotSupportedException>(
|
||||
() => _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_HexEncodedSecret_Works()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = CreateTestArtifact();
|
||||
var hexSecret = new string('a', 64); // 32 bytes as hex
|
||||
var options = new BundleSigningOptions
|
||||
{
|
||||
KeyId = "hex-key",
|
||||
SharedSecret = hexSecret,
|
||||
TimeProvider = _timeProvider
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.SignAsync(artifact, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
private BundleArtifact CreateTestArtifact()
|
||||
{
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Id = "test-bundle",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Integrity = new BundleIntegrity
|
||||
{
|
||||
RulesSha256 = new string('0', 64),
|
||||
TotalRules = 1,
|
||||
EnabledRules = 1
|
||||
}
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(_tempDir, "secrets.ruleset.manifest.json");
|
||||
var rulesPath = Path.Combine(_tempDir, "secrets.ruleset.rules.jsonl");
|
||||
|
||||
File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }));
|
||||
File.WriteAllText(rulesPath, "{\"id\":\"test.rule\"}");
|
||||
|
||||
return new BundleArtifact
|
||||
{
|
||||
ManifestPath = manifestPath,
|
||||
RulesPath = rulesPath,
|
||||
RulesSha256 = manifest.Integrity.RulesSha256,
|
||||
TotalRules = 1,
|
||||
EnabledRules = 1,
|
||||
Manifest = manifest
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleVerifierTests.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Task: RB-011 - Unit tests for bundle verification.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests.Bundles;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class BundleVerifierTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly BundleVerifier _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly byte[] _testSecret;
|
||||
|
||||
public BundleVerifierTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"verifier-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
_sut = new BundleVerifier(NullLogger<BundleVerifier>.Instance);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
_testSecret = new byte[32];
|
||||
RandomNumberGenerator.Fill(_testSecret);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidBundle_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("test-bundle", result.BundleId);
|
||||
Assert.Equal("1.0.0", result.BundleVersion);
|
||||
Assert.Empty(result.ValidationErrors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_TamperedRulesFile_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Tamper with the rules file
|
||||
var rulesPath = Path.Combine(bundleDir, "secrets.ruleset.rules.jsonl");
|
||||
await File.AppendAllTextAsync(rulesPath, "\n{\"id\":\"injected.rule\"}", TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("integrity", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WrongSecret_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
var wrongSecret = new byte[32];
|
||||
RandomNumberGenerator.Fill(wrongSecret);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(wrongSecret),
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("signature", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_MissingManifest_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = Path.Combine(_tempDir, "missing-manifest");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("manifest", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_NonexistentDirectory_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var options = new BundleVerificationOptions();
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(Path.Combine(_tempDir, "nonexistent"), options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("not found", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_SkipSignatureVerification_OnlyChecksIntegrity()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateUnsignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SkipSignatureVerification = true,
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_UntrustedKeyId_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
TrustedKeyIds = new[] { "other-trusted-key" },
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.ValidationErrors, e => e.Contains("trusted", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_TrustedKeyId_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
TrustedKeyIds = new[] { "test-key-001" },
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("test-key-001", result.SignerKeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_RequireRekorProof_ReturnsWarningWhenNotVerified()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = await CreateSignedBundleWithRekorAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var options = new BundleVerificationOptions
|
||||
{
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
RequireRekorProof = true,
|
||||
VerifyIntegrity = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.VerifyAsync(bundleDir, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert (Rekor verification not implemented, should have warning)
|
||||
Assert.NotEmpty(result.ValidationWarnings);
|
||||
Assert.Contains(result.ValidationWarnings, w => w.Contains("Rekor", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private async Task<string> CreateUnsignedBundleAsync(CancellationToken ct = default)
|
||||
{
|
||||
var bundleDir = Path.Combine(_tempDir, $"bundle-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
// Create rules file
|
||||
var rulesPath = Path.Combine(bundleDir, "secrets.ruleset.rules.jsonl");
|
||||
var ruleJson = JsonSerializer.Serialize(new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test",
|
||||
Description = "A test rule for verification tests",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "test",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
});
|
||||
await File.WriteAllTextAsync(rulesPath, ruleJson, ct);
|
||||
|
||||
// Compute hash
|
||||
await using var stream = File.OpenRead(rulesPath);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct);
|
||||
var hashHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
// Create manifest
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Id = "test-bundle",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Integrity = new BundleIntegrity
|
||||
{
|
||||
RulesSha256 = hashHex,
|
||||
TotalRules = 1,
|
||||
EnabledRules = 1
|
||||
}
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(bundleDir, "secrets.ruleset.manifest.json");
|
||||
await File.WriteAllTextAsync(manifestPath,
|
||||
JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }), ct);
|
||||
|
||||
return bundleDir;
|
||||
}
|
||||
|
||||
private async Task<string> CreateSignedBundleAsync(CancellationToken ct = default)
|
||||
{
|
||||
var bundleDir = await CreateUnsignedBundleAsync(ct);
|
||||
|
||||
// Sign the bundle
|
||||
var signer = new BundleSigner(NullLogger<BundleSigner>.Instance);
|
||||
var manifestPath = Path.Combine(bundleDir, "secrets.ruleset.manifest.json");
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
var artifact = new BundleArtifact
|
||||
{
|
||||
ManifestPath = manifestPath,
|
||||
RulesPath = Path.Combine(bundleDir, "secrets.ruleset.rules.jsonl"),
|
||||
RulesSha256 = manifest.Integrity.RulesSha256,
|
||||
TotalRules = 1,
|
||||
EnabledRules = 1,
|
||||
Manifest = manifest
|
||||
};
|
||||
|
||||
await signer.SignAsync(artifact, new BundleSigningOptions
|
||||
{
|
||||
KeyId = "test-key-001",
|
||||
SharedSecret = Convert.ToBase64String(_testSecret),
|
||||
TimeProvider = _timeProvider
|
||||
}, ct);
|
||||
|
||||
return bundleDir;
|
||||
}
|
||||
|
||||
private async Task<string> CreateSignedBundleWithRekorAsync(CancellationToken ct = default)
|
||||
{
|
||||
var bundleDir = await CreateSignedBundleAsync(ct);
|
||||
|
||||
// Update manifest to include Rekor log ID
|
||||
var manifestPath = Path.Combine(bundleDir, "secrets.ruleset.manifest.json");
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifest>(manifestJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
var updatedManifest = manifest with
|
||||
{
|
||||
Signatures = manifest.Signatures! with
|
||||
{
|
||||
RekorLogId = "rekor-log-entry-123456"
|
||||
}
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(manifestPath,
|
||||
JsonSerializer.Serialize(updatedManifest, new JsonSerializerOptions { WriteIndented = true }), ct);
|
||||
|
||||
return bundleDir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RuleValidatorTests.cs
|
||||
// Sprint: SPRINT_20260104_003_SCANNER (Secret Detection Rule Bundles)
|
||||
// Task: RB-011 - Unit tests for rule validation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using StellaOps.Scanner.Analyzers.Secrets.Bundles;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests.Bundles;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class RuleValidatorTests
|
||||
{
|
||||
private readonly RuleValidator _sut;
|
||||
|
||||
public RuleValidatorTests()
|
||||
{
|
||||
_sut = new RuleValidator(NullLogger<RuleValidator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRule_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "A test rule for validation",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[A-Z]{10}",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("invalid-id")] // No namespace separator (no dots)
|
||||
[InlineData("InvalidCase.rule")] // Starts with uppercase
|
||||
public void Validate_InvalidId_ReturnsError(string invalidId)
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = invalidId,
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[A-Z]{10}",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("ID"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("1.0")]
|
||||
[InlineData("v1.0.0")]
|
||||
public void Validate_InvalidVersion_ReturnsError(string invalidVersion)
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test",
|
||||
Version = invalidVersion,
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[A-Z]{10}",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("version", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[")]
|
||||
[InlineData("(unclosed")]
|
||||
[InlineData("(?invalid)")]
|
||||
public void Validate_InvalidRegex_ReturnsError(string invalidPattern)
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = invalidPattern,
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("regex", StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Contains("pattern", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyPattern_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.test",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test description",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("pattern", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidEntropyRule_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.entropy-test",
|
||||
Version = "1.0.0",
|
||||
Name = "Entropy Test",
|
||||
Description = "Detects high-entropy strings",
|
||||
Type = SecretRuleType.Entropy,
|
||||
Pattern = "", // Pattern can be empty for entropy rules
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
EntropyThreshold = 4.5
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EntropyRuleWithDefaultThreshold_ReturnsValid()
|
||||
{
|
||||
// Arrange - using the default entropy threshold (4.5) which is in valid range
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.entropy-test",
|
||||
Version = "1.0.0",
|
||||
Name = "Entropy Test",
|
||||
Description = "Detects high-entropy strings with default threshold",
|
||||
Type = SecretRuleType.Entropy,
|
||||
Pattern = "", // Pattern can be empty for entropy rules
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium
|
||||
// Default entropy threshold is 4.5, which is in the valid range
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert - default threshold (4.5) is valid, no warnings expected
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EntropyRuleWithOutOfRangeThreshold_ReturnsWarning()
|
||||
{
|
||||
// Arrange - using an out-of-range threshold
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "stellaops.secrets.entropy-test",
|
||||
Version = "1.0.0",
|
||||
Name = "Entropy Test",
|
||||
Description = "Detects high-entropy strings with extreme threshold",
|
||||
Type = SecretRuleType.Entropy,
|
||||
Pattern = "",
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
EntropyThreshold = 0 // Zero triggers <= 0 warning condition
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Validate(rule);
|
||||
|
||||
// Assert - valid but with warning about unusual threshold
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Contains("entropy", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EntropyCalculatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Calculate_EmptyString_ReturnsZero()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate(string.Empty);
|
||||
|
||||
entropy.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_SingleCharacter_ReturnsZero()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate("a");
|
||||
|
||||
entropy.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_RepeatedCharacter_ReturnsZero()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate("aaaaaaaaaa");
|
||||
|
||||
entropy.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_TwoDistinctCharacters_ReturnsOne()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate("ababababab");
|
||||
|
||||
entropy.Should().BeApproximately(1.0, 0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_FourDistinctCharacters_ReturnsTwo()
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate("abcdabcdabcd");
|
||||
|
||||
entropy.Should().BeApproximately(2.0, 0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_HighEntropyString_ReturnsHighValue()
|
||||
{
|
||||
var highEntropyString = "aB1cD2eF3gH4iJ5kL6mN7oP8qR9sT0uV";
|
||||
|
||||
var entropy = EntropyCalculator.Calculate(highEntropyString);
|
||||
|
||||
entropy.Should().BeGreaterThan(4.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_LowEntropyPassword_ReturnsLowValue()
|
||||
{
|
||||
var lowEntropyString = "password";
|
||||
|
||||
var entropy = EntropyCalculator.Calculate(lowEntropyString);
|
||||
|
||||
entropy.Should().BeLessThan(3.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_AwsAccessKeyPattern_ReturnsHighEntropy()
|
||||
{
|
||||
var awsKey = "AKIAIOSFODNN7EXAMPLE";
|
||||
|
||||
var entropy = EntropyCalculator.Calculate(awsKey);
|
||||
|
||||
entropy.Should().BeGreaterThan(3.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Base64String_ReturnsHighEntropy()
|
||||
{
|
||||
var base64 = "SGVsbG8gV29ybGQhIFRoaXMgaXMgYSB0ZXN0";
|
||||
|
||||
var entropy = EntropyCalculator.Calculate(base64);
|
||||
|
||||
entropy.Should().BeGreaterThan(4.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_IsDeterministic()
|
||||
{
|
||||
var input = "TestString123!@#";
|
||||
|
||||
var entropy1 = EntropyCalculator.Calculate(input);
|
||||
var entropy2 = EntropyCalculator.Calculate(input);
|
||||
|
||||
entropy1.Should().Be(entropy2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0123456789", 3.32)]
|
||||
[InlineData("abcdefghij", 3.32)]
|
||||
[InlineData("ABCDEFGHIJ", 3.32)]
|
||||
public void Calculate_KnownPatterns_ReturnsExpectedEntropy(string input, double expectedEntropy)
|
||||
{
|
||||
var entropy = EntropyCalculator.Calculate(input);
|
||||
|
||||
entropy.Should().BeApproximately(expectedEntropy, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PayloadMaskerTests
|
||||
{
|
||||
private readonly PayloadMasker _masker = new();
|
||||
|
||||
[Fact]
|
||||
public void Mask_EmptySpan_ReturnsEmpty()
|
||||
{
|
||||
_masker.Mask(ReadOnlySpan<char>.Empty).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_ShortValue_ReturnsMaskChars()
|
||||
{
|
||||
// Values shorter than prefix+suffix get masked placeholder
|
||||
var result = _masker.Mask("abc".AsSpan());
|
||||
|
||||
result.Should().Contain("*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_StandardValue_PreservesPrefixAndSuffix()
|
||||
{
|
||||
var result = _masker.Mask("1234567890".AsSpan());
|
||||
|
||||
// Default: 4 char prefix, 2 char suffix
|
||||
result.Should().StartWith("1234");
|
||||
result.Should().EndWith("90");
|
||||
result.Should().Contain("****");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_AwsAccessKey_PreservesPrefix()
|
||||
{
|
||||
var awsKey = "AKIAIOSFODNN7EXAMPLE";
|
||||
|
||||
var result = _masker.Mask(awsKey.AsSpan());
|
||||
|
||||
result.Should().StartWith("AKIA");
|
||||
result.Should().EndWith("LE");
|
||||
result.Should().Contain("****");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_WithPrefixHint_UsesCustomPrefixLength()
|
||||
{
|
||||
var apiKey = "sk-proj-abcdefghijklmnop";
|
||||
|
||||
// MaxExposedChars is 6, so prefix:8 + suffix:2 gets scaled down
|
||||
var result = _masker.Mask(apiKey.AsSpan(), "prefix:4,suffix:2");
|
||||
|
||||
result.Should().StartWith("sk-p");
|
||||
result.Should().Contain("****");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_LongValue_MasksMiddle()
|
||||
{
|
||||
var longSecret = "verylongsecretthatexceeds100characters" +
|
||||
"andshouldbemaskkedproperlywithoutexpo" +
|
||||
"singtheentirecontentstoanyoneviewingit";
|
||||
|
||||
var result = _masker.Mask(longSecret.AsSpan());
|
||||
|
||||
// Should contain mask characters and be shorter than original
|
||||
result.Should().Contain("****");
|
||||
result.Length.Should().BeLessThan(longSecret.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_IsDeterministic()
|
||||
{
|
||||
var secret = "AKIAIOSFODNN7EXAMPLE";
|
||||
|
||||
var result1 = _masker.Mask(secret.AsSpan());
|
||||
var result2 = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result1.Should().Be(result2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_NeverExposesFullSecret()
|
||||
{
|
||||
var secret = "supersecretkey123";
|
||||
|
||||
var result = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result.Should().NotBe(secret);
|
||||
result.Should().Contain("*");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("prefix:6,suffix:0")]
|
||||
[InlineData("prefix:0,suffix:6")]
|
||||
[InlineData("prefix:3,suffix:3")]
|
||||
public void Mask_WithVariousHints_RespectsTotalLimit(string hint)
|
||||
{
|
||||
var secret = "abcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
var result = _masker.Mask(secret.AsSpan(), hint);
|
||||
|
||||
var visibleChars = result.Replace("*", "").Length;
|
||||
visibleChars.Should().BeLessThanOrEqualTo(PayloadMasker.MaxExposedChars);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_EnforcesMinOutputLength()
|
||||
{
|
||||
var secret = "abcdefghijklmnop";
|
||||
|
||||
var result = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result.Length.Should().BeGreaterThanOrEqualTo(PayloadMasker.MinOutputLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_ByteOverload_DecodesUtf8()
|
||||
{
|
||||
var text = "secretpassword123";
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(text);
|
||||
|
||||
var result = _masker.Mask(bytes.AsSpan());
|
||||
|
||||
result.Should().Contain("****");
|
||||
result.Should().StartWith("secr");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_EmptyByteSpan_ReturnsEmpty()
|
||||
{
|
||||
_masker.Mask(ReadOnlySpan<byte>.Empty).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_InvalidHint_UsesDefaults()
|
||||
{
|
||||
var secret = "abcdefghijklmnop";
|
||||
|
||||
var result1 = _masker.Mask(secret.AsSpan(), "invalid:hint:format");
|
||||
var result2 = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result1.Should().Be(result2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_UsesCorrectMaskChar()
|
||||
{
|
||||
var secret = "abcdefghijklmnop";
|
||||
|
||||
var result = _masker.Mask(secret.AsSpan());
|
||||
|
||||
result.Should().Contain(PayloadMasker.MaskChar.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mask_MaskLengthLimited()
|
||||
{
|
||||
var longSecret = new string('x', 100);
|
||||
|
||||
var result = _masker.Mask(longSecret.AsSpan());
|
||||
|
||||
// Count mask characters
|
||||
var maskCount = result.Count(c => c == PayloadMasker.MaskChar);
|
||||
maskCount.Should().BeLessThanOrEqualTo(PayloadMasker.MaxMaskLength);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RegexDetectorTests
|
||||
{
|
||||
private readonly RegexDetector _detector = new(NullLogger<RegexDetector>.Instance);
|
||||
|
||||
[Fact]
|
||||
public void DetectorId_ReturnsRegex()
|
||||
{
|
||||
_detector.DetectorId.Should().Be("regex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_RegexType_ReturnsTrue()
|
||||
{
|
||||
_detector.CanHandle(SecretRuleType.Regex).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_EntropyType_ReturnsFalse()
|
||||
{
|
||||
_detector.CanHandle(SecretRuleType.Entropy).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_CompositeType_ReturnsTrue()
|
||||
{
|
||||
// RegexDetector handles both Regex and Composite types
|
||||
_detector.CanHandle(SecretRuleType.Composite).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_NoMatch_ReturnsEmpty()
|
||||
{
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}");
|
||||
var content = Encoding.UTF8.GetBytes("no aws key here");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_SingleMatch_ReturnsOne()
|
||||
{
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}");
|
||||
var content = Encoding.UTF8.GetBytes("aws_key = AKIAIOSFODNN7EXAMPLE");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].Rule.Id.Should().Be("test-rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_MultipleMatches_ReturnsAll()
|
||||
{
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}");
|
||||
var content = Encoding.UTF8.GetBytes(
|
||||
"key1 = AKIAIOSFODNN7EXAMPLE\n" +
|
||||
"key2 = AKIABCDEFGHIJKLMNOP1");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_ReportsCorrectLineNumber()
|
||||
{
|
||||
var rule = CreateRule(@"secret_key\s*=\s*\S+");
|
||||
var content = Encoding.UTF8.GetBytes(
|
||||
"# config file\n" +
|
||||
"debug = true\n" +
|
||||
"secret_key = mysecretvalue\n" +
|
||||
"port = 8080");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"config.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].LineNumber.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_ReportsCorrectColumn()
|
||||
{
|
||||
var rule = CreateRule(@"secret_key");
|
||||
var content = Encoding.UTF8.GetBytes("config: secret_key = value");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
// "secret_key" starts at index 8 (0-based), column 9 (1-based)
|
||||
matches[0].ColumnStart.Should().Be(9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_HandlesMultilineContent()
|
||||
{
|
||||
var rule = CreateRule(@"API_KEY\s*=\s*\w+");
|
||||
var content = Encoding.UTF8.GetBytes(
|
||||
"line1\n" +
|
||||
"line2\n" +
|
||||
"API_KEY = abc123\n" +
|
||||
"line4\n" +
|
||||
"API_KEY = xyz789");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(2);
|
||||
matches[0].LineNumber.Should().Be(3);
|
||||
matches[1].LineNumber.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_DisabledRule_StillProcesses()
|
||||
{
|
||||
// Note: The detector doesn't filter by Enabled status.
|
||||
// Filtering disabled rules is the caller's responsibility (e.g., SecretsAnalyzer)
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}", enabled: false);
|
||||
var content = Encoding.UTF8.GetBytes("AKIAIOSFODNN7EXAMPLE");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"test.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Detector processes regardless of Enabled flag
|
||||
matches.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_RespectsCancellation()
|
||||
{
|
||||
var rule = CreateRule(@"test");
|
||||
var content = Encoding.UTF8.GetBytes("test content");
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
// When cancellation is already requested, detector returns empty (doesn't throw)
|
||||
var matches = await _detector.DetectAsync(content.AsMemory(), "test.txt", rule, cts.Token);
|
||||
|
||||
matches.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_IncludesFilePath()
|
||||
{
|
||||
var rule = CreateRule(@"secret");
|
||||
var content = Encoding.UTF8.GetBytes("mysecret");
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"path/to/file.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].FilePath.Should().Be("path/to/file.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_LargeFile_HandlesEfficiently()
|
||||
{
|
||||
var rule = CreateRule(@"SECRET_KEY");
|
||||
var lines = Enumerable.Range(0, 10000)
|
||||
.Select(i => i == 5000 ? "SECRET_KEY = value" : $"line {i}")
|
||||
.ToArray();
|
||||
var content = Encoding.UTF8.GetBytes(string.Join("\n", lines));
|
||||
|
||||
var matches = await _detector.DetectAsync(
|
||||
content.AsMemory(),
|
||||
"large.txt",
|
||||
rule,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].LineNumber.Should().Be(5001);
|
||||
}
|
||||
|
||||
private static SecretRule CreateRule(string pattern, bool enabled = true)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = "test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test rule for unit tests",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = pattern,
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = enabled,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RulesetLoaderTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _testDir;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly RulesetLoader _loader;
|
||||
|
||||
public RulesetLoaderTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), $"secrets-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDir);
|
||||
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero));
|
||||
_loader = new RulesetLoader(NullLogger<RulesetLoader>.Instance, _timeProvider);
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Directory.Exists(_testDir))
|
||||
{
|
||||
Directory.Delete(_testDir, recursive: true);
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ValidBundle_LoadsRuleset()
|
||||
{
|
||||
await CreateValidBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Should().NotBeNull();
|
||||
ruleset.Id.Should().Be("test-secrets");
|
||||
ruleset.Version.Should().Be("1.0.0");
|
||||
ruleset.Rules.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_MissingDirectory_ThrowsDirectoryNotFound()
|
||||
{
|
||||
var nonExistentPath = Path.Combine(_testDir, "does-not-exist");
|
||||
|
||||
await Assert.ThrowsAsync<DirectoryNotFoundException>(
|
||||
() => _loader.LoadAsync(nonExistentPath, TestContext.Current.CancellationToken).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_MissingManifest_ThrowsFileNotFound()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.rules.jsonl"),
|
||||
"""{"id":"rule1","pattern":"test"}""",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_MissingRulesFile_ThrowsFileNotFound()
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
"""{"id":"test","version":"1.0.0"}""",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_InvalidIntegrity_ThrowsException()
|
||||
{
|
||||
await CreateBundleWithBadIntegrityAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SortsRulesById()
|
||||
{
|
||||
await CreateBundleWithUnorderedRulesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules.Select(r => r.Id).Should().BeInAscendingOrder();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SkipsBlankLines()
|
||||
{
|
||||
await CreateBundleWithBlankLinesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SkipsInvalidJsonLines()
|
||||
{
|
||||
await CreateBundleWithInvalidJsonAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
// JSONL processes each line independently - invalid lines are skipped but don't stop processing
|
||||
// So we get rule1 and rule2 (2 rules), with the invalid line skipped
|
||||
ruleset.Rules.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SetsCreatedAt()
|
||||
{
|
||||
await CreateValidBundleAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ruleset = await _loader.LoadAsync(_testDir, TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromJsonlAsync_ValidStream_LoadsRules()
|
||||
{
|
||||
var jsonl = """
|
||||
{"id":"rule1","version":"1.0","name":"Rule 1","type":"regex","pattern":"secret","severity":"high","confidence":"high"}
|
||||
{"id":"rule2","version":"1.0","name":"Rule 2","type":"regex","pattern":"password","severity":"medium","confidence":"medium"}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test-bundle",
|
||||
"1.0.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Should().NotBeNull();
|
||||
ruleset.Id.Should().Be("test-bundle");
|
||||
ruleset.Version.Should().Be("1.0.0");
|
||||
ruleset.Rules.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromJsonlAsync_DefaultValues_AppliedCorrectly()
|
||||
{
|
||||
var jsonl = """{"id":"minimal-rule","pattern":"test"}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var rule = ruleset.Rules[0];
|
||||
rule.Version.Should().Be("1.0.0");
|
||||
rule.Enabled.Should().BeTrue();
|
||||
rule.Severity.Should().Be(SecretSeverity.Medium);
|
||||
rule.Confidence.Should().Be(SecretConfidence.Medium);
|
||||
rule.Type.Should().Be(SecretRuleType.Regex);
|
||||
rule.EntropyThreshold.Should().Be(4.5);
|
||||
rule.MinLength.Should().Be(16);
|
||||
rule.MaxLength.Should().Be(1000);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("regex", SecretRuleType.Regex)]
|
||||
[InlineData("entropy", SecretRuleType.Entropy)]
|
||||
[InlineData("composite", SecretRuleType.Composite)]
|
||||
[InlineData("REGEX", SecretRuleType.Regex)]
|
||||
[InlineData("unknown", SecretRuleType.Regex)]
|
||||
public async Task LoadFromJsonlAsync_ParsesRuleType(string typeString, SecretRuleType expected)
|
||||
{
|
||||
var jsonl = $$$"""{"id":"rule1","pattern":"test","type":"{{{typeString}}}"}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules[0].Type.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("low", SecretSeverity.Low)]
|
||||
[InlineData("medium", SecretSeverity.Medium)]
|
||||
[InlineData("high", SecretSeverity.High)]
|
||||
[InlineData("critical", SecretSeverity.Critical)]
|
||||
[InlineData("HIGH", SecretSeverity.High)]
|
||||
public async Task LoadFromJsonlAsync_ParsesSeverity(string severityString, SecretSeverity expected)
|
||||
{
|
||||
var jsonl = $$$"""{"id":"rule1","pattern":"test","severity":"{{{severityString}}}"}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules[0].Severity.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromJsonlAsync_ParsesKeywords()
|
||||
{
|
||||
var jsonl = """{"id":"rule1","pattern":"test","keywords":["aws","key","secret"]}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules[0].Keywords.Should().BeEquivalentTo(["aws", "key", "secret"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadFromJsonlAsync_ParsesMetadata()
|
||||
{
|
||||
var jsonl = """{"id":"rule1","pattern":"test","metadata":{"source":"gitleaks","category":"api-key"}}""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonl));
|
||||
|
||||
var ruleset = await _loader.LoadFromJsonlAsync(
|
||||
stream,
|
||||
"test",
|
||||
"1.0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ruleset.Rules[0].Metadata.Should().Contain("source", "gitleaks");
|
||||
ruleset.Rules[0].Metadata.Should().Contain("category", "api-key");
|
||||
}
|
||||
|
||||
private async Task CreateValidBundleAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """
|
||||
{"id":"aws-key","version":"1.0","name":"AWS Access Key","type":"regex","pattern":"AKIA[0-9A-Z]{16}","severity":"critical","confidence":"high"}
|
||||
{"id":"generic-secret","version":"1.0","name":"Generic Secret","type":"regex","pattern":"secret[_-]?key","severity":"medium","confidence":"medium"}
|
||||
""";
|
||||
|
||||
var rulesPath = Path.Combine(_testDir, "secrets.ruleset.rules.jsonl");
|
||||
await File.WriteAllTextAsync(rulesPath, rules, ct);
|
||||
|
||||
var hash = await ComputeHashAsync(rulesPath, ct);
|
||||
var manifest = $$$"""{"id":"test-secrets","version":"1.0.0","integrity":{"rulesSha256":"{{{hash}}}"}}""";
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task CreateBundleWithBadIntegrityAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """{"id":"rule1","pattern":"test"}""";
|
||||
var rulesPath = Path.Combine(_testDir, "secrets.ruleset.rules.jsonl");
|
||||
await File.WriteAllTextAsync(rulesPath, rules, ct);
|
||||
|
||||
// Use a known bad hash (clearly different from any real SHA-256)
|
||||
const string badHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
var manifest = $$$"""{"id":"test","version":"1.0","integrity":{"rulesSha256":"{{{badHash}}}"}}""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task CreateBundleWithUnorderedRulesAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """
|
||||
{"id":"z-rule","pattern":"z"}
|
||||
{"id":"a-rule","pattern":"a"}
|
||||
{"id":"m-rule","pattern":"m"}
|
||||
""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.rules.jsonl"),
|
||||
rules,
|
||||
ct);
|
||||
|
||||
var manifest = """{"id":"test","version":"1.0"}""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task CreateBundleWithBlankLinesAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """
|
||||
{"id":"rule1","pattern":"test1"}
|
||||
|
||||
{"id":"rule2","pattern":"test2"}
|
||||
|
||||
""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.rules.jsonl"),
|
||||
rules,
|
||||
ct);
|
||||
|
||||
var manifest = """{"id":"test","version":"1.0"}""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task CreateBundleWithInvalidJsonAsync(CancellationToken ct)
|
||||
{
|
||||
var rules = """
|
||||
{"id":"rule1","pattern":"valid"}
|
||||
not valid json at all
|
||||
{"id":"rule2","pattern":"also valid but will be skipped due to earlier error?"}
|
||||
""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.rules.jsonl"),
|
||||
rules,
|
||||
ct);
|
||||
|
||||
var manifest = """{"id":"test","version":"1.0"}""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(_testDir, "secrets.ruleset.manifest.json"),
|
||||
manifest,
|
||||
ct);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeHashAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetCompiledPattern_ValidRegex_ReturnsRegex()
|
||||
{
|
||||
var rule = CreateRule(@"AKIA[0-9A-Z]{16}");
|
||||
|
||||
var regex = rule.GetCompiledPattern();
|
||||
|
||||
regex.Should().NotBeNull();
|
||||
regex!.IsMatch("AKIAIOSFODNN7EXAMPLE").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCompiledPattern_InvalidRegex_ReturnsNull()
|
||||
{
|
||||
var rule = CreateRule(@"[invalid(regex");
|
||||
|
||||
var regex = rule.GetCompiledPattern();
|
||||
|
||||
regex.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCompiledPattern_IsCached()
|
||||
{
|
||||
var rule = CreateRule(@"test\d+");
|
||||
|
||||
var regex1 = rule.GetCompiledPattern();
|
||||
var regex2 = rule.GetCompiledPattern();
|
||||
|
||||
regex1.Should().BeSameAs(regex2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCompiledPattern_EntropyType_ReturnsNull()
|
||||
{
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "entropy-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Entropy Rule",
|
||||
Description = "Test",
|
||||
Type = SecretRuleType.Entropy,
|
||||
Pattern = string.Empty,
|
||||
Severity = SecretSeverity.Medium,
|
||||
Confidence = SecretConfidence.Medium,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = true,
|
||||
EntropyThreshold = 4.5,
|
||||
MinLength = 16,
|
||||
MaxLength = 100,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
var regex = rule.GetCompiledPattern();
|
||||
|
||||
regex.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppliesToFile_NoPatterns_MatchesAll()
|
||||
{
|
||||
var rule = CreateRule(@"test");
|
||||
|
||||
rule.AppliesToFile("any/path/file.txt").Should().BeTrue();
|
||||
rule.AppliesToFile("config.json").Should().BeTrue();
|
||||
rule.AppliesToFile("secrets.yaml").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppliesToFile_WithExtensionPattern_FiltersByExtension()
|
||||
{
|
||||
var rule = CreateRuleWithFilePatterns(@"test", "*.json", "*.yaml");
|
||||
|
||||
rule.AppliesToFile("config.json").Should().BeTrue();
|
||||
rule.AppliesToFile("config.yaml").Should().BeTrue();
|
||||
rule.AppliesToFile("config.xml").Should().BeFalse();
|
||||
rule.AppliesToFile("config.txt").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MightMatch_NoKeywords_ReturnsTrue()
|
||||
{
|
||||
var rule = CreateRule(@"test");
|
||||
|
||||
rule.MightMatch("any content here".AsSpan()).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MightMatch_WithKeywords_MatchesIfKeywordFound()
|
||||
{
|
||||
var rule = CreateRuleWithKeywords(@"test", "secret", "password");
|
||||
|
||||
rule.MightMatch("contains secret here".AsSpan()).Should().BeTrue();
|
||||
rule.MightMatch("contains password here".AsSpan()).Should().BeTrue();
|
||||
rule.MightMatch("no matching content".AsSpan()).Should().BeFalse();
|
||||
}
|
||||
|
||||
private static SecretRule CreateRule(string pattern)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = "test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = pattern,
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = true,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule CreateRuleWithFilePatterns(string pattern, params string[] filePatterns)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = "test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = pattern,
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = [..filePatterns],
|
||||
Enabled = true,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule CreateRuleWithKeywords(string pattern, params string[] keywords)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = "test-rule",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Rule",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = pattern,
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = [..keywords],
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = true,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecretRulesetTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnabledRuleCount_ReturnsCorrectCount()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: true),
|
||||
CreateRule("rule2", enabled: true),
|
||||
CreateRule("rule3", enabled: false));
|
||||
|
||||
ruleset.EnabledRuleCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledRuleCount_AllDisabled_ReturnsZero()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: false),
|
||||
CreateRule("rule2", enabled: false));
|
||||
|
||||
ruleset.EnabledRuleCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledRuleCount_AllEnabled_ReturnsTotal()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: true),
|
||||
CreateRule("rule2", enabled: true),
|
||||
CreateRule("rule3", enabled: true));
|
||||
|
||||
ruleset.EnabledRuleCount.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRulesForFile_ReturnsEnabledMatchingRules()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRuleWithPattern("json-rule", "*.json", enabled: true),
|
||||
CreateRuleWithPattern("yaml-rule", "*.yaml", enabled: true),
|
||||
CreateRuleWithPattern("disabled-rule", "*.json", enabled: false));
|
||||
|
||||
var rules = ruleset.GetRulesForFile("config.json").ToList();
|
||||
|
||||
rules.Should().HaveCount(1);
|
||||
rules[0].Id.Should().Be("json-rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRulesForFile_NoMatchingPatterns_ReturnsRulesWithNoPatterns()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("generic-rule", enabled: true),
|
||||
CreateRuleWithPattern("json-rule", "*.json", enabled: true));
|
||||
|
||||
var rules = ruleset.GetRulesForFile("config.xml").ToList();
|
||||
|
||||
rules.Should().HaveCount(1);
|
||||
rules[0].Id.Should().Be("generic-rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRuleset_ReturnsEmpty()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: true),
|
||||
CreateRule("rule2", enabled: true));
|
||||
|
||||
var errors = ruleset.Validate();
|
||||
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DuplicateIds_ReturnsError()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("same-id", enabled: true),
|
||||
CreateRule("same-id", enabled: true));
|
||||
|
||||
var errors = ruleset.Validate();
|
||||
|
||||
errors.Should().Contain(e => e.Contains("Duplicate", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidRegex_ReturnsError()
|
||||
{
|
||||
var rule = new SecretRule
|
||||
{
|
||||
Id = "bad-regex",
|
||||
Version = "1.0.0",
|
||||
Name = "Bad Regex",
|
||||
Description = "Invalid regex pattern",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "[invalid(regex",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = true,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
var ruleset = new SecretRuleset
|
||||
{
|
||||
Id = "test",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Rules = [rule]
|
||||
};
|
||||
|
||||
var errors = ruleset.Validate();
|
||||
|
||||
errors.Should().Contain(e => e.Contains("bad-regex", StringComparison.OrdinalIgnoreCase) &&
|
||||
e.Contains("invalid", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledRules_ReturnsOnlyEnabled()
|
||||
{
|
||||
var ruleset = CreateRuleset(
|
||||
CreateRule("rule1", enabled: true),
|
||||
CreateRule("rule2", enabled: false),
|
||||
CreateRule("rule3", enabled: true));
|
||||
|
||||
var enabled = ruleset.EnabledRules.ToList();
|
||||
|
||||
enabled.Should().HaveCount(2);
|
||||
enabled.Select(r => r.Id).Should().BeEquivalentTo(["rule1", "rule3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_ReturnsEmptyRuleset()
|
||||
{
|
||||
var empty = SecretRuleset.Empty;
|
||||
|
||||
empty.Id.Should().Be("empty");
|
||||
empty.Version.Should().Be("0.0");
|
||||
empty.Rules.Should().BeEmpty();
|
||||
empty.EnabledRuleCount.Should().Be(0);
|
||||
}
|
||||
|
||||
private static SecretRuleset CreateRuleset(params SecretRule[] rules)
|
||||
{
|
||||
return new SecretRuleset
|
||||
{
|
||||
Id = "test-ruleset",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Rules = [..rules]
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule CreateRule(string id, bool enabled)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = id,
|
||||
Version = "1.0.0",
|
||||
Name = $"Rule {id}",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "test",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = ImmutableArray<string>.Empty,
|
||||
Enabled = enabled,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretRule CreateRuleWithPattern(string id, string filePattern, bool enabled)
|
||||
{
|
||||
return new SecretRule
|
||||
{
|
||||
Id = id,
|
||||
Version = "1.0.0",
|
||||
Name = $"Rule {id}",
|
||||
Description = "Test rule",
|
||||
Type = SecretRuleType.Regex,
|
||||
Pattern = "test",
|
||||
Severity = SecretSeverity.High,
|
||||
Confidence = SecretConfidence.High,
|
||||
Keywords = ImmutableArray<string>.Empty,
|
||||
FilePatterns = [filePattern],
|
||||
Enabled = enabled,
|
||||
EntropyThreshold = 0,
|
||||
MinLength = 0,
|
||||
MaxLength = 1000,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Secrets/StellaOps.Scanner.Analyzers.Secrets.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Fixtures/**/*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,469 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CallGraphDigestsTests.cs
|
||||
// Sprint: SPRINT_20260104_001_CLI
|
||||
// Description: Unit tests for call graph digest computation and determinism.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class CallGraphDigestsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_ReturnsValidSha256Format()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateMinimalSnapshot();
|
||||
|
||||
// Act
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(digest);
|
||||
Assert.StartsWith("sha256:", digest, StringComparison.Ordinal);
|
||||
Assert.Equal(71, digest.Length); // "sha256:" (7) + 64 hex chars
|
||||
Assert.True(IsValidHex(digest[7..]), "Digest should be valid hex string");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateMinimalSnapshot();
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
var digest3 = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(digest1, digest2);
|
||||
Assert.Equal(digest2, digest3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_EquivalentSnapshotsProduceSameDigest()
|
||||
{
|
||||
// Arrange - two separately created but equivalent snapshots
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan-1",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, true, EntrypointType.CliCommand, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Internal, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct)
|
||||
),
|
||||
EntrypointIds: ImmutableArray.Create("node-a"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan-1",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow.AddMinutes(5), // Different timestamp
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, true, EntrypointType.CliCommand, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Internal, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct)
|
||||
),
|
||||
EntrypointIds: ImmutableArray.Create("node-a"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert - digests should match because ExtractedAt is not part of the digest payload
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_DifferentNodesProduceDifferentDigests()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_NodeOrderDoesNotAffectDigest()
|
||||
{
|
||||
// Arrange - nodes in different order
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert - digests should match because Trimmed() sorts nodes
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_EdgeOrderDoesNotAffectDigest()
|
||||
{
|
||||
// Arrange - edges in different order
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, true, EntrypointType.CliCommand, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Internal, false, null, false, null),
|
||||
new CallGraphNode("node-c", "func_c", "test.c", 30, "pkg", Visibility.Internal, false, null, false, null)
|
||||
);
|
||||
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct),
|
||||
new CallGraphEdge("node-a", "node-c", CallKind.Direct)
|
||||
),
|
||||
EntrypointIds: ImmutableArray.Create("node-a"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-c", CallKind.Direct),
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct)
|
||||
),
|
||||
EntrypointIds: ImmutableArray.Create("node-a"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert - digests should match because Trimmed() sorts edges
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_WhitespaceIsTrimmed()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: " test-scan ",
|
||||
GraphDigest: "",
|
||||
Language: " native ",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode(" node-a ", " func_a ", " test.c ", 10, " pkg ", Visibility.Public, false, null, false, null)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert - digests should match because Trimmed() trims whitespace
|
||||
Assert.Equal(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_ThrowsOnNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => CallGraphDigests.ComputeGraphDigest(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_HandlesEmptySnapshot()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray<CallGraphNode>.Empty,
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(digest);
|
||||
Assert.StartsWith("sha256:", digest, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_LanguageAffectsDigest()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray<CallGraphNode>.Empty,
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray<CallGraphNode>.Empty,
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGraphDigest_EdgeExplanationAffectsDigest()
|
||||
{
|
||||
// Arrange
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null)
|
||||
);
|
||||
|
||||
var snapshot1 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct, null, null)
|
||||
),
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
var snapshot2 = new CallGraphSnapshot(
|
||||
ScanId: "test-scan",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray.Create(
|
||||
new CallGraphEdge("node-a", "node-b", CallKind.Direct, null, CallEdgeExplanation.DirectCall())
|
||||
),
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
|
||||
// Act
|
||||
var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1);
|
||||
var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(digest1, digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_Compute_ReturnsValidSha256Format()
|
||||
{
|
||||
// Arrange
|
||||
var stableId = "native:main";
|
||||
|
||||
// Act
|
||||
var nodeId = CallGraphNodeIds.Compute(stableId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(nodeId);
|
||||
Assert.StartsWith("sha256:", nodeId, StringComparison.Ordinal);
|
||||
Assert.Equal(71, nodeId.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_Compute_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var stableId = "native:SSL_read";
|
||||
|
||||
// Act
|
||||
var id1 = CallGraphNodeIds.Compute(stableId);
|
||||
var id2 = CallGraphNodeIds.Compute(stableId);
|
||||
var id3 = CallGraphNodeIds.Compute(stableId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(id1, id2);
|
||||
Assert.Equal(id2, id3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_Compute_DifferentSymbolsProduceDifferentIds()
|
||||
{
|
||||
// Arrange
|
||||
var stableId1 = "native:func_a";
|
||||
var stableId2 = "native:func_b";
|
||||
|
||||
// Act
|
||||
var id1 = CallGraphNodeIds.Compute(stableId1);
|
||||
var id2 = CallGraphNodeIds.Compute(stableId2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(id1, id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_StableSymbolId_CreatesConsistentFormat()
|
||||
{
|
||||
// Arrange & Act
|
||||
var stableId = CallGraphNodeIds.StableSymbolId("Native", "SSL_read");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("native:SSL_read", stableId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNodeIds_StableSymbolId_TrimsWhitespace()
|
||||
{
|
||||
// Arrange & Act
|
||||
var stableId = CallGraphNodeIds.StableSymbolId(" Native ", " SSL_read ");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("native:SSL_read", stableId);
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateMinimalSnapshot()
|
||||
{
|
||||
return new CallGraphSnapshot(
|
||||
ScanId: "test-scan-001",
|
||||
GraphDigest: "",
|
||||
Language: "native",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray.Create(
|
||||
new CallGraphNode(
|
||||
NodeId: "sha256:abc123",
|
||||
Symbol: "main",
|
||||
File: "main.c",
|
||||
Line: 1,
|
||||
Package: "test-binary",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.CliCommand,
|
||||
IsSink: false,
|
||||
SinkCategory: null
|
||||
)
|
||||
),
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray.Create("sha256:abc123"),
|
||||
SinkIds: ImmutableArray<string>.Empty
|
||||
);
|
||||
}
|
||||
|
||||
private static bool IsValidHex(string hex)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hex))
|
||||
return false;
|
||||
|
||||
foreach (char c in hex)
|
||||
{
|
||||
if (!char.IsAsciiHexDigit(c))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user