save progress

This commit is contained in:
StellaOps Bot
2026-01-04 14:54:52 +02:00
parent c49b03a254
commit 3098e84de4
132 changed files with 19783 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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