Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Canonicalization.Json;
|
||||
using StellaOps.Canonicalization.Ordering;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonicalization.Tests;
|
||||
|
||||
public class CanonicalJsonSerializerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_Dictionary_OrdersKeysAlphabetically()
|
||||
{
|
||||
var dict = new Dictionary<string, int> { ["z"] = 1, ["a"] = 2, ["m"] = 3 };
|
||||
var json = CanonicalJsonSerializer.Serialize(dict);
|
||||
json.Should().Be("{\"a\":2,\"m\":3,\"z\":1}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_DateTimeOffset_UsesUtcIso8601()
|
||||
{
|
||||
var dt = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.FromHours(5));
|
||||
var obj = new { Timestamp = dt };
|
||||
var json = CanonicalJsonSerializer.Serialize(obj);
|
||||
json.Should().Contain("2024-01-15T05:30:00.000Z");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_NullValues_AreOmitted()
|
||||
{
|
||||
var obj = new { Name = "test", Value = (string?)null };
|
||||
var json = CanonicalJsonSerializer.Serialize(obj);
|
||||
json.Should().NotContain("value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeWithDigest_ProducesConsistentDigest()
|
||||
{
|
||||
var obj = new { Name = "test", Value = 123 };
|
||||
var (_, digest1) = CanonicalJsonSerializer.SerializeWithDigest(obj);
|
||||
var (_, digest2) = CanonicalJsonSerializer.SerializeWithDigest(obj);
|
||||
digest1.Should().Be(digest2);
|
||||
}
|
||||
}
|
||||
|
||||
public class PackageOrdererTests
|
||||
{
|
||||
[Fact]
|
||||
public void StableOrder_OrdersByPurlFirst()
|
||||
{
|
||||
var packages = new[]
|
||||
{
|
||||
(purl: "pkg:npm/b@1.0.0", name: "b", version: "1.0.0"),
|
||||
(purl: "pkg:npm/a@1.0.0", name: "a", version: "1.0.0")
|
||||
};
|
||||
var ordered = packages.StableOrder(p => p.purl, p => p.name, p => p.version, _ => null).ToList();
|
||||
ordered[0].purl.Should().Be("pkg:npm/a@1.0.0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
using StellaOps.Canonicalization.Json;
|
||||
using StellaOps.Canonicalization.Ordering;
|
||||
|
||||
namespace StellaOps.Canonicalization.Tests.Properties;
|
||||
|
||||
public class CanonicalJsonProperties
|
||||
{
|
||||
[Property]
|
||||
public Property Serialize_IsIdempotent(Dictionary<string, int> dict)
|
||||
{
|
||||
var json1 = CanonicalJsonSerializer.Serialize(dict);
|
||||
var json2 = CanonicalJsonSerializer.Serialize(dict);
|
||||
return (json1 == json2).ToProperty();
|
||||
}
|
||||
|
||||
[Property]
|
||||
public Property Serialize_OrderIndependent(Dictionary<string, int> dict)
|
||||
{
|
||||
var reversed = dict.Reverse().ToDictionary(x => x.Key, x => x.Value);
|
||||
var json1 = CanonicalJsonSerializer.Serialize(dict);
|
||||
var json2 = CanonicalJsonSerializer.Serialize(reversed);
|
||||
return (json1 == json2).ToProperty();
|
||||
}
|
||||
|
||||
[Property]
|
||||
public Property Digest_IsDeterministic(string input)
|
||||
{
|
||||
var obj = new { Value = input ?? string.Empty };
|
||||
var (_, digest1) = CanonicalJsonSerializer.SerializeWithDigest(obj);
|
||||
var (_, digest2) = CanonicalJsonSerializer.SerializeWithDigest(obj);
|
||||
return (digest1 == digest2).ToProperty();
|
||||
}
|
||||
}
|
||||
|
||||
public class OrderingProperties
|
||||
{
|
||||
[Property]
|
||||
public Property PackageOrdering_IsStable(List<(string purl, string name, string version)> packages)
|
||||
{
|
||||
var ordered1 = packages.StableOrder(p => p.purl, p => p.name, p => p.version, _ => null).ToList();
|
||||
var ordered2 = packages.StableOrder(p => p.purl, p => p.name, p => p.version, _ => null).ToList();
|
||||
return ordered1.SequenceEqual(ordered2).ToProperty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FsCheck.Xunit" Version="2.16.6" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Canonicalization\StellaOps.Canonicalization.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.DeltaVerdict.Engine;
|
||||
using StellaOps.DeltaVerdict.Models;
|
||||
using StellaOps.DeltaVerdict.Policy;
|
||||
using StellaOps.DeltaVerdict.Serialization;
|
||||
using StellaOps.DeltaVerdict.Signing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.DeltaVerdict.Tests;
|
||||
|
||||
public class DeltaVerdictTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeDelta_TracksComponentAndVulnerabilityChanges()
|
||||
{
|
||||
var baseVerdict = CreateVerdict(
|
||||
verdictId: "base",
|
||||
riskScore: 10,
|
||||
components:
|
||||
[
|
||||
new Component("pkg:apk/openssl@1.0", "openssl", "1.0", "apk", ["CVE-1"])
|
||||
],
|
||||
vulnerabilities:
|
||||
[
|
||||
new Vulnerability("CVE-1", "high", 7.1m, "pkg:apk/openssl@1.0", "reachable", "open")
|
||||
]);
|
||||
|
||||
var headVerdict = CreateVerdict(
|
||||
verdictId: "head",
|
||||
riskScore: 20,
|
||||
components:
|
||||
[
|
||||
new Component("pkg:apk/openssl@1.1", "openssl", "1.1", "apk", ["CVE-2"]),
|
||||
new Component("pkg:apk/zlib@2.0", "zlib", "2.0", "apk", [])
|
||||
],
|
||||
vulnerabilities:
|
||||
[
|
||||
new Vulnerability("CVE-2", "critical", 9.5m, "pkg:apk/openssl@1.1", "reachable", "open")
|
||||
]);
|
||||
|
||||
var engine = new DeltaComputationEngine(new FakeTimeProvider());
|
||||
var delta = engine.ComputeDelta(baseVerdict, headVerdict);
|
||||
|
||||
delta.AddedComponents.Should().Contain(c => c.Purl == "pkg:apk/zlib@2.0");
|
||||
delta.RemovedComponents.Should().Contain(c => c.Purl == "pkg:apk/openssl@1.0");
|
||||
delta.ChangedComponents.Should().Contain(c => c.Purl == "pkg:apk/openssl@1.0");
|
||||
delta.AddedVulnerabilities.Should().Contain(v => v.VulnerabilityId == "CVE-2");
|
||||
delta.RemovedVulnerabilities.Should().Contain(v => v.VulnerabilityId == "CVE-1");
|
||||
delta.RiskScoreDelta.Change.Should().Be(10);
|
||||
delta.Summary.TotalChanges.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RiskBudgetEvaluator_FlagsCriticalViolations()
|
||||
{
|
||||
var delta = new DeltaVerdict.Models.DeltaVerdict
|
||||
{
|
||||
DeltaId = "delta",
|
||||
SchemaVersion = "1.0.0",
|
||||
BaseVerdict = new VerdictReference("base", null, null, DateTimeOffset.UnixEpoch),
|
||||
HeadVerdict = new VerdictReference("head", null, null, DateTimeOffset.UnixEpoch),
|
||||
AddedVulnerabilities = [new VulnerabilityDelta("CVE-9", "critical", 9.9m, null, "reachable")],
|
||||
RemovedVulnerabilities = [],
|
||||
AddedComponents = [],
|
||||
RemovedComponents = [],
|
||||
ChangedComponents = [],
|
||||
ChangedVulnerabilityStatuses = [],
|
||||
RiskScoreDelta = new RiskScoreDelta(10, 15, 5, 50, RiskTrend.Degraded),
|
||||
Summary = new DeltaSummary(0, 0, 0, 1, 0, 0, 1, DeltaMagnitude.Minimal),
|
||||
ComputedAt = DateTimeOffset.UnixEpoch
|
||||
};
|
||||
|
||||
var budget = new RiskBudget
|
||||
{
|
||||
MaxNewCriticalVulnerabilities = 0,
|
||||
MaxRiskScoreIncrease = 2
|
||||
};
|
||||
|
||||
var evaluator = new RiskBudgetEvaluator();
|
||||
var result = evaluator.Evaluate(delta, budget);
|
||||
|
||||
result.IsWithinBudget.Should().BeFalse();
|
||||
result.Violations.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SigningService_RoundTrip_VerifiesEnvelope()
|
||||
{
|
||||
var delta = new DeltaVerdict.Models.DeltaVerdict
|
||||
{
|
||||
DeltaId = "delta",
|
||||
SchemaVersion = "1.0.0",
|
||||
BaseVerdict = new VerdictReference("base", null, null, DateTimeOffset.UnixEpoch),
|
||||
HeadVerdict = new VerdictReference("head", null, null, DateTimeOffset.UnixEpoch),
|
||||
AddedComponents = [],
|
||||
RemovedComponents = [],
|
||||
ChangedComponents = [],
|
||||
AddedVulnerabilities = [],
|
||||
RemovedVulnerabilities = [],
|
||||
ChangedVulnerabilityStatuses = [],
|
||||
RiskScoreDelta = new RiskScoreDelta(0, 0, 0, 0, RiskTrend.Stable),
|
||||
Summary = new DeltaSummary(0, 0, 0, 0, 0, 0, 0, DeltaMagnitude.None),
|
||||
ComputedAt = DateTimeOffset.UnixEpoch
|
||||
};
|
||||
|
||||
var service = new DeltaSigningService();
|
||||
var options = new SigningOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
SecretBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("delta-secret"))
|
||||
};
|
||||
|
||||
var signed = await service.SignAsync(delta, options);
|
||||
var verify = await service.VerifyAsync(signed, new VerificationOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
SecretBase64 = options.SecretBase64
|
||||
});
|
||||
|
||||
verify.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serializer_ComputesDeterministicDigest()
|
||||
{
|
||||
var verdict = CreateVerdict(
|
||||
verdictId: "verdict",
|
||||
riskScore: 0,
|
||||
components: [],
|
||||
vulnerabilities: []);
|
||||
|
||||
var withDigest = VerdictSerializer.WithDigest(verdict);
|
||||
withDigest.Digest.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
private static Verdict CreateVerdict(
|
||||
string verdictId,
|
||||
decimal riskScore,
|
||||
ImmutableArray<Component> components,
|
||||
ImmutableArray<Vulnerability> vulnerabilities)
|
||||
{
|
||||
return new Verdict
|
||||
{
|
||||
VerdictId = verdictId,
|
||||
Digest = null,
|
||||
ArtifactRef = "local",
|
||||
ScannedAt = DateTimeOffset.UnixEpoch,
|
||||
RiskScore = riskScore,
|
||||
Components = components,
|
||||
Vulnerabilities = vulnerabilities
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => DateTimeOffset.UnixEpoch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.DeltaVerdict\StellaOps.DeltaVerdict.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,240 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Nulls Logger;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Evidence.Budgets;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Tests.Budgets;
|
||||
|
||||
public class EvidenceBudgetServiceTests
|
||||
{
|
||||
private readonly Mock<IEvidenceRepository> _repository = new();
|
||||
private readonly Mock<IOptionsMonitor<EvidenceBudget>> _options = new();
|
||||
private readonly EvidenceBudgetService _service;
|
||||
|
||||
public EvidenceBudgetServiceTests()
|
||||
{
|
||||
_options.Setup(o => o.CurrentValue).Returns(EvidenceBudget.Default);
|
||||
_service = new EvidenceBudgetService(
|
||||
_repository.Object,
|
||||
_options.Object,
|
||||
NullLogger<EvidenceBudgetService>.Instance);
|
||||
|
||||
// Default setup: empty scan
|
||||
_repository.Setup(r => r.GetByScanIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EvidenceItem>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBudget_WithinLimit_ReturnsSuccess()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var item = CreateItem(type: EvidenceType.CallGraph, sizeBytes: 1024);
|
||||
|
||||
var result = _service.CheckBudget(scanId, item);
|
||||
|
||||
result.IsWithinBudget.Should().BeTrue();
|
||||
result.Issues.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBudget_ExceedsTotal_ReturnsViolation()
|
||||
{
|
||||
var scanId = SetupScanAtBudgetLimit();
|
||||
var item = CreateItem(type: EvidenceType.CallGraph, sizeBytes: 10 * 1024 * 1024); // 10 MB over
|
||||
|
||||
var result = _service.CheckBudget(scanId, item);
|
||||
|
||||
result.IsWithinBudget.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Contains("total budget"));
|
||||
result.BytesToFree.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBudget_ExceedsTypeLimit_ReturnsViolation()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var existingCallGraph = CreateItem(type: EvidenceType.CallGraph, sizeBytes: 49 * 1024 * 1024);
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EvidenceItem> { existingCallGraph });
|
||||
|
||||
// CallGraph limit is 50MB, adding 2MB would exceed
|
||||
var item = CreateItem(type: EvidenceType.CallGraph, sizeBytes: 2 * 1024 * 1024);
|
||||
|
||||
var result = _service.CheckBudget(scanId, item);
|
||||
|
||||
result.IsWithinBudget.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Contains("CallGraph budget"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneToFitAsync_NoExcess_NoPruning()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(type: EvidenceType.Sbom, sizeBytes: 5 * 1024 * 1024)
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
|
||||
var result = await _service.PruneToFitAsync(scanId, 50 * 1024 * 1024, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.BytesPruned.Should().Be(0);
|
||||
result.ItemsPruned.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneToFitAsync_PreservesAlwaysPreserveTypes()
|
||||
{
|
||||
var scanId = SetupScanOverBudget();
|
||||
|
||||
var result = await _service.PruneToFitAsync(scanId, 50 * 1024 * 1024, CancellationToken.None);
|
||||
|
||||
result.ItemsPruned.Should().NotContain(i => i.Type == EvidenceType.Verdict);
|
||||
result.ItemsPruned.Should().NotContain(i => i.Type == EvidenceType.Attestation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneToFitAsync_PrunesLowestPriorityFirst()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(id: Guid.NewGuid(), type: EvidenceType.RuntimeCapture, sizeBytes: 10 * 1024 * 1024), // Priority 1
|
||||
CreateItem(id: Guid.NewGuid(), type: EvidenceType.CallGraph, sizeBytes: 10 * 1024 * 1024), // Priority 2
|
||||
CreateItem(id: Guid.NewGuid(), type: EvidenceType.Sbom, sizeBytes: 10 * 1024 * 1024), // Priority 6
|
||||
CreateItem(id: Guid.NewGuid(), type: EvidenceType.Verdict, sizeBytes: 1 * 1024 * 1024) // Priority 9 (never prune)
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
|
||||
// Prune to 20MB (need to remove 11MB)
|
||||
var result = await _service.PruneToFitAsync(scanId, 20 * 1024 * 1024, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.ItemsPruned.Should().HaveCount(2);
|
||||
result.ItemsPruned[0].Type.Should().Be(EvidenceType.RuntimeCapture); // Pruned first
|
||||
result.ItemsPruned[1].Type.Should().Be(EvidenceType.CallGraph); // Pruned second
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBudgetStatus_CalculatesUtilization()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(type: EvidenceType.CallGraph, sizeBytes: 25 * 1024 * 1024), // 25 MB
|
||||
CreateItem(type: EvidenceType.Sbom, sizeBytes: 5 * 1024 * 1024) // 5 MB
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
|
||||
var status = _service.GetBudgetStatus(scanId);
|
||||
|
||||
status.ScanId.Should().Be(scanId);
|
||||
status.TotalBudgetBytes.Should().Be(100 * 1024 * 1024); // 100 MB
|
||||
status.UsedBytes.Should().Be(30 * 1024 * 1024); // 30 MB
|
||||
status.RemainingBytes.Should().Be(70 * 1024 * 1024); // 70 MB
|
||||
status.UtilizationPercent.Should().Be(30); // 30%
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBudgetStatus_CalculatesPerTypeUtilization()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(type: EvidenceType.CallGraph, sizeBytes: 25 * 1024 * 1024) // 25 of 50 MB limit
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
|
||||
var status = _service.GetBudgetStatus(scanId);
|
||||
|
||||
status.ByType.Should().ContainKey(EvidenceType.CallGraph);
|
||||
var callGraphStatus = status.ByType[EvidenceType.CallGraph];
|
||||
callGraphStatus.UsedBytes.Should().Be(25 * 1024 * 1024);
|
||||
callGraphStatus.LimitBytes.Should().Be(50 * 1024 * 1024);
|
||||
callGraphStatus.UtilizationPercent.Should().Be(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBudget_AutoPruneAction_SetsCanAutoPrune()
|
||||
{
|
||||
var budget = new EvidenceBudget
|
||||
{
|
||||
MaxScanSizeBytes = 1024,
|
||||
RetentionPolicies = EvidenceBudget.Default.RetentionPolicies,
|
||||
ExceededAction = BudgetExceededAction.AutoPrune
|
||||
};
|
||||
_options.Setup(o => o.CurrentValue).Returns(budget);
|
||||
|
||||
var scanId = Guid.NewGuid();
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(type: EvidenceType.Sbom, sizeBytes: 1000)
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
|
||||
var item = CreateItem(type: EvidenceType.CallGraph, sizeBytes: 100);
|
||||
var result = _service.CheckBudget(scanId, item);
|
||||
|
||||
result.IsWithinBudget.Should().BeFalse();
|
||||
result.RecommendedAction.Should().Be(BudgetExceededAction.AutoPrune);
|
||||
result.CanAutoPrune.Should().BeTrue();
|
||||
}
|
||||
|
||||
private Guid SetupScanAtBudgetLimit()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(type: EvidenceType.CallGraph, sizeBytes: 50 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.RuntimeCapture, sizeBytes: 20 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.Sbom, sizeBytes: 10 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.PolicyTrace, sizeBytes: 5 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.Verdict, sizeBytes: 5 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.Advisory, sizeBytes: 10 * 1024 * 1024)
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
return scanId;
|
||||
}
|
||||
|
||||
private Guid SetupScanOverBudget()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(type: EvidenceType.CallGraph, sizeBytes: 40 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.RuntimeCapture, sizeBytes: 30 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.Sbom, sizeBytes: 20 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.PolicyTrace, sizeBytes: 10 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.Verdict, sizeBytes: 5 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.Attestation, sizeBytes: 5 * 1024 * 1024)
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
return scanId;
|
||||
}
|
||||
|
||||
private static EvidenceItem CreateItem(
|
||||
Guid? id = null,
|
||||
EvidenceType type = EvidenceType.CallGraph,
|
||||
long sizeBytes = 1024)
|
||||
{
|
||||
return new EvidenceItem
|
||||
{
|
||||
Id = id ?? Guid.NewGuid(),
|
||||
ScanId = Guid.NewGuid(),
|
||||
Type = type,
|
||||
SizeBytes = sizeBytes,
|
||||
Tier = RetentionTier.Hot,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Models;
|
||||
using StellaOps.Evidence.Serialization;
|
||||
using StellaOps.Evidence.Services;
|
||||
using StellaOps.Evidence.Validation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Tests;
|
||||
|
||||
public class EvidenceIndexTests
|
||||
{
|
||||
[Fact]
|
||||
public void EvidenceLinker_BuildsIndexWithDigest()
|
||||
{
|
||||
var linker = new EvidenceLinker();
|
||||
linker.SetToolChain(CreateToolChain());
|
||||
linker.AddSbom(new SbomEvidence("sbom-1", "cyclonedx-1.6", new string('a', 64), null, 10, DateTimeOffset.UtcNow));
|
||||
linker.AddAttestation(new AttestationEvidence("att-1", "sbom", new string('b', 64), "key", true, DateTimeOffset.UtcNow, null));
|
||||
|
||||
var index = linker.Build(new VerdictReference("verdict-1", new string('c', 64), VerdictOutcome.Pass, "1.0.0"), "digest");
|
||||
|
||||
index.IndexDigest.Should().NotBeNullOrEmpty();
|
||||
index.Sboms.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceValidator_FlagsMissingSbom()
|
||||
{
|
||||
var index = CreateIndex() with { Sboms = [] };
|
||||
var validator = new EvidenceIndexValidator();
|
||||
var result = validator.Validate(index);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceSerializer_RoundTrip_PreservesFields()
|
||||
{
|
||||
var index = CreateIndex();
|
||||
var json = EvidenceIndexSerializer.Serialize(index);
|
||||
var deserialized = EvidenceIndexSerializer.Deserialize(json);
|
||||
deserialized.Should().BeEquivalentTo(index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceQueryService_BuildsSummary()
|
||||
{
|
||||
var index = CreateIndex();
|
||||
var service = new EvidenceQueryService();
|
||||
var report = service.BuildChainReport(index);
|
||||
|
||||
report.SbomCount.Should().Be(1);
|
||||
report.AttestationCount.Should().Be(1);
|
||||
}
|
||||
|
||||
private static EvidenceIndex CreateIndex()
|
||||
{
|
||||
return new EvidenceIndex
|
||||
{
|
||||
IndexId = Guid.NewGuid().ToString(),
|
||||
SchemaVersion = "1.0.0",
|
||||
Verdict = new VerdictReference("verdict-1", new string('c', 64), VerdictOutcome.Pass, "1.0.0"),
|
||||
Sboms = ImmutableArray.Create(new SbomEvidence("sbom-1", "cyclonedx-1.6", new string('a', 64), null, 10, DateTimeOffset.UtcNow)),
|
||||
Attestations = ImmutableArray.Create(new AttestationEvidence("att-1", "sbom", new string('b', 64), "key", true, DateTimeOffset.UtcNow, null)),
|
||||
VexDocuments = ImmutableArray.Create(new VexEvidence("vex-1", "openvex", new string('d', 64), "vendor", 1, ImmutableArray.Create("CVE-2024-0001"))),
|
||||
ReachabilityProofs = ImmutableArray.Create(new ReachabilityEvidence("proof-1", "CVE-2024-0001", "pkg:npm/foo@1.0.0", ReachabilityStatus.Reachable, "main", ImmutableArray.Create("main"), new string('e', 64))),
|
||||
Unknowns = ImmutableArray.Create(new UnknownEvidence("unk-1", "U-RCH", "Reachability inconclusive", "pkg:npm/foo", "CVE-2024-0001", UnknownSeverity.Medium)),
|
||||
ToolChain = CreateToolChain(),
|
||||
RunManifestDigest = new string('f', 64),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static ToolChainEvidence CreateToolChain() => new(
|
||||
"1.0.0",
|
||||
"1.0.0",
|
||||
"1.0.0",
|
||||
"1.0.0",
|
||||
"1.0.0",
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Evidence\StellaOps.Evidence.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Evidence.Models;
|
||||
using StellaOps.Replay.Engine;
|
||||
using StellaOps.Replay.Models;
|
||||
using StellaOps.Testing.Manifests.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Tests;
|
||||
|
||||
public class ReplayEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Replay_SameManifest_ProducesIdenticalVerdict()
|
||||
{
|
||||
var manifest = CreateManifest();
|
||||
var engine = CreateEngine();
|
||||
|
||||
var result1 = await engine.ReplayAsync(manifest, new ReplayOptions());
|
||||
var result2 = await engine.ReplayAsync(manifest, new ReplayOptions());
|
||||
|
||||
result1.VerdictDigest.Should().Be(result2.VerdictDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replay_DifferentManifest_ProducesDifferentVerdict()
|
||||
{
|
||||
var manifest1 = CreateManifest();
|
||||
var manifest2 = manifest1 with
|
||||
{
|
||||
FeedSnapshot = manifest1.FeedSnapshot with { Version = "v2" }
|
||||
};
|
||||
|
||||
var engine = CreateEngine();
|
||||
var result1 = await engine.ReplayAsync(manifest1, new ReplayOptions());
|
||||
var result2 = await engine.ReplayAsync(manifest2, new ReplayOptions());
|
||||
|
||||
result1.VerdictDigest.Should().NotBe(result2.VerdictDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckDeterminism_IdenticalResults_ReturnsTrue()
|
||||
{
|
||||
var engine = CreateEngine();
|
||||
var result1 = new ReplayResult { RunId = "1", VerdictDigest = "abc123", Success = true, ExecutedAt = DateTimeOffset.UtcNow };
|
||||
var result2 = new ReplayResult { RunId = "1", VerdictDigest = "abc123", Success = true, ExecutedAt = DateTimeOffset.UtcNow };
|
||||
|
||||
var check = engine.CheckDeterminism(result1, result2);
|
||||
|
||||
check.IsDeterministic.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckDeterminism_DifferentResults_ReturnsDifferences()
|
||||
{
|
||||
var engine = CreateEngine();
|
||||
var result1 = new ReplayResult
|
||||
{
|
||||
RunId = "1",
|
||||
VerdictJson = "{\"score\":100}",
|
||||
VerdictDigest = "abc123",
|
||||
Success = true,
|
||||
ExecutedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var result2 = new ReplayResult
|
||||
{
|
||||
RunId = "1",
|
||||
VerdictJson = "{\"score\":99}",
|
||||
VerdictDigest = "def456",
|
||||
Success = true,
|
||||
ExecutedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var check = engine.CheckDeterminism(result1, result2);
|
||||
|
||||
check.IsDeterministic.Should().BeFalse();
|
||||
check.Differences.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
private static ReplayEngine CreateEngine()
|
||||
{
|
||||
return new ReplayEngine(
|
||||
new FakeFeedLoader(),
|
||||
new FakePolicyLoader(),
|
||||
new FakeScannerFactory(),
|
||||
NullLogger<ReplayEngine>.Instance);
|
||||
}
|
||||
|
||||
private static RunManifest CreateManifest()
|
||||
{
|
||||
return new RunManifest
|
||||
{
|
||||
RunId = Guid.NewGuid().ToString(),
|
||||
SchemaVersion = "1.0.0",
|
||||
ArtifactDigests = ImmutableArray.Create(new ArtifactDigest("sha256", new string('a', 64), null, null)),
|
||||
FeedSnapshot = new FeedSnapshot("nvd", "v1", new string('b', 64), DateTimeOffset.UtcNow.AddHours(-1)),
|
||||
PolicySnapshot = new PolicySnapshot("1.0.0", new string('c', 64), ImmutableArray<string>.Empty),
|
||||
ToolVersions = new ToolVersions("1.0.0", "1.0.0", "1.0.0", "1.0.0", ImmutableDictionary<string, string>.Empty),
|
||||
CryptoProfile = new CryptoProfile("default", ImmutableArray<string>.Empty, ImmutableArray<string>.Empty),
|
||||
EnvironmentProfile = new EnvironmentProfile("postgres-only", false, null, null),
|
||||
CanonicalizationVersion = "1.0.0",
|
||||
InitiatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeFeedLoader : IFeedLoader
|
||||
{
|
||||
public Task<FeedSnapshot> LoadByDigestAsync(string digest, CancellationToken ct = default)
|
||||
=> Task.FromResult(new FeedSnapshot("nvd", "v1", digest, DateTimeOffset.UtcNow.AddHours(-1)));
|
||||
}
|
||||
|
||||
private sealed class FakePolicyLoader : IPolicyLoader
|
||||
{
|
||||
public Task<PolicySnapshot> LoadByDigestAsync(string digest, CancellationToken ct = default)
|
||||
=> Task.FromResult(new PolicySnapshot("1.0.0", digest, ImmutableArray<string>.Empty));
|
||||
}
|
||||
|
||||
private sealed class FakeScannerFactory : IScannerFactory
|
||||
{
|
||||
public IScanner Create(ScannerOptions options) => new FakeScanner(options);
|
||||
}
|
||||
|
||||
private sealed class FakeScanner : IScanner
|
||||
{
|
||||
private readonly ScannerOptions _options;
|
||||
public FakeScanner(ScannerOptions options) => _options = options;
|
||||
|
||||
public Task<ScanResult> ScanAsync(ImmutableArray<ArtifactDigest> artifacts, CancellationToken ct = default)
|
||||
{
|
||||
var verdict = new
|
||||
{
|
||||
feedVersion = _options.FeedSnapshot.Version,
|
||||
policyDigest = _options.PolicySnapshot.LatticeRulesDigest
|
||||
};
|
||||
var evidence = new EvidenceIndex
|
||||
{
|
||||
IndexId = Guid.NewGuid().ToString(),
|
||||
SchemaVersion = "1.0.0",
|
||||
Verdict = new VerdictReference("v1", new string('d', 64), VerdictOutcome.Pass, null),
|
||||
Sboms = ImmutableArray<SbomEvidence>.Empty,
|
||||
Attestations = ImmutableArray<AttestationEvidence>.Empty,
|
||||
ToolChain = new ToolChainEvidence("1", "1", "1", "1", "1", ImmutableDictionary<string, string>.Empty),
|
||||
RunManifestDigest = new string('e', 64),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
return Task.FromResult(new ScanResult(verdict, evidence, 10));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Replay\StellaOps.Replay.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Testing.Manifests\StellaOps.Testing.Manifests.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Evidence\StellaOps.Evidence.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Testing.Manifests.Models;
|
||||
using StellaOps.Testing.Manifests.Serialization;
|
||||
using StellaOps.Testing.Manifests.Validation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Testing.Manifests.Tests;
|
||||
|
||||
public class RunManifestTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_ValidManifest_ProducesCanonicalJson()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var json1 = RunManifestSerializer.Serialize(manifest);
|
||||
var json2 = RunManifestSerializer.Serialize(manifest);
|
||||
json1.Should().Be(json2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_SameManifest_ProducesSameDigest()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var digest1 = RunManifestSerializer.ComputeDigest(manifest);
|
||||
var digest2 = RunManifestSerializer.ComputeDigest(manifest);
|
||||
digest1.Should().Be(digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_DifferentManifest_ProducesDifferentDigest()
|
||||
{
|
||||
var manifest1 = CreateTestManifest();
|
||||
var manifest2 = manifest1 with { RunId = Guid.NewGuid().ToString() };
|
||||
var digest1 = RunManifestSerializer.ComputeDigest(manifest1);
|
||||
var digest2 = RunManifestSerializer.ComputeDigest(manifest2);
|
||||
digest1.Should().NotBe(digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidManifest_ReturnsSuccess()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var validator = new RunManifestValidator();
|
||||
var result = validator.Validate(manifest);
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyArtifacts_ReturnsFalse()
|
||||
{
|
||||
var manifest = CreateTestManifest() with { ArtifactDigests = [] };
|
||||
var validator = new RunManifestValidator();
|
||||
var result = validator.Validate(manifest);
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_PreservesAllFields()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var json = RunManifestSerializer.Serialize(manifest);
|
||||
var deserialized = RunManifestSerializer.Deserialize(json);
|
||||
deserialized.Should().BeEquivalentTo(manifest);
|
||||
}
|
||||
|
||||
private static RunManifest CreateTestManifest()
|
||||
{
|
||||
return new RunManifest
|
||||
{
|
||||
RunId = Guid.NewGuid().ToString(),
|
||||
SchemaVersion = "1.0.0",
|
||||
ArtifactDigests = ImmutableArray.Create(
|
||||
new ArtifactDigest("sha256", new string('a', 64), "application/vnd.oci.image.layer.v1.tar", "example")),
|
||||
SbomDigests = ImmutableArray.Create(
|
||||
new SbomReference("cyclonedx-1.6", new string('b', 64), "sbom.json")),
|
||||
FeedSnapshot = new FeedSnapshot("nvd", "2025.12.01", new string('c', 64), DateTimeOffset.UtcNow.AddHours(-1)),
|
||||
PolicySnapshot = new PolicySnapshot("1.0.0", new string('d', 64), ImmutableArray.Create("rule-1")),
|
||||
ToolVersions = new ToolVersions("1.0.0", "1.0.0", "1.0.0", "1.0.0", ImmutableDictionary<string, string>.Empty),
|
||||
CryptoProfile = new CryptoProfile("default", ImmutableArray.Create("root-1"), ImmutableArray.Create("sha256")),
|
||||
EnvironmentProfile = new EnvironmentProfile("postgres-only", false, "16", null),
|
||||
PrngSeed = 1234,
|
||||
CanonicalizationVersion = "1.0.0",
|
||||
InitiatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Testing.Manifests\StellaOps.Testing.Manifests.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user