sprints work
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ProofSpineBuilderExtensions.cs
|
||||
// Sprint: SPRINT_8100_0012_0003 - Graph Root Attestation Service
|
||||
// Task: GROOT-8100-012 - Extend ProofSpineBuilder with BuildWithAttestationAsync()
|
||||
// Description: Extensions for ProofSpineBuilder to emit graph root attestations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.GraphRoot;
|
||||
using StellaOps.Attestor.GraphRoot.Models;
|
||||
using StellaOps.Replay.Core;
|
||||
using AttestorEnvelope = StellaOps.Attestor.Envelope.DsseEnvelope;
|
||||
using AttestorSignature = StellaOps.Attestor.Envelope.DsseSignature;
|
||||
|
||||
namespace StellaOps.Scanner.ProofSpine;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="ProofSpineBuilder"/> to support graph root attestation.
|
||||
/// </summary>
|
||||
public static class ProofSpineBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the proof spine and creates a graph root attestation.
|
||||
/// </summary>
|
||||
/// <param name="builder">The proof spine builder.</param>
|
||||
/// <param name="attestor">The graph root attestor service.</param>
|
||||
/// <param name="request">The attestation request configuration.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A proof spine with attached graph root attestation.</returns>
|
||||
public static async Task<ProofSpine> BuildWithAttestationAsync(
|
||||
this ProofSpineBuilder builder,
|
||||
IGraphRootAttestor attestor,
|
||||
ProofSpineAttestationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(attestor);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// Build the spine first
|
||||
var spine = await builder.BuildAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Create attestation request from spine data
|
||||
var attestRequest = new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.ProofSpine,
|
||||
NodeIds = spine.Segments.Select(s => s.SegmentId).ToList(),
|
||||
EdgeIds = BuildEdgeIds(spine.Segments),
|
||||
PolicyDigest = request.PolicyDigest,
|
||||
FeedsDigest = request.FeedsDigest,
|
||||
ToolchainDigest = request.ToolchainDigest,
|
||||
ParamsDigest = request.ParamsDigest,
|
||||
ArtifactDigest = request.ArtifactDigest ?? spine.ArtifactId,
|
||||
EvidenceIds = request.EvidenceIds,
|
||||
PublishToRekor = request.PublishToRekor,
|
||||
SigningKeyId = request.SigningKeyId
|
||||
};
|
||||
|
||||
// Create the attestation
|
||||
var attestResult = await attestor.AttestAsync(attestRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Convert Attestor envelope to Replay.Core envelope
|
||||
var replayEnvelope = ConvertToReplayEnvelope(attestResult.Envelope);
|
||||
|
||||
// Return spine with attestation attached
|
||||
return spine with
|
||||
{
|
||||
GraphRootAttestationId = attestResult.RootHash,
|
||||
GraphRootEnvelope = replayEnvelope
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an Attestor.Envelope.DsseEnvelope to Replay.Core.DsseEnvelope.
|
||||
/// </summary>
|
||||
private static DsseEnvelope ConvertToReplayEnvelope(AttestorEnvelope envelope)
|
||||
{
|
||||
var base64Payload = Convert.ToBase64String(envelope.Payload.Span);
|
||||
var signatures = envelope.Signatures
|
||||
.Select(s => new DsseSignature(s.KeyId ?? string.Empty, s.Signature))
|
||||
.ToList();
|
||||
|
||||
return new DsseEnvelope(envelope.PayloadType, base64Payload, signatures);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds edge IDs from segment chain (each segment links to the previous).
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string> BuildEdgeIds(IReadOnlyList<ProofSegment> segments)
|
||||
{
|
||||
var edges = new List<string>(segments.Count - 1);
|
||||
|
||||
for (var i = 1; i < segments.Count; i++)
|
||||
{
|
||||
var prevSegment = segments[i - 1];
|
||||
var currSegment = segments[i];
|
||||
edges.Add($"{prevSegment.SegmentId}->{currSegment.SegmentId}");
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for proof spine attestation.
|
||||
/// </summary>
|
||||
public sealed record ProofSpineAttestationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Digest of the policy profile used for evaluation.
|
||||
/// </summary>
|
||||
public required string PolicyDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the advisory/vulnerability feeds snapshot.
|
||||
/// </summary>
|
||||
public required string FeedsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the toolchain (scanner, analyzer versions).
|
||||
/// </summary>
|
||||
public required string ToolchainDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the evaluation parameters.
|
||||
/// </summary>
|
||||
public required string ParamsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Override artifact digest (defaults to spine's ArtifactId).
|
||||
/// </summary>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence IDs linked to this proof spine.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> EvidenceIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether to publish the attestation to Rekor transparency log.
|
||||
/// </summary>
|
||||
public bool PublishToRekor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Specific signing key ID to use.
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
}
|
||||
@@ -5,6 +5,19 @@ namespace StellaOps.Scanner.ProofSpine;
|
||||
/// <summary>
|
||||
/// Represents a complete verifiable decision chain from SBOM to VEX verdict.
|
||||
/// </summary>
|
||||
/// <param name="SpineId">Content-addressed ID of this proof spine.</param>
|
||||
/// <param name="ArtifactId">The artifact (container image, package) this spine evaluates.</param>
|
||||
/// <param name="VulnerabilityId">The vulnerability ID being evaluated.</param>
|
||||
/// <param name="PolicyProfileId">The policy profile used for evaluation.</param>
|
||||
/// <param name="Segments">Ordered list of evidence segments in the proof chain.</param>
|
||||
/// <param name="Verdict">Final verdict (affected, not_affected, fixed, under_investigation).</param>
|
||||
/// <param name="VerdictReason">Human-readable explanation of the verdict.</param>
|
||||
/// <param name="RootHash">Merkle root hash of all segment hashes.</param>
|
||||
/// <param name="ScanRunId">ID of the scan run that produced this spine.</param>
|
||||
/// <param name="CreatedAt">When this spine was created.</param>
|
||||
/// <param name="SupersededBySpineId">If superseded, the ID of the newer spine.</param>
|
||||
/// <param name="GraphRootAttestationId">Optional: Content-addressed ID of the graph root attestation.</param>
|
||||
/// <param name="GraphRootEnvelope">Optional: DSSE envelope containing the graph root attestation.</param>
|
||||
public sealed record ProofSpine(
|
||||
string SpineId,
|
||||
string ArtifactId,
|
||||
@@ -16,7 +29,9 @@ public sealed record ProofSpine(
|
||||
string RootHash,
|
||||
string ScanRunId,
|
||||
DateTimeOffset CreatedAt,
|
||||
string? SupersededBySpineId);
|
||||
string? SupersededBySpineId,
|
||||
string? GraphRootAttestationId = null,
|
||||
DsseEnvelope? GraphRootEnvelope = null);
|
||||
|
||||
/// <summary>
|
||||
/// A single evidence segment in the proof chain.
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="../../../Attestor/__Libraries/StellaOps.Attestor.GraphRoot/StellaOps.Attestor.GraphRoot.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GatingReasonServiceTests.cs
|
||||
// Sprint: SPRINT_9200_0001_0001_SCANNER_gated_triage_contracts
|
||||
// Tasks: GTR-9200-019, GTR-9200-020, GTR-9200-021
|
||||
// Description: Unit tests for gating reason logic, bucket counting, and VEX trust.
|
||||
// Tests the gating contract DTOs and their expected behavior.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for gating contracts and gating reason logic.
|
||||
/// Covers GTR-9200-019 (all gating reason paths), GTR-9200-020 (bucket counting),
|
||||
/// and GTR-9200-021 (VEX trust threshold comparison).
|
||||
/// </summary>
|
||||
public sealed class GatingReasonServiceTests
|
||||
{
|
||||
#region GTR-9200-019: Gating Reason Path Tests - Entity Model Validation
|
||||
|
||||
[Theory]
|
||||
[InlineData(GatingReason.None, false)]
|
||||
[InlineData(GatingReason.Unreachable, true)]
|
||||
[InlineData(GatingReason.PolicyDismissed, true)]
|
||||
[InlineData(GatingReason.Backported, true)]
|
||||
[InlineData(GatingReason.VexNotAffected, true)]
|
||||
[InlineData(GatingReason.Superseded, true)]
|
||||
[InlineData(GatingReason.UserMuted, true)]
|
||||
public void FindingGatingStatusDto_IsHiddenByDefault_MatchesGatingReason(
|
||||
GatingReason reason, bool expectedHidden)
|
||||
{
|
||||
// Arrange & Act
|
||||
var dto = new FindingGatingStatusDto
|
||||
{
|
||||
GatingReason = reason,
|
||||
IsHiddenByDefault = reason != GatingReason.None
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.IsHiddenByDefault.Should().Be(expectedHidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindingGatingStatusDto_UserMuted_HasExpectedExplanation()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new FindingGatingStatusDto
|
||||
{
|
||||
GatingReason = GatingReason.UserMuted,
|
||||
IsHiddenByDefault = true,
|
||||
GatingExplanation = "This finding has been muted by a user decision.",
|
||||
WouldShowIf = new[] { "Un-mute the finding in triage settings" }
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.GatingExplanation.Should().Contain("muted");
|
||||
dto.WouldShowIf.Should().ContainSingle();
|
||||
dto.WouldShowIf.Should().Contain("Un-mute the finding in triage settings");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindingGatingStatusDto_PolicyDismissed_HasPolicyIdInExplanation()
|
||||
{
|
||||
// Arrange
|
||||
var policyId = "security-policy-v1";
|
||||
var dto = new FindingGatingStatusDto
|
||||
{
|
||||
GatingReason = GatingReason.PolicyDismissed,
|
||||
IsHiddenByDefault = true,
|
||||
GatingExplanation = $"Policy '{policyId}' dismissed this finding: Low risk tolerance",
|
||||
WouldShowIf = new[] { "Update policy to remove dismissal rule", "Remove policy exception" }
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.GatingExplanation.Should().Contain(policyId);
|
||||
dto.WouldShowIf.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindingGatingStatusDto_VexNotAffected_IncludesTrustInfo()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new FindingGatingStatusDto
|
||||
{
|
||||
GatingReason = GatingReason.VexNotAffected,
|
||||
IsHiddenByDefault = true,
|
||||
GatingExplanation = "VEX statement from 'redhat' declares not_affected (trust: 95%)",
|
||||
WouldShowIf = new[] { "Contest the VEX statement", "Lower trust threshold in policy" }
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.GatingExplanation.Should().Contain("redhat");
|
||||
dto.GatingExplanation.Should().Contain("trust");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindingGatingStatusDto_Backported_IncludesFixedVersion()
|
||||
{
|
||||
// Arrange
|
||||
var fixedVersion = "1.2.3-ubuntu1";
|
||||
var dto = new FindingGatingStatusDto
|
||||
{
|
||||
GatingReason = GatingReason.Backported,
|
||||
IsHiddenByDefault = true,
|
||||
GatingExplanation = $"Vulnerability is fixed via distro backport in version {fixedVersion}.",
|
||||
WouldShowIf = new[] { "Override backport detection", "Report false positive in backport fix" }
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.GatingExplanation.Should().Contain(fixedVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindingGatingStatusDto_Superseded_IncludesSupersedingCve()
|
||||
{
|
||||
// Arrange
|
||||
var supersedingCve = "CVE-2024-9999";
|
||||
var dto = new FindingGatingStatusDto
|
||||
{
|
||||
GatingReason = GatingReason.Superseded,
|
||||
IsHiddenByDefault = true,
|
||||
GatingExplanation = $"This CVE has been superseded by {supersedingCve}.",
|
||||
WouldShowIf = new[] { "Show superseded CVEs in settings" }
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.GatingExplanation.Should().Contain(supersedingCve);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindingGatingStatusDto_Unreachable_HasSubgraphId()
|
||||
{
|
||||
// Arrange
|
||||
var subgraphId = "sha256:subgraph123";
|
||||
var dto = new FindingGatingStatusDto
|
||||
{
|
||||
GatingReason = GatingReason.Unreachable,
|
||||
IsHiddenByDefault = true,
|
||||
SubgraphId = subgraphId,
|
||||
GatingExplanation = "Vulnerable code is not reachable from any application entrypoint.",
|
||||
WouldShowIf = new[] { "Add new entrypoint trace", "Enable 'show unreachable' filter" }
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.SubgraphId.Should().Be(subgraphId);
|
||||
dto.GatingExplanation.Should().Contain("not reachable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindingGatingStatusDto_None_IsNotHidden()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new FindingGatingStatusDto
|
||||
{
|
||||
GatingReason = GatingReason.None,
|
||||
IsHiddenByDefault = false
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.IsHiddenByDefault.Should().BeFalse();
|
||||
dto.GatingExplanation.Should().BeNull();
|
||||
dto.WouldShowIf.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GTR-9200-020: Bucket Counting Logic Tests
|
||||
|
||||
[Fact]
|
||||
public void GatedBucketsSummaryDto_Empty_ReturnsZeroCounts()
|
||||
{
|
||||
// Arrange & Act
|
||||
var dto = GatedBucketsSummaryDto.Empty;
|
||||
|
||||
// Assert
|
||||
dto.UnreachableCount.Should().Be(0);
|
||||
dto.PolicyDismissedCount.Should().Be(0);
|
||||
dto.BackportedCount.Should().Be(0);
|
||||
dto.VexNotAffectedCount.Should().Be(0);
|
||||
dto.SupersededCount.Should().Be(0);
|
||||
dto.UserMutedCount.Should().Be(0);
|
||||
dto.TotalHiddenCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GatedBucketsSummaryDto_TotalHiddenCount_SumsAllBuckets()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new GatedBucketsSummaryDto
|
||||
{
|
||||
UnreachableCount = 10,
|
||||
PolicyDismissedCount = 5,
|
||||
BackportedCount = 3,
|
||||
VexNotAffectedCount = 7,
|
||||
SupersededCount = 2,
|
||||
UserMutedCount = 1
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.TotalHiddenCount.Should().Be(28);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GatedBucketsSummaryDto_WithMixedCounts_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new GatedBucketsSummaryDto
|
||||
{
|
||||
UnreachableCount = 15,
|
||||
PolicyDismissedCount = 3,
|
||||
BackportedCount = 7,
|
||||
VexNotAffectedCount = 12,
|
||||
SupersededCount = 2,
|
||||
UserMutedCount = 5
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.TotalHiddenCount.Should().Be(44);
|
||||
dto.UnreachableCount.Should().Be(15);
|
||||
dto.VexNotAffectedCount.Should().Be(12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BulkTriageQueryWithGatingResponseDto_IncludesGatedBuckets()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new BulkTriageQueryWithGatingResponseDto
|
||||
{
|
||||
TotalCount = 100,
|
||||
VisibleCount = 72,
|
||||
GatedBuckets = new GatedBucketsSummaryDto
|
||||
{
|
||||
UnreachableCount = 15,
|
||||
PolicyDismissedCount = 5,
|
||||
BackportedCount = 3,
|
||||
VexNotAffectedCount = 5
|
||||
},
|
||||
Findings = Array.Empty<FindingTriageStatusWithGatingDto>()
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.TotalCount.Should().Be(100);
|
||||
dto.VisibleCount.Should().Be(72);
|
||||
dto.GatedBuckets.Should().NotBeNull();
|
||||
dto.GatedBuckets!.TotalHiddenCount.Should().Be(28);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BulkTriageQueryWithGatingRequestDto_SupportsGatingReasonFilter()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new BulkTriageQueryWithGatingRequestDto
|
||||
{
|
||||
Query = new BulkTriageQueryRequestDto(),
|
||||
IncludeHidden = true,
|
||||
GatingReasonFilter = new[] { GatingReason.Unreachable, GatingReason.VexNotAffected }
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.IncludeHidden.Should().BeTrue();
|
||||
dto.GatingReasonFilter.Should().HaveCount(2);
|
||||
dto.GatingReasonFilter.Should().Contain(GatingReason.Unreachable);
|
||||
dto.GatingReasonFilter.Should().Contain(GatingReason.VexNotAffected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BulkTriageQueryWithGatingRequestDto_DefaultsToNotIncludeHidden()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new BulkTriageQueryWithGatingRequestDto
|
||||
{
|
||||
Query = new BulkTriageQueryRequestDto()
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.IncludeHidden.Should().BeFalse();
|
||||
dto.GatingReasonFilter.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GTR-9200-021: VEX Trust Threshold Comparison Tests
|
||||
|
||||
[Fact]
|
||||
public void VexTrustBreakdownDto_AllComponents_SumToCompositeScore()
|
||||
{
|
||||
// Arrange - weights: issuer=0.4, recency=0.2, justification=0.2, evidence=0.2
|
||||
var dto = new VexTrustBreakdownDto
|
||||
{
|
||||
IssuerTrust = 1.0, // Max issuer trust (NVD)
|
||||
RecencyTrust = 1.0, // Very recent
|
||||
JustificationTrust = 1.0, // Detailed justification
|
||||
EvidenceTrust = 1.0 // Signed with ledger
|
||||
};
|
||||
|
||||
// Assert - all max values = composite score of 1.0
|
||||
var compositeScore = (dto.IssuerTrust * 0.4) +
|
||||
(dto.RecencyTrust * 0.2) +
|
||||
(dto.JustificationTrust * 0.2) +
|
||||
(dto.EvidenceTrust * 0.2);
|
||||
compositeScore.Should().Be(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexTrustBreakdownDto_LowIssuerTrust_ReducesCompositeScore()
|
||||
{
|
||||
// Arrange - unknown issuer has low trust (0.5)
|
||||
var dto = new VexTrustBreakdownDto
|
||||
{
|
||||
IssuerTrust = 0.5, // Unknown issuer
|
||||
RecencyTrust = 1.0,
|
||||
JustificationTrust = 1.0,
|
||||
EvidenceTrust = 1.0
|
||||
};
|
||||
|
||||
// Assert
|
||||
var compositeScore = (dto.IssuerTrust * 0.4) +
|
||||
(dto.RecencyTrust * 0.2) +
|
||||
(dto.JustificationTrust * 0.2) +
|
||||
(dto.EvidenceTrust * 0.2);
|
||||
compositeScore.Should().Be(0.8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageVexTrustStatusDto_MeetsPolicyThreshold_WhenTrustExceedsThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new TriageVexTrustStatusDto
|
||||
{
|
||||
VexStatus = new TriageVexStatusDto { Status = "not_affected" },
|
||||
TrustScore = 0.85,
|
||||
PolicyTrustThreshold = 0.7,
|
||||
MeetsPolicyThreshold = true
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.TrustScore.Should().NotBeNull();
|
||||
dto.PolicyTrustThreshold.Should().NotBeNull();
|
||||
dto.TrustScore!.Value.Should().BeGreaterThan(dto.PolicyTrustThreshold!.Value);
|
||||
dto.MeetsPolicyThreshold.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageVexTrustStatusDto_DoesNotMeetThreshold_WhenTrustBelowThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new TriageVexTrustStatusDto
|
||||
{
|
||||
VexStatus = new TriageVexStatusDto { Status = "not_affected" },
|
||||
TrustScore = 0.5,
|
||||
PolicyTrustThreshold = 0.7,
|
||||
MeetsPolicyThreshold = false
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.TrustScore.Should().NotBeNull();
|
||||
dto.PolicyTrustThreshold.Should().NotBeNull();
|
||||
dto.TrustScore!.Value.Should().BeLessThan(dto.PolicyTrustThreshold!.Value);
|
||||
dto.MeetsPolicyThreshold.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("nvd", 1.0)]
|
||||
[InlineData("redhat", 0.95)]
|
||||
[InlineData("canonical", 0.95)]
|
||||
[InlineData("debian", 0.95)]
|
||||
[InlineData("suse", 0.9)]
|
||||
[InlineData("microsoft", 0.9)]
|
||||
public void VexIssuerTrust_KnownIssuers_HaveExpectedTrustScores(string issuer, double expectedTrust)
|
||||
{
|
||||
// This test documents the expected trust scores for known issuers
|
||||
// The actual implementation is in GatingReasonService.GetIssuerTrust()
|
||||
expectedTrust.Should().BeGreaterOrEqualTo(0.9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexRecencyTrust_RecentStatement_HasHighTrust()
|
||||
{
|
||||
// Arrange - VEX from within a week
|
||||
var validFrom = DateTimeOffset.UtcNow.AddDays(-3);
|
||||
var age = DateTimeOffset.UtcNow - validFrom;
|
||||
|
||||
// Assert - within a week = trust 1.0
|
||||
age.TotalDays.Should().BeLessThan(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexRecencyTrust_OldStatement_HasLowTrust()
|
||||
{
|
||||
// Arrange - VEX from over a year ago
|
||||
var validFrom = DateTimeOffset.UtcNow.AddYears(-2);
|
||||
var age = DateTimeOffset.UtcNow - validFrom;
|
||||
|
||||
// Assert - over a year = trust 0.3
|
||||
age.TotalDays.Should().BeGreaterThan(365);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexJustificationTrust_DetailedJustification_HasHighTrust()
|
||||
{
|
||||
// Arrange - 500+ chars = trust 1.0
|
||||
var justification = new string('x', 600);
|
||||
|
||||
// Assert
|
||||
justification.Length.Should().BeGreaterOrEqualTo(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexJustificationTrust_ShortJustification_HasLowTrust()
|
||||
{
|
||||
// Arrange - < 50 chars = trust 0.4
|
||||
var justification = "short";
|
||||
|
||||
// Assert
|
||||
justification.Length.Should().BeLessThan(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexEvidenceTrust_SignedWithLedger_HasHighTrust()
|
||||
{
|
||||
// Arrange - DSSE envelope + signature ref + source ref
|
||||
var vex = new TriageEffectiveVex
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Status = TriageVexStatus.NotAffected,
|
||||
DsseEnvelopeHash = "sha256:signed",
|
||||
SignatureRef = "ledger-entry",
|
||||
SourceDomain = "nvd",
|
||||
SourceRef = "NVD-CVE-2024-1234"
|
||||
};
|
||||
|
||||
// Assert - all evidence factors present
|
||||
vex.DsseEnvelopeHash.Should().NotBeNull();
|
||||
vex.SignatureRef.Should().NotBeNull();
|
||||
vex.SourceRef.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexEvidenceTrust_NoEvidence_HasBaseTrust()
|
||||
{
|
||||
// Arrange - no signature, no ledger, no source
|
||||
var vex = new TriageEffectiveVex
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Status = TriageVexStatus.NotAffected,
|
||||
DsseEnvelopeHash = null,
|
||||
SignatureRef = null,
|
||||
SourceDomain = "unknown",
|
||||
SourceRef = "unknown"
|
||||
};
|
||||
|
||||
// Assert - base trust only
|
||||
vex.DsseEnvelopeHash.Should().BeNull();
|
||||
vex.SignatureRef.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases and Entity Model Validation
|
||||
|
||||
[Fact]
|
||||
public void TriageFinding_RequiredFields_AreSet()
|
||||
{
|
||||
// Arrange
|
||||
var finding = new TriageFinding
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AssetLabel = "test-asset",
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
CveId = "CVE-2024-1234"
|
||||
};
|
||||
|
||||
// Assert
|
||||
finding.AssetLabel.Should().NotBeNullOrEmpty();
|
||||
finding.Purl.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriagePolicyDecision_PolicyActions_AreValid()
|
||||
{
|
||||
// Valid actions: dismiss, waive, tolerate, block
|
||||
var validActions = new[] { "dismiss", "waive", "tolerate", "block" };
|
||||
|
||||
foreach (var action in validActions)
|
||||
{
|
||||
var decision = new TriagePolicyDecision
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PolicyId = "test-policy",
|
||||
Action = action
|
||||
};
|
||||
|
||||
decision.Action.Should().Be(action);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageEffectiveVex_VexStatuses_AreAllDefined()
|
||||
{
|
||||
// Arrange
|
||||
var statuses = Enum.GetValues<TriageVexStatus>();
|
||||
|
||||
// Assert - all expected statuses exist
|
||||
statuses.Should().Contain(TriageVexStatus.NotAffected);
|
||||
statuses.Should().Contain(TriageVexStatus.Affected);
|
||||
statuses.Should().Contain(TriageVexStatus.UnderInvestigation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageReachability_Values_AreAllDefined()
|
||||
{
|
||||
// Arrange
|
||||
var values = Enum.GetValues<TriageReachability>();
|
||||
|
||||
// Assert
|
||||
values.Should().Contain(TriageReachability.Yes);
|
||||
values.Should().Contain(TriageReachability.No);
|
||||
values.Should().Contain(TriageReachability.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageReachabilityResult_RequiredInputsHash_IsSet()
|
||||
{
|
||||
// Arrange
|
||||
var result = new TriageReachabilityResult
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Reachable = TriageReachability.No,
|
||||
InputsHash = "sha256:inputs-hash",
|
||||
SubgraphId = "sha256:subgraph"
|
||||
};
|
||||
|
||||
// Assert
|
||||
result.InputsHash.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GatingReason_AllValues_HaveCorrectNumericMapping()
|
||||
{
|
||||
// Document the enum values for API stability
|
||||
GatingReason.None.Should().Be((GatingReason)0);
|
||||
GatingReason.Unreachable.Should().Be((GatingReason)1);
|
||||
GatingReason.PolicyDismissed.Should().Be((GatingReason)2);
|
||||
GatingReason.Backported.Should().Be((GatingReason)3);
|
||||
GatingReason.VexNotAffected.Should().Be((GatingReason)4);
|
||||
GatingReason.Superseded.Should().Be((GatingReason)5);
|
||||
GatingReason.UserMuted.Should().Be((GatingReason)6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindingTriageStatusWithGatingDto_CombinesBaseStatusWithGating()
|
||||
{
|
||||
// Arrange
|
||||
var baseStatus = new FindingTriageStatusDto
|
||||
{
|
||||
FindingId = Guid.NewGuid().ToString(),
|
||||
Lane = "high",
|
||||
Verdict = "Block"
|
||||
};
|
||||
var gating = new FindingGatingStatusDto
|
||||
{
|
||||
GatingReason = GatingReason.Unreachable,
|
||||
IsHiddenByDefault = true
|
||||
};
|
||||
|
||||
var dto = new FindingTriageStatusWithGatingDto
|
||||
{
|
||||
BaseStatus = baseStatus,
|
||||
Gating = gating
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.BaseStatus.Should().NotBeNull();
|
||||
dto.Gating.Should().NotBeNull();
|
||||
dto.Gating!.GatingReason.Should().Be(GatingReason.Unreachable);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReplayCommandServiceTests.cs
|
||||
// Sprint: SPRINT_9200_0001_0003_CLI_replay_command_generator
|
||||
// Tasks: RCG-9200-025 through RCG-9200-029
|
||||
// Description: Unit tests for replay command generation and evidence bundle logic.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for replay command contracts and service behavior.
|
||||
/// Covers RCG-9200-025 (command formats), RCG-9200-026 (bundle generation),
|
||||
/// RCG-9200-029 (determinism tests).
|
||||
/// </summary>
|
||||
public sealed class ReplayCommandServiceTests
|
||||
{
|
||||
#region RCG-9200-025: ReplayCommandService - All Command Formats
|
||||
|
||||
[Fact]
|
||||
public void ReplayCommandDto_FullCommand_ContainsAllParameters()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new ReplayCommandDto
|
||||
{
|
||||
Type = "full",
|
||||
Command = "stellaops replay --target \"pkg:npm/lodash@4.17.21\" --cve CVE-2024-0001 --feed-snapshot sha256:abc --policy-hash sha256:def --verify",
|
||||
Shell = "bash",
|
||||
RequiresNetwork = true,
|
||||
Parts = new ReplayCommandPartsDto
|
||||
{
|
||||
Binary = "stellaops",
|
||||
Subcommand = "replay",
|
||||
Target = "pkg:npm/lodash@4.17.21",
|
||||
Arguments = new Dictionary<string, string>
|
||||
{
|
||||
["cve"] = "CVE-2024-0001",
|
||||
["feed-snapshot"] = "sha256:abc",
|
||||
["policy-hash"] = "sha256:def"
|
||||
},
|
||||
Flags = new[] { "verify" }
|
||||
},
|
||||
Prerequisites = new[]
|
||||
{
|
||||
"stellaops CLI installed",
|
||||
"Network access to feed servers"
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.Type.Should().Be("full");
|
||||
dto.Command.Should().Contain("--target");
|
||||
dto.Command.Should().Contain("--cve CVE-2024-0001");
|
||||
dto.Command.Should().Contain("--feed-snapshot");
|
||||
dto.Command.Should().Contain("--policy-hash");
|
||||
dto.Command.Should().Contain("--verify");
|
||||
dto.RequiresNetwork.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayCommandDto_ShortCommand_UsesSnapshotReference()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new ReplayCommandDto
|
||||
{
|
||||
Type = "short",
|
||||
Command = "stellaops replay --target \"pkg:npm/lodash@4.17.21\" --cve CVE-2024-0001 --snapshot snap-2024-12-24 --verify",
|
||||
Shell = "bash",
|
||||
RequiresNetwork = true,
|
||||
Parts = new ReplayCommandPartsDto
|
||||
{
|
||||
Binary = "stellaops",
|
||||
Subcommand = "replay",
|
||||
Target = "pkg:npm/lodash@4.17.21",
|
||||
Arguments = new Dictionary<string, string>
|
||||
{
|
||||
["cve"] = "CVE-2024-0001",
|
||||
["snapshot"] = "snap-2024-12-24"
|
||||
},
|
||||
Flags = new[] { "verify" }
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.Type.Should().Be("short");
|
||||
dto.Command.Should().Contain("--snapshot snap-2024-12-24");
|
||||
dto.Command.Should().NotContain("--feed-snapshot");
|
||||
dto.Command.Should().NotContain("--policy-hash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayCommandDto_OfflineCommand_HasOfflineFlag()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new ReplayCommandDto
|
||||
{
|
||||
Type = "offline",
|
||||
Command = "stellaops replay --target \"pkg:npm/lodash@4.17.21\" --cve CVE-2024-0001 --bundle ./evidence-bundle.tar.gz --offline --verify",
|
||||
Shell = "bash",
|
||||
RequiresNetwork = false,
|
||||
Parts = new ReplayCommandPartsDto
|
||||
{
|
||||
Binary = "stellaops",
|
||||
Subcommand = "replay",
|
||||
Target = "pkg:npm/lodash@4.17.21",
|
||||
Arguments = new Dictionary<string, string>
|
||||
{
|
||||
["cve"] = "CVE-2024-0001",
|
||||
["bundle"] = "./evidence-bundle.tar.gz"
|
||||
},
|
||||
Flags = new[] { "offline", "verify" }
|
||||
},
|
||||
Prerequisites = new[]
|
||||
{
|
||||
"stellaops CLI installed",
|
||||
"Evidence bundle downloaded: evidence-bundle.tar.gz"
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.Type.Should().Be("offline");
|
||||
dto.Command.Should().Contain("--offline");
|
||||
dto.Command.Should().Contain("--bundle");
|
||||
dto.RequiresNetwork.Should().BeFalse();
|
||||
dto.Prerequisites.Should().Contain(p => p.Contains("bundle"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("bash")]
|
||||
[InlineData("powershell")]
|
||||
[InlineData("cmd")]
|
||||
public void ReplayCommandDto_SupportsMultipleShells(string shell)
|
||||
{
|
||||
// Arrange
|
||||
var dto = new ReplayCommandDto
|
||||
{
|
||||
Type = "full",
|
||||
Command = shell == "powershell"
|
||||
? "stellaops.exe replay --target \"pkg:npm/lodash@4.17.21\" --verify"
|
||||
: "stellaops replay --target \"pkg:npm/lodash@4.17.21\" --verify",
|
||||
Shell = shell,
|
||||
RequiresNetwork = true
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.Shell.Should().Be(shell);
|
||||
if (shell == "powershell")
|
||||
{
|
||||
dto.Command.Should().Contain(".exe");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayCommandPartsDto_HasStructuredBreakdown()
|
||||
{
|
||||
// Arrange
|
||||
var parts = new ReplayCommandPartsDto
|
||||
{
|
||||
Binary = "stellaops",
|
||||
Subcommand = "scan replay",
|
||||
Target = "sha256:abc123def456",
|
||||
Arguments = new Dictionary<string, string>
|
||||
{
|
||||
["feed-snapshot"] = "sha256:feed123",
|
||||
["policy-hash"] = "sha256:policy456",
|
||||
["output"] = "json"
|
||||
},
|
||||
Flags = new[] { "verify", "verbose", "strict" }
|
||||
};
|
||||
|
||||
// Assert
|
||||
parts.Binary.Should().Be("stellaops");
|
||||
parts.Subcommand.Should().Be("scan replay");
|
||||
parts.Arguments.Should().ContainKey("feed-snapshot");
|
||||
parts.Arguments.Should().ContainKey("policy-hash");
|
||||
parts.Flags.Should().Contain("verify");
|
||||
parts.Flags.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayCommandResponseDto_ContainsAllCommandVariants()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateFullReplayCommandResponse();
|
||||
|
||||
// Assert
|
||||
response.FullCommand.Should().NotBeNull();
|
||||
response.ShortCommand.Should().NotBeNull();
|
||||
response.OfflineCommand.Should().NotBeNull();
|
||||
response.FullCommand.Type.Should().Be("full");
|
||||
response.ShortCommand!.Type.Should().Be("short");
|
||||
response.OfflineCommand!.Type.Should().Be("offline");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanReplayCommandResponseDto_ContainsExpectedFields()
|
||||
{
|
||||
// Arrange
|
||||
var response = new ScanReplayCommandResponseDto
|
||||
{
|
||||
ScanId = "scan-123",
|
||||
FullCommand = new ReplayCommandDto
|
||||
{
|
||||
Type = "full",
|
||||
Command = "stellaops scan replay --target sha256:abc --verify",
|
||||
Shell = "bash",
|
||||
RequiresNetwork = true
|
||||
},
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
ExpectedFinalDigest = "sha256:final123"
|
||||
};
|
||||
|
||||
// Assert
|
||||
response.ScanId.Should().Be("scan-123");
|
||||
response.FullCommand.Command.Should().Contain("scan replay");
|
||||
response.ExpectedFinalDigest.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RCG-9200-026: Evidence Bundle Generation Tests
|
||||
|
||||
[Fact]
|
||||
public void EvidenceBundleInfoDto_ContainsRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new EvidenceBundleInfoDto
|
||||
{
|
||||
Id = "bundle-scan-123-finding-456",
|
||||
DownloadUri = "https://api.stellaops.local/bundles/bundle-scan-123-finding-456",
|
||||
SizeBytes = 1024 * 1024 * 5, // 5 MB
|
||||
ContentHash = "sha256:bundle789",
|
||||
Format = "tar.gz",
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||
Contents = new[]
|
||||
{
|
||||
"manifest.json",
|
||||
"feeds/",
|
||||
"sbom/",
|
||||
"policy/",
|
||||
"attestations/"
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
bundle.Id.Should().NotBeNullOrEmpty();
|
||||
bundle.DownloadUri.Should().Contain("/bundles/");
|
||||
bundle.ContentHash.Should().StartWith("sha256:");
|
||||
bundle.Format.Should().Be("tar.gz");
|
||||
bundle.Contents.Should().Contain("manifest.json");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("tar.gz")]
|
||||
[InlineData("zip")]
|
||||
public void EvidenceBundleInfoDto_SupportsBothFormats(string format)
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new EvidenceBundleInfoDto
|
||||
{
|
||||
Id = "bundle-001",
|
||||
DownloadUri = $"https://api.stellaops.local/bundles/bundle-001.{format}",
|
||||
ContentHash = "sha256:abc",
|
||||
Format = format
|
||||
};
|
||||
|
||||
// Assert
|
||||
bundle.Format.Should().Be(format);
|
||||
bundle.DownloadUri.Should().EndWith(format);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceBundleInfoDto_HasExpirationDate()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var bundle = new EvidenceBundleInfoDto
|
||||
{
|
||||
Id = "bundle-expiring",
|
||||
DownloadUri = "/bundles/bundle-expiring",
|
||||
ContentHash = "sha256:exp123",
|
||||
ExpiresAt = now.AddDays(7)
|
||||
};
|
||||
|
||||
// Assert
|
||||
bundle.ExpiresAt.Should().BeAfter(now);
|
||||
bundle.ExpiresAt.Should().BeBefore(now.AddDays(30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceBundleInfoDto_ContainsExpectedManifestItems()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new EvidenceBundleInfoDto
|
||||
{
|
||||
Id = "bundle-full",
|
||||
DownloadUri = "/bundles/bundle-full",
|
||||
ContentHash = "sha256:full123",
|
||||
Contents = new[]
|
||||
{
|
||||
"manifest.json",
|
||||
"feeds/nvd.json",
|
||||
"feeds/osv.json",
|
||||
"sbom/sbom.cyclonedx.json",
|
||||
"policy/policy.rego",
|
||||
"attestations/slsa.intoto.jsonl",
|
||||
"attestations/vuln.intoto.jsonl",
|
||||
"scripts/replay.sh",
|
||||
"scripts/replay.ps1",
|
||||
"README.md"
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
bundle.Contents.Should().Contain("manifest.json");
|
||||
bundle.Contents.Should().Contain(c => c.StartsWith("feeds/"));
|
||||
bundle.Contents.Should().Contain(c => c.StartsWith("sbom/"));
|
||||
bundle.Contents.Should().Contain(c => c.StartsWith("policy/"));
|
||||
bundle.Contents.Should().Contain(c => c.StartsWith("attestations/"));
|
||||
bundle.Contents.Should().Contain(c => c.StartsWith("scripts/"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RCG-9200-027/028: Integration Test Stubs (Unit Test Versions)
|
||||
|
||||
[Fact]
|
||||
public void GenerateReplayCommandRequestDto_HasRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var request = new GenerateReplayCommandRequestDto
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
Shells = new[] { "bash", "powershell" },
|
||||
IncludeOffline = true,
|
||||
GenerateBundle = true
|
||||
};
|
||||
|
||||
// Assert
|
||||
request.FindingId.Should().Be("finding-123");
|
||||
request.Shells.Should().Contain("bash");
|
||||
request.Shells.Should().Contain("powershell");
|
||||
request.IncludeOffline.Should().BeTrue();
|
||||
request.GenerateBundle.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateScanReplayCommandRequestDto_HasRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var request = new GenerateScanReplayCommandRequestDto
|
||||
{
|
||||
ScanId = "scan-456",
|
||||
Shells = new[] { "bash" },
|
||||
IncludeOffline = false,
|
||||
GenerateBundle = true
|
||||
};
|
||||
|
||||
// Assert
|
||||
request.ScanId.Should().Be("scan-456");
|
||||
request.IncludeOffline.Should().BeFalse();
|
||||
request.GenerateBundle.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayCommandResponseDto_FindingAndScanIds_ArePopulated()
|
||||
{
|
||||
// Arrange
|
||||
var response = new ReplayCommandResponseDto
|
||||
{
|
||||
FindingId = "finding-789",
|
||||
ScanId = "scan-456",
|
||||
FullCommand = new ReplayCommandDto
|
||||
{
|
||||
Type = "full",
|
||||
Command = "stellaops replay --target pkg:npm/test@1.0.0 --verify",
|
||||
Shell = "bash",
|
||||
RequiresNetwork = true
|
||||
},
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
ExpectedVerdictHash = "sha256:verdict123"
|
||||
};
|
||||
|
||||
// Assert
|
||||
response.FindingId.Should().Be("finding-789");
|
||||
response.ScanId.Should().Be("scan-456");
|
||||
response.ExpectedVerdictHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RCG-9200-029: Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void ExpectedVerdictHash_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var response1 = new ReplayCommandResponseDto
|
||||
{
|
||||
FindingId = "f1",
|
||||
ScanId = "s1",
|
||||
FullCommand = CreateBasicCommand(),
|
||||
GeneratedAt = DateTimeOffset.Parse("2024-12-24T12:00:00Z"),
|
||||
ExpectedVerdictHash = "sha256:abc123"
|
||||
};
|
||||
|
||||
var response2 = new ReplayCommandResponseDto
|
||||
{
|
||||
FindingId = "f1",
|
||||
ScanId = "s1",
|
||||
FullCommand = CreateBasicCommand(),
|
||||
GeneratedAt = DateTimeOffset.Parse("2024-12-24T12:00:00Z"),
|
||||
ExpectedVerdictHash = "sha256:abc123" // Same inputs = same hash
|
||||
};
|
||||
|
||||
// Assert
|
||||
response1.ExpectedVerdictHash.Should().Be(response2.ExpectedVerdictHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotInfoDto_EnablesDeterministicReplay()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new SnapshotInfoDto
|
||||
{
|
||||
Id = "snap-2024-12-24-001",
|
||||
CreatedAt = DateTimeOffset.Parse("2024-12-24T00:00:00Z"),
|
||||
FeedVersions = new Dictionary<string, string>
|
||||
{
|
||||
["nvd"] = "2024-12-23",
|
||||
["osv"] = "2024-12-23",
|
||||
["epss"] = "2024-12-23"
|
||||
},
|
||||
DownloadUri = "https://api.stellaops.local/snapshots/snap-2024-12-24-001",
|
||||
ContentHash = "sha256:snapshot123"
|
||||
};
|
||||
|
||||
// Assert
|
||||
snapshot.Id.Should().Contain("2024-12-24");
|
||||
snapshot.FeedVersions.Should().ContainKey("nvd");
|
||||
snapshot.FeedVersions.Should().ContainKey("osv");
|
||||
snapshot.ContentHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandParts_CanBeReassembledDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var parts = new ReplayCommandPartsDto
|
||||
{
|
||||
Binary = "stellaops",
|
||||
Subcommand = "replay",
|
||||
Target = "pkg:npm/lodash@4.17.21",
|
||||
Arguments = new Dictionary<string, string>
|
||||
{
|
||||
["cve"] = "CVE-2024-0001",
|
||||
["snapshot"] = "snap-123"
|
||||
},
|
||||
Flags = new[] { "verify" }
|
||||
};
|
||||
|
||||
// Act - Reassemble command from parts
|
||||
var reassembled = ReassembleCommand(parts);
|
||||
|
||||
// Assert
|
||||
reassembled.Should().Contain("stellaops replay");
|
||||
reassembled.Should().Contain("--target \"pkg:npm/lodash@4.17.21\"");
|
||||
reassembled.Should().Contain("--cve CVE-2024-0001");
|
||||
reassembled.Should().Contain("--snapshot snap-123");
|
||||
reassembled.Should().Contain("--verify");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pkg:npm/lodash@4.17.21", "CVE-2024-0001", "sha256:feed123", "sha256:policy456")]
|
||||
[InlineData("pkg:maven/org.example/lib@1.0.0", "CVE-2023-9999", "sha256:feedabc", "sha256:policydef")]
|
||||
public void FullCommand_IncludesAllDeterminismInputs(
|
||||
string target, string cve, string feedSnapshot, string policyHash)
|
||||
{
|
||||
// Arrange
|
||||
var dto = new ReplayCommandDto
|
||||
{
|
||||
Type = "full",
|
||||
Command = $"stellaops replay --target \"{target}\" --cve {cve} --feed-snapshot {feedSnapshot} --policy-hash {policyHash} --verify",
|
||||
Shell = "bash",
|
||||
RequiresNetwork = true,
|
||||
Parts = new ReplayCommandPartsDto
|
||||
{
|
||||
Binary = "stellaops",
|
||||
Subcommand = "replay",
|
||||
Target = target,
|
||||
Arguments = new Dictionary<string, string>
|
||||
{
|
||||
["cve"] = cve,
|
||||
["feed-snapshot"] = feedSnapshot,
|
||||
["policy-hash"] = policyHash
|
||||
},
|
||||
Flags = new[] { "verify" }
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.Command.Should().Contain(target);
|
||||
dto.Command.Should().Contain(cve);
|
||||
dto.Command.Should().Contain(feedSnapshot);
|
||||
dto.Command.Should().Contain(policyHash);
|
||||
dto.Parts!.Arguments.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OfflineBundle_ContainsSameInputsAsOnlineReplay()
|
||||
{
|
||||
// Arrange
|
||||
var onlineCommand = new ReplayCommandDto
|
||||
{
|
||||
Type = "full",
|
||||
Command = "stellaops replay --target pkg:npm/a@1 --cve CVE-2024-0001 --feed-snapshot sha256:feed --policy-hash sha256:policy --verify",
|
||||
Shell = "bash",
|
||||
RequiresNetwork = true
|
||||
};
|
||||
|
||||
var bundleContents = new[]
|
||||
{
|
||||
"manifest.json", // Contains all hashes
|
||||
"feeds/nvd.json", // Feed snapshot
|
||||
"feeds/osv.json",
|
||||
"sbom/sbom.json", // Target artifact
|
||||
"policy/policy.rego" // Policy hash
|
||||
};
|
||||
|
||||
// Assert - bundle should contain equivalent data for deterministic replay
|
||||
bundleContents.Should().Contain("manifest.json");
|
||||
bundleContents.Should().Contain(c => c.StartsWith("feeds/"));
|
||||
bundleContents.Should().Contain(c => c.StartsWith("policy/"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Serialization Tests
|
||||
|
||||
[Fact]
|
||||
public void ReplayCommandResponseDto_Serializes_Correctly()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateFullReplayCommandResponse();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true });
|
||||
var deserialized = JsonSerializer.Deserialize<ReplayCommandResponseDto>(json);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.FindingId.Should().Be(response.FindingId);
|
||||
deserialized.FullCommand.Should().NotBeNull();
|
||||
deserialized.Snapshot.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayCommandDto_HasExpectedJsonStructure()
|
||||
{
|
||||
// Arrange
|
||||
var dto = CreateBasicCommand();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("\"Type\"");
|
||||
json.Should().Contain("\"Command\"");
|
||||
json.Should().Contain("\"Shell\"");
|
||||
json.Should().Contain("\"RequiresNetwork\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotInfoDto_Serializes_WithFeedVersions()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new SnapshotInfoDto
|
||||
{
|
||||
Id = "snap-001",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
FeedVersions = new Dictionary<string, string>
|
||||
{
|
||||
["nvd"] = "2024-12-23",
|
||||
["osv"] = "2024-12-22"
|
||||
},
|
||||
ContentHash = "sha256:snap123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(snapshot);
|
||||
var deserialized = JsonSerializer.Deserialize<SnapshotInfoDto>(json);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.FeedVersions.Should().ContainKey("nvd");
|
||||
deserialized.FeedVersions!["nvd"].Should().Be("2024-12-23");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ReplayCommandDto CreateBasicCommand() => new()
|
||||
{
|
||||
Type = "full",
|
||||
Command = "stellaops replay --target pkg:npm/test@1.0.0 --verify",
|
||||
Shell = "bash",
|
||||
RequiresNetwork = true
|
||||
};
|
||||
|
||||
private static ReplayCommandResponseDto CreateFullReplayCommandResponse() => new()
|
||||
{
|
||||
FindingId = "finding-test-001",
|
||||
ScanId = "scan-test-001",
|
||||
FullCommand = new ReplayCommandDto
|
||||
{
|
||||
Type = "full",
|
||||
Command = "stellaops replay --target \"pkg:npm/test@1.0.0\" --cve CVE-2024-0001 --feed-snapshot sha256:abc --policy-hash sha256:def --verify",
|
||||
Shell = "bash",
|
||||
RequiresNetwork = true,
|
||||
Parts = new ReplayCommandPartsDto
|
||||
{
|
||||
Binary = "stellaops",
|
||||
Subcommand = "replay",
|
||||
Target = "pkg:npm/test@1.0.0",
|
||||
Arguments = new Dictionary<string, string>
|
||||
{
|
||||
["cve"] = "CVE-2024-0001"
|
||||
},
|
||||
Flags = new[] { "verify" }
|
||||
}
|
||||
},
|
||||
ShortCommand = new ReplayCommandDto
|
||||
{
|
||||
Type = "short",
|
||||
Command = "stellaops replay --target \"pkg:npm/test@1.0.0\" --snapshot snap-001 --verify",
|
||||
Shell = "bash",
|
||||
RequiresNetwork = true
|
||||
},
|
||||
OfflineCommand = new ReplayCommandDto
|
||||
{
|
||||
Type = "offline",
|
||||
Command = "stellaops replay --target \"pkg:npm/test@1.0.0\" --bundle ./bundle.tar.gz --offline --verify",
|
||||
Shell = "bash",
|
||||
RequiresNetwork = false
|
||||
},
|
||||
Snapshot = new SnapshotInfoDto
|
||||
{
|
||||
Id = "snap-001",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
FeedVersions = new Dictionary<string, string> { ["nvd"] = "latest" },
|
||||
ContentHash = "sha256:snap123"
|
||||
},
|
||||
Bundle = new EvidenceBundleInfoDto
|
||||
{
|
||||
Id = "bundle-001",
|
||||
DownloadUri = "/bundles/bundle-001",
|
||||
ContentHash = "sha256:bundle123",
|
||||
Format = "tar.gz"
|
||||
},
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
ExpectedVerdictHash = "sha256:verdict123"
|
||||
};
|
||||
|
||||
private static string ReassembleCommand(ReplayCommandPartsDto parts)
|
||||
{
|
||||
var args = string.Join(" ", parts.Arguments?.Select(kv => $"--{kv.Key} {kv.Value}") ?? Array.Empty<string>());
|
||||
var flags = string.Join(" ", parts.Flags?.Select(f => $"--{f}") ?? Array.Empty<string>());
|
||||
return $"{parts.Binary} {parts.Subcommand} --target \"{parts.Target}\" {args} {flags}".Trim();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// UnifiedEvidenceServiceTests.cs
|
||||
// Sprint: SPRINT_9200_0001_0002_SCANNER_unified_evidence_endpoint
|
||||
// Tasks: UEE-9200-030 through UEE-9200-035
|
||||
// Description: Unit tests for unified evidence DTOs, aggregation, and verification.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for unified evidence contracts and service behavior.
|
||||
/// Covers UEE-9200-030 (DTO serialization), UEE-9200-031 (evidence aggregation),
|
||||
/// UEE-9200-032 (verification status), UEE-9200-035 (JSON snapshot structure).
|
||||
/// </summary>
|
||||
public sealed class UnifiedEvidenceServiceTests
|
||||
{
|
||||
#region UEE-9200-030: DTO Serialization Tests
|
||||
|
||||
[Fact]
|
||||
public void UnifiedEvidenceResponseDto_Serializes_WithRequiredProperties()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new UnifiedEvidenceResponseDto
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
CveId = "CVE-2024-0001",
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.21",
|
||||
Manifests = CreateMinimalManifests(),
|
||||
Verification = CreateMinimalVerification(),
|
||||
GeneratedAt = DateTimeOffset.Parse("2024-12-24T12:00:00Z")
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("finding-123");
|
||||
json.Should().Contain("CVE-2024-0001");
|
||||
json.Should().Contain("pkg:npm/lodash@4.17.21");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomEvidenceDto_Serializes_WithAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new SbomEvidenceDto
|
||||
{
|
||||
Format = "cyclonedx",
|
||||
Version = "1.5",
|
||||
DocumentUri = "/sbom/doc-123",
|
||||
Digest = "sha256:abc123",
|
||||
Component = new SbomComponentDto
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Name = "lodash",
|
||||
Version = "4.17.21",
|
||||
Ecosystem = "npm",
|
||||
Licenses = new[] { "MIT" },
|
||||
Cpes = new[] { "cpe:2.3:a:lodash:lodash:4.17.21:*:*:*:*:node.js:*:*" }
|
||||
},
|
||||
Dependencies = new[] { "pkg:npm/deep-extend@0.6.0" },
|
||||
Dependents = new[] { "pkg:npm/my-app@1.0.0" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
var deserialized = JsonSerializer.Deserialize<SbomEvidenceDto>(json);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.Format.Should().Be("cyclonedx");
|
||||
deserialized.Component.Should().NotBeNull();
|
||||
deserialized.Component!.Name.Should().Be("lodash");
|
||||
deserialized.Licenses().Should().Contain("MIT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReachabilityEvidenceDto_Serializes_WithEntryPoints()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new ReachabilityEvidenceDto
|
||||
{
|
||||
SubgraphId = "subgraph-456",
|
||||
Status = "reachable",
|
||||
Confidence = 0.95,
|
||||
Method = "static",
|
||||
EntryPoints = new[]
|
||||
{
|
||||
new EntryPointDto
|
||||
{
|
||||
Id = "ep-1",
|
||||
Type = "http",
|
||||
Name = "POST /api/users",
|
||||
Location = "src/api/users.ts:42",
|
||||
Distance = 3
|
||||
}
|
||||
},
|
||||
CallChain = new CallChainSummaryDto
|
||||
{
|
||||
PathLength = 3,
|
||||
PathCount = 2,
|
||||
KeySymbols = new[] { "parseJSON", "merge", "vulnerable_call" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("subgraph-456");
|
||||
json.Should().Contain("reachable");
|
||||
json.Should().Contain("POST /api/users");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexClaimDto_Serializes_WithTrustScore()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new VexClaimDto
|
||||
{
|
||||
StatementId = "vex-stmt-789",
|
||||
Source = "redhat",
|
||||
Status = "not_affected",
|
||||
Justification = "component_not_present",
|
||||
ImpactStatement = "Component is not used in this build",
|
||||
IssuedAt = DateTimeOffset.Parse("2024-12-20T10:00:00Z"),
|
||||
TrustScore = 0.92,
|
||||
MeetsPolicyThreshold = true,
|
||||
DocumentUri = "/vex/rhsa-2024-0001.json"
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
var deserialized = JsonSerializer.Deserialize<VexClaimDto>(json);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.TrustScore.Should().BeApproximately(0.92, 0.01);
|
||||
deserialized.MeetsPolicyThreshold.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttestationSummaryDto_Serializes_WithTransparencyLog()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new AttestationSummaryDto
|
||||
{
|
||||
Id = "att-001",
|
||||
PredicateType = "https://slsa.dev/provenance/v1",
|
||||
SubjectDigest = "sha256:def456",
|
||||
Signer = "sigstore@example.com",
|
||||
SignedAt = DateTimeOffset.Parse("2024-12-23T15:00:00Z"),
|
||||
VerificationStatus = "verified",
|
||||
TransparencyLogEntry = "https://rekor.sigstore.dev/api/v1/log/entries/abc123",
|
||||
AttestationUri = "/attestations/att-001.intoto.jsonl"
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("https://slsa.dev/provenance/v1");
|
||||
json.Should().Contain("sigstore@example.com");
|
||||
json.Should().Contain("rekor.sigstore.dev");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeltaEvidenceDto_Serializes_WithSummary()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new DeltaEvidenceDto
|
||||
{
|
||||
DeltaId = "delta-101",
|
||||
PreviousScanId = "scan-099",
|
||||
CurrentScanId = "scan-100",
|
||||
ComparedAt = DateTimeOffset.UtcNow,
|
||||
Summary = new DeltaSummaryDto
|
||||
{
|
||||
AddedCount = 5,
|
||||
RemovedCount = 2,
|
||||
ChangedCount = 3,
|
||||
IsNew = true,
|
||||
StatusChanged = false
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
var deserialized = JsonSerializer.Deserialize<DeltaEvidenceDto>(json);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.Summary.Should().NotBeNull();
|
||||
deserialized.Summary!.AddedCount.Should().Be(5);
|
||||
deserialized.Summary.IsNew.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyEvidenceDto_Serializes_WithRulesFired()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new PolicyEvidenceDto
|
||||
{
|
||||
PolicyVersion = "2.1.0",
|
||||
PolicyDigest = "sha256:policy789",
|
||||
Verdict = "warn",
|
||||
RulesFired = new[]
|
||||
{
|
||||
new PolicyRuleFiredDto
|
||||
{
|
||||
RuleId = "critical-vuln",
|
||||
Name = "Block Critical Vulnerabilities",
|
||||
Effect = "deny",
|
||||
Reason = "CVSS >= 9.0"
|
||||
},
|
||||
new PolicyRuleFiredDto
|
||||
{
|
||||
RuleId = "warn-high-vuln",
|
||||
Name = "Warn High Vulnerabilities",
|
||||
Effect = "warn",
|
||||
Reason = "CVSS >= 7.0"
|
||||
}
|
||||
},
|
||||
Counterfactuals = new[] { "Lower CVSS to < 7.0", "Add VEX not_affected" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("Block Critical Vulnerabilities");
|
||||
// Note: JSON escapes > as \u003E, so we check for the rule ID instead
|
||||
json.Should().Contain("critical-vuln");
|
||||
json.Should().Contain("Counterfactuals");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManifestHashesDto_Serializes_RequiredHashes()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new ManifestHashesDto
|
||||
{
|
||||
ArtifactDigest = "sha256:artifact123",
|
||||
ManifestHash = "sha256:manifest456",
|
||||
FeedSnapshotHash = "sha256:feed789",
|
||||
PolicyHash = "sha256:policy012",
|
||||
KnowledgeSnapshotId = "snapshot-2024-12-24",
|
||||
GraphRevisionId = "graph-rev-100"
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
var deserialized = JsonSerializer.Deserialize<ManifestHashesDto>(json);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.ArtifactDigest.Should().StartWith("sha256:");
|
||||
deserialized.ManifestHash.Should().StartWith("sha256:");
|
||||
deserialized.FeedSnapshotHash.Should().StartWith("sha256:");
|
||||
deserialized.PolicyHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UEE-9200-031: Evidence Aggregation Tests
|
||||
|
||||
[Fact]
|
||||
public void UnifiedEvidenceResponseDto_CanHaveAllTabsPopulated()
|
||||
{
|
||||
// Arrange & Act
|
||||
var dto = CreateFullyPopulatedEvidence();
|
||||
|
||||
// Assert
|
||||
dto.Sbom.Should().NotBeNull();
|
||||
dto.Reachability.Should().NotBeNull();
|
||||
dto.VexClaims.Should().NotBeNullOrEmpty();
|
||||
dto.Attestations.Should().NotBeNullOrEmpty();
|
||||
dto.Deltas.Should().NotBeNull();
|
||||
dto.Policy.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnifiedEvidenceResponseDto_HandlesNullTabs_Gracefully()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new UnifiedEvidenceResponseDto
|
||||
{
|
||||
FindingId = "finding-minimal",
|
||||
CveId = "CVE-2024-0001",
|
||||
ComponentPurl = "pkg:npm/test@1.0.0",
|
||||
Manifests = CreateMinimalManifests(),
|
||||
Verification = CreateMinimalVerification(),
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
// All tabs null
|
||||
Sbom = null,
|
||||
Reachability = null,
|
||||
VexClaims = null,
|
||||
Attestations = null,
|
||||
Deltas = null,
|
||||
Policy = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
var deserialized = JsonSerializer.Deserialize<UnifiedEvidenceResponseDto>(json);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.FindingId.Should().Be("finding-minimal");
|
||||
deserialized.Sbom.Should().BeNull();
|
||||
deserialized.VexClaims.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexClaims_CanContainMultipleSources()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new UnifiedEvidenceResponseDto
|
||||
{
|
||||
FindingId = "finding-multi-vex",
|
||||
CveId = "CVE-2024-0001",
|
||||
ComponentPurl = "pkg:npm/test@1.0.0",
|
||||
VexClaims = new[]
|
||||
{
|
||||
new VexClaimDto
|
||||
{
|
||||
StatementId = "vex-1",
|
||||
Source = "nvd",
|
||||
Status = "affected",
|
||||
TrustScore = 1.0,
|
||||
MeetsPolicyThreshold = true,
|
||||
IssuedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new VexClaimDto
|
||||
{
|
||||
StatementId = "vex-2",
|
||||
Source = "redhat",
|
||||
Status = "not_affected",
|
||||
TrustScore = 0.95,
|
||||
MeetsPolicyThreshold = true,
|
||||
IssuedAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
},
|
||||
new VexClaimDto
|
||||
{
|
||||
StatementId = "vex-3",
|
||||
Source = "vendor",
|
||||
Status = "under_investigation",
|
||||
TrustScore = 0.6,
|
||||
MeetsPolicyThreshold = false,
|
||||
IssuedAt = DateTimeOffset.UtcNow.AddDays(-7)
|
||||
}
|
||||
},
|
||||
Manifests = CreateMinimalManifests(),
|
||||
Verification = CreateMinimalVerification(),
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.VexClaims.Should().HaveCount(3);
|
||||
dto.VexClaims!.Should().Contain(v => v.Source == "nvd" && v.TrustScore == 1.0);
|
||||
dto.VexClaims!.Count(v => v.MeetsPolicyThreshold).Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attestations_CanContainMultiplePredicateTypes()
|
||||
{
|
||||
// Arrange
|
||||
var attestations = new[]
|
||||
{
|
||||
new AttestationSummaryDto
|
||||
{
|
||||
Id = "att-slsa",
|
||||
PredicateType = "https://slsa.dev/provenance/v1",
|
||||
SubjectDigest = "sha256:abc",
|
||||
VerificationStatus = "verified"
|
||||
},
|
||||
new AttestationSummaryDto
|
||||
{
|
||||
Id = "att-vuln",
|
||||
PredicateType = "https://in-toto.io/attestation/vulns/v1",
|
||||
SubjectDigest = "sha256:abc",
|
||||
VerificationStatus = "verified"
|
||||
},
|
||||
new AttestationSummaryDto
|
||||
{
|
||||
Id = "att-sbom",
|
||||
PredicateType = "https://spdx.dev/Document",
|
||||
SubjectDigest = "sha256:abc",
|
||||
VerificationStatus = "unverified"
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
attestations.Should().HaveCount(3);
|
||||
attestations.Select(a => a.PredicateType).Should().OnlyHaveUniqueItems();
|
||||
attestations.Count(a => a.VerificationStatus == "verified").Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayCommand_IsIncludedInEvidence()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new UnifiedEvidenceResponseDto
|
||||
{
|
||||
FindingId = "finding-with-replay",
|
||||
CveId = "CVE-2024-0001",
|
||||
ComponentPurl = "pkg:npm/test@1.0.0",
|
||||
ReplayCommand = "stellaops replay --target pkg:npm/test@1.0.0 --cve CVE-2024-0001 --verify",
|
||||
ShortReplayCommand = "stellaops replay --snapshot snap-123 --verify",
|
||||
EvidenceBundleUrl = "https://api.stellaops.local/bundles/bundle-123",
|
||||
Manifests = CreateMinimalManifests(),
|
||||
Verification = CreateMinimalVerification(),
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.ReplayCommand.Should().Contain("stellaops replay");
|
||||
dto.ReplayCommand.Should().Contain("--cve CVE-2024-0001");
|
||||
dto.ShortReplayCommand.Should().Contain("--snapshot");
|
||||
dto.EvidenceBundleUrl.Should().Contain("/bundles/");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UEE-9200-032: Verification Status Tests
|
||||
|
||||
[Fact]
|
||||
public void VerificationStatusDto_Verified_WhenAllChecksPass()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new VerificationStatusDto
|
||||
{
|
||||
Status = "verified",
|
||||
HashesVerified = true,
|
||||
AttestationsVerified = true,
|
||||
EvidenceComplete = true,
|
||||
Issues = null,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.Status.Should().Be("verified");
|
||||
dto.HashesVerified.Should().BeTrue();
|
||||
dto.AttestationsVerified.Should().BeTrue();
|
||||
dto.EvidenceComplete.Should().BeTrue();
|
||||
dto.Issues.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerificationStatusDto_Partial_WhenSomeChecksPass()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new VerificationStatusDto
|
||||
{
|
||||
Status = "partial",
|
||||
HashesVerified = true,
|
||||
AttestationsVerified = false,
|
||||
EvidenceComplete = true,
|
||||
Issues = new[] { "Attestation signature verification failed" },
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.Status.Should().Be("partial");
|
||||
dto.AttestationsVerified.Should().BeFalse();
|
||||
dto.Issues.Should().ContainSingle();
|
||||
dto.Issues![0].Should().Contain("Attestation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerificationStatusDto_Failed_WhenCriticalChecksFail()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new VerificationStatusDto
|
||||
{
|
||||
Status = "failed",
|
||||
HashesVerified = false,
|
||||
AttestationsVerified = false,
|
||||
EvidenceComplete = false,
|
||||
Issues = new[]
|
||||
{
|
||||
"Manifest hash mismatch",
|
||||
"Attestation not found",
|
||||
"VEX evidence missing"
|
||||
},
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.Status.Should().Be("failed");
|
||||
dto.HashesVerified.Should().BeFalse();
|
||||
dto.Issues.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerificationStatusDto_Unknown_WhenNoVerificationRun()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new VerificationStatusDto
|
||||
{
|
||||
Status = "unknown",
|
||||
HashesVerified = false,
|
||||
AttestationsVerified = false,
|
||||
EvidenceComplete = false,
|
||||
Issues = new[] { "No verification has been performed" },
|
||||
VerifiedAt = null
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.Status.Should().Be("unknown");
|
||||
dto.VerifiedAt.Should().BeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true, true, "verified")]
|
||||
[InlineData(true, false, true, "partial")]
|
||||
[InlineData(false, true, true, "partial")]
|
||||
[InlineData(true, true, false, "partial")]
|
||||
[InlineData(false, false, false, "failed")]
|
||||
public void VerificationStatusDto_DeterminesCorrectStatus(
|
||||
bool hashesVerified, bool attestationsVerified, bool evidenceComplete, string expectedStatus)
|
||||
{
|
||||
// Arrange
|
||||
var actualStatus = DetermineVerificationStatus(hashesVerified, attestationsVerified, evidenceComplete);
|
||||
|
||||
// Assert
|
||||
actualStatus.Should().Be(expectedStatus);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UEE-9200-035: JSON Snapshot Structure Tests
|
||||
|
||||
[Fact]
|
||||
public void UnifiedEvidenceResponseDto_HasExpectedJsonStructure()
|
||||
{
|
||||
// Arrange
|
||||
var dto = CreateFullyPopulatedEvidence();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
// Assert - verify top-level structure
|
||||
json.Should().Contain("\"FindingId\"");
|
||||
json.Should().Contain("\"CveId\"");
|
||||
json.Should().Contain("\"ComponentPurl\"");
|
||||
json.Should().Contain("\"Sbom\"");
|
||||
json.Should().Contain("\"Reachability\"");
|
||||
json.Should().Contain("\"VexClaims\"");
|
||||
json.Should().Contain("\"Attestations\"");
|
||||
json.Should().Contain("\"Deltas\"");
|
||||
json.Should().Contain("\"Policy\"");
|
||||
json.Should().Contain("\"Manifests\"");
|
||||
json.Should().Contain("\"Verification\"");
|
||||
json.Should().Contain("\"GeneratedAt\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomComponentDto_HasExpectedJsonStructure()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new SbomComponentDto
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Name = "lodash",
|
||||
Version = "4.17.21",
|
||||
Ecosystem = "npm",
|
||||
Licenses = new[] { "MIT" },
|
||||
Cpes = new[] { "cpe:2.3:a:lodash:*" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("\"Purl\"");
|
||||
json.Should().Contain("\"Name\"");
|
||||
json.Should().Contain("\"Version\"");
|
||||
json.Should().Contain("\"Ecosystem\"");
|
||||
json.Should().Contain("\"Licenses\"");
|
||||
json.Should().Contain("\"Cpes\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallChainSummaryDto_HasExpectedJsonStructure()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new CallChainSummaryDto
|
||||
{
|
||||
PathLength = 5,
|
||||
PathCount = 3,
|
||||
KeySymbols = new[] { "entrypoint", "middleware", "vulnerable_fn" },
|
||||
CallGraphUri = "/graphs/cg-123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("\"PathLength\":5");
|
||||
json.Should().Contain("\"PathCount\":3");
|
||||
json.Should().Contain("\"KeySymbols\"");
|
||||
json.Should().Contain("\"CallGraphUri\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexClaimDto_HasExpectedJsonStructure()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new VexClaimDto
|
||||
{
|
||||
StatementId = "stmt-1",
|
||||
Source = "nvd",
|
||||
Status = "affected",
|
||||
Justification = "vulnerable_code_cannot_be_controlled_by_adversary",
|
||||
ImpactStatement = "Not exploitable in this configuration",
|
||||
IssuedAt = DateTimeOffset.Parse("2024-12-24T00:00:00Z"),
|
||||
TrustScore = 0.85,
|
||||
MeetsPolicyThreshold = true,
|
||||
DocumentUri = "/vex/stmt-1.json"
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("\"StatementId\"");
|
||||
json.Should().Contain("\"TrustScore\"");
|
||||
json.Should().Contain("\"MeetsPolicyThreshold\"");
|
||||
json.Should().Contain("\"ImpactStatement\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManifestHashesDto_AllHashesAreSha256Prefixed()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new ManifestHashesDto
|
||||
{
|
||||
ArtifactDigest = "sha256:abcd1234",
|
||||
ManifestHash = "sha256:efgh5678",
|
||||
FeedSnapshotHash = "sha256:ijkl9012",
|
||||
PolicyHash = "sha256:mnop3456"
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.ArtifactDigest.Should().StartWith("sha256:");
|
||||
dto.ManifestHash.Should().StartWith("sha256:");
|
||||
dto.FeedSnapshotHash.Should().StartWith("sha256:");
|
||||
dto.PolicyHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnifiedEvidenceResponseDto_RoundTrips_WithJsonSerialization()
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateFullyPopulatedEvidence();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
var deserialized = JsonSerializer.Deserialize<UnifiedEvidenceResponseDto>(json);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.FindingId.Should().Be(original.FindingId);
|
||||
deserialized.CveId.Should().Be(original.CveId);
|
||||
deserialized.ComponentPurl.Should().Be(original.ComponentPurl);
|
||||
deserialized.Sbom.Should().NotBeNull();
|
||||
deserialized.Reachability.Should().NotBeNull();
|
||||
deserialized.VexClaims.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UEE-9200-033/034: Integration Test Stubs (Unit Test Versions)
|
||||
|
||||
[Fact]
|
||||
public void CacheKey_IsContentAddressed()
|
||||
{
|
||||
// Arrange
|
||||
var dto1 = new UnifiedEvidenceResponseDto
|
||||
{
|
||||
FindingId = "f1",
|
||||
CveId = "CVE-2024-0001",
|
||||
ComponentPurl = "pkg:npm/a@1",
|
||||
Manifests = CreateMinimalManifests(),
|
||||
Verification = CreateMinimalVerification(),
|
||||
GeneratedAt = DateTimeOffset.Parse("2024-12-24T12:00:00Z"),
|
||||
CacheKey = "sha256:abc123"
|
||||
};
|
||||
|
||||
var dto2 = new UnifiedEvidenceResponseDto
|
||||
{
|
||||
FindingId = "f1",
|
||||
CveId = "CVE-2024-0001",
|
||||
ComponentPurl = "pkg:npm/a@1",
|
||||
Manifests = CreateMinimalManifests(),
|
||||
Verification = CreateMinimalVerification(),
|
||||
GeneratedAt = DateTimeOffset.Parse("2024-12-24T12:00:00Z"),
|
||||
CacheKey = "sha256:abc123" // Same content = same cache key
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto1.CacheKey.Should().Be(dto2.CacheKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceBundleUrl_FollowsExpectedPattern()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new UnifiedEvidenceResponseDto
|
||||
{
|
||||
FindingId = "finding-001",
|
||||
CveId = "CVE-2024-0001",
|
||||
ComponentPurl = "pkg:npm/test@1.0.0",
|
||||
EvidenceBundleUrl = "https://api.stellaops.local/bundles/scan-001-finding-001",
|
||||
Manifests = CreateMinimalManifests(),
|
||||
Verification = CreateMinimalVerification(),
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Assert
|
||||
dto.EvidenceBundleUrl.Should().Contain("/bundles/");
|
||||
dto.EvidenceBundleUrl.Should().Contain("finding-001");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ManifestHashesDto CreateMinimalManifests() => new()
|
||||
{
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
ManifestHash = "sha256:def456",
|
||||
FeedSnapshotHash = "sha256:ghi789",
|
||||
PolicyHash = "sha256:jkl012"
|
||||
};
|
||||
|
||||
private static VerificationStatusDto CreateMinimalVerification() => new()
|
||||
{
|
||||
Status = "verified",
|
||||
HashesVerified = true,
|
||||
AttestationsVerified = true,
|
||||
EvidenceComplete = true
|
||||
};
|
||||
|
||||
private static UnifiedEvidenceResponseDto CreateFullyPopulatedEvidence() => new()
|
||||
{
|
||||
FindingId = "finding-full-001",
|
||||
CveId = "CVE-2024-0001",
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.21",
|
||||
Sbom = new SbomEvidenceDto
|
||||
{
|
||||
Format = "cyclonedx",
|
||||
Version = "1.5",
|
||||
DocumentUri = "/sbom/doc-001",
|
||||
Digest = "sha256:sbom123",
|
||||
Component = new SbomComponentDto
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Name = "lodash",
|
||||
Version = "4.17.21"
|
||||
}
|
||||
},
|
||||
Reachability = new ReachabilityEvidenceDto
|
||||
{
|
||||
SubgraphId = "sg-001",
|
||||
Status = "reachable",
|
||||
Confidence = 0.92,
|
||||
Method = "static"
|
||||
},
|
||||
VexClaims = new[]
|
||||
{
|
||||
new VexClaimDto
|
||||
{
|
||||
StatementId = "vex-001",
|
||||
Source = "redhat",
|
||||
Status = "not_affected",
|
||||
TrustScore = 0.95,
|
||||
MeetsPolicyThreshold = true,
|
||||
IssuedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
},
|
||||
Attestations = new[]
|
||||
{
|
||||
new AttestationSummaryDto
|
||||
{
|
||||
Id = "att-001",
|
||||
PredicateType = "https://slsa.dev/provenance/v1",
|
||||
SubjectDigest = "sha256:subject123",
|
||||
VerificationStatus = "verified"
|
||||
}
|
||||
},
|
||||
Deltas = new DeltaEvidenceDto
|
||||
{
|
||||
DeltaId = "delta-001",
|
||||
PreviousScanId = "scan-099",
|
||||
CurrentScanId = "scan-100",
|
||||
ComparedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
Policy = new PolicyEvidenceDto
|
||||
{
|
||||
PolicyVersion = "1.0",
|
||||
PolicyDigest = "sha256:policy123",
|
||||
Verdict = "allow"
|
||||
},
|
||||
Manifests = CreateMinimalManifests(),
|
||||
Verification = CreateMinimalVerification(),
|
||||
ReplayCommand = "stellaops replay --target pkg:npm/lodash@4.17.21 --verify",
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private static string DetermineVerificationStatus(
|
||||
bool hashesVerified, bool attestationsVerified, bool evidenceComplete)
|
||||
{
|
||||
if (hashesVerified && attestationsVerified && evidenceComplete)
|
||||
return "verified";
|
||||
if (hashesVerified || attestationsVerified || evidenceComplete)
|
||||
return "partial";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for test assertions on DTOs.
|
||||
/// </summary>
|
||||
internal static class SbomEvidenceDtoExtensions
|
||||
{
|
||||
public static IReadOnlyList<string> Licenses(this SbomEvidenceDto dto) =>
|
||||
dto.Component?.Licenses ?? Array.Empty<string>();
|
||||
}
|
||||
Reference in New Issue
Block a user