Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
namespace StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence graph for a finding showing all contributing evidence.
|
||||
/// </summary>
|
||||
public sealed record EvidenceGraphResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding this graph is for.
|
||||
/// </summary>
|
||||
public required Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID.
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All evidence nodes.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<EvidenceNode> Nodes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Edges representing derivation relationships.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<EvidenceEdge> Edges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root node (verdict).
|
||||
/// </summary>
|
||||
public required string RootNodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Graph generation timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A node in the evidence graph.
|
||||
/// </summary>
|
||||
public sealed record EvidenceNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Node identifier (content-addressed).
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Node type.
|
||||
/// </summary>
|
||||
public required EvidenceNodeType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable label.
|
||||
/// </summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content digest (sha256:...).
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer of this evidence.
|
||||
/// </summary>
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature status.
|
||||
/// </summary>
|
||||
public required SignatureStatus Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; }
|
||||
= new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// URL to fetch raw content.
|
||||
/// </summary>
|
||||
public string? ContentUrl { get; init; }
|
||||
}
|
||||
|
||||
public enum EvidenceNodeType
|
||||
{
|
||||
/// <summary>Final verdict.</summary>
|
||||
Verdict,
|
||||
|
||||
/// <summary>Policy evaluation trace.</summary>
|
||||
PolicyTrace,
|
||||
|
||||
/// <summary>VEX statement.</summary>
|
||||
VexStatement,
|
||||
|
||||
/// <summary>Reachability analysis.</summary>
|
||||
Reachability,
|
||||
|
||||
/// <summary>Runtime observation.</summary>
|
||||
RuntimeObservation,
|
||||
|
||||
/// <summary>SBOM component.</summary>
|
||||
SbomComponent,
|
||||
|
||||
/// <summary>Advisory source.</summary>
|
||||
Advisory,
|
||||
|
||||
/// <summary>Build provenance.</summary>
|
||||
Provenance,
|
||||
|
||||
/// <summary>Attestation envelope.</summary>
|
||||
Attestation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification status.
|
||||
/// </summary>
|
||||
public sealed record SignatureStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether signed.
|
||||
/// </summary>
|
||||
public required bool IsSigned { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether signature is valid.
|
||||
/// </summary>
|
||||
public bool? IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signer identity (if known).
|
||||
/// </summary>
|
||||
public string? SignerIdentity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index (if published).
|
||||
/// </summary>
|
||||
public long? RekorLogIndex { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edge representing derivation relationship.
|
||||
/// </summary>
|
||||
public sealed record EvidenceEdge
|
||||
{
|
||||
/// <summary>
|
||||
/// Source node ID.
|
||||
/// </summary>
|
||||
public required string From { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target node ID.
|
||||
/// </summary>
|
||||
public required string To { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Relationship type.
|
||||
/// </summary>
|
||||
public required EvidenceRelation Relation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable label.
|
||||
/// </summary>
|
||||
public string? Label { get; init; }
|
||||
}
|
||||
|
||||
public enum EvidenceRelation
|
||||
{
|
||||
/// <summary>Derived from (input to output).</summary>
|
||||
DerivedFrom,
|
||||
|
||||
/// <summary>Verified by (attestation verifies content).</summary>
|
||||
VerifiedBy,
|
||||
|
||||
/// <summary>Supersedes (newer replaces older).</summary>
|
||||
Supersedes,
|
||||
|
||||
/// <summary>References (general reference).</summary>
|
||||
References,
|
||||
|
||||
/// <summary>Corroborates (supports claim).</summary>
|
||||
Corroborates
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
namespace StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Condensed finding summary for vulnerability-first UX.
|
||||
/// </summary>
|
||||
public sealed record FindingSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding ID.
|
||||
/// </summary>
|
||||
public required Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability title.
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected component (PURL).
|
||||
/// </summary>
|
||||
public required string Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verdict status.
|
||||
/// </summary>
|
||||
public required VerdictStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verdict chip for quick visual reference.
|
||||
/// </summary>
|
||||
public required VerdictChip Chip { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unified confidence score (0-1).
|
||||
/// </summary>
|
||||
public required decimal Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// One-liner summary explaining the verdict.
|
||||
/// </summary>
|
||||
public required string OneLiner { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Proof badges indicating evidence quality.
|
||||
/// </summary>
|
||||
public required ProofBadges Badges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVSS score (if available).
|
||||
/// </summary>
|
||||
public decimal? CvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity level.
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// First seen timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset FirstSeen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last updated timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset LastUpdated { get; init; }
|
||||
}
|
||||
|
||||
public enum VerdictStatus
|
||||
{
|
||||
Affected,
|
||||
NotAffected,
|
||||
UnderReview,
|
||||
Mitigated
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visual verdict chip with color coding.
|
||||
/// </summary>
|
||||
public sealed record VerdictChip
|
||||
{
|
||||
/// <summary>
|
||||
/// Display text.
|
||||
/// </summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Color scheme.
|
||||
/// </summary>
|
||||
public required ChipColor Color { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Icon name (optional).
|
||||
/// </summary>
|
||||
public string? Icon { get; init; }
|
||||
}
|
||||
|
||||
public enum ChipColor
|
||||
{
|
||||
Red, // Affected
|
||||
Green, // NotAffected
|
||||
Yellow, // UnderReview
|
||||
Blue // Mitigated
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proof badges indicating evidence quality.
|
||||
/// </summary>
|
||||
public sealed record ProofBadges
|
||||
{
|
||||
/// <summary>
|
||||
/// Reachability analysis badge.
|
||||
/// </summary>
|
||||
public required BadgeStatus Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime corroboration badge.
|
||||
/// </summary>
|
||||
public required BadgeStatus Runtime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation badge.
|
||||
/// </summary>
|
||||
public required BadgeStatus Policy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Provenance/attestation badge.
|
||||
/// </summary>
|
||||
public required BadgeStatus Provenance { get; init; }
|
||||
}
|
||||
|
||||
public enum BadgeStatus
|
||||
{
|
||||
/// <summary>Evidence not available.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Evidence available but inconclusive.</summary>
|
||||
Partial,
|
||||
|
||||
/// <summary>Strong evidence available.</summary>
|
||||
Strong,
|
||||
|
||||
/// <summary>Cryptographically verified evidence.</summary>
|
||||
Verified
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated response for finding summaries.
|
||||
/// </summary>
|
||||
public sealed record FindingSummaryPage
|
||||
{
|
||||
public required IReadOnlyList<FindingSummary> Items { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
public required int Page { get; init; }
|
||||
public required int PageSize { get; init; }
|
||||
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter for finding summaries query.
|
||||
/// </summary>
|
||||
public sealed record FindingSummaryFilter
|
||||
{
|
||||
public int Page { get; init; } = 1;
|
||||
public int PageSize { get; init; } = 50;
|
||||
public string? Status { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public decimal? MinConfidence { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||
|
||||
public static class EvidenceGraphEndpoints
|
||||
{
|
||||
public static void MapEvidenceGraphEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/findings")
|
||||
.WithTags("Evidence Graph")
|
||||
.RequireAuthorization();
|
||||
|
||||
// GET /api/v1/findings/{findingId}/evidence-graph
|
||||
group.MapGet("/{findingId:guid}/evidence-graph", async Task<Results<Ok<EvidenceGraphResponse>, NotFound>> (
|
||||
Guid findingId,
|
||||
[FromQuery] bool includeContent = false,
|
||||
IEvidenceGraphBuilder builder,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var graph = await builder.BuildAsync(findingId, ct);
|
||||
return graph is not null
|
||||
? TypedResults.Ok(graph)
|
||||
: TypedResults.NotFound();
|
||||
})
|
||||
.WithName("GetEvidenceGraph")
|
||||
.WithDescription("Get evidence graph for finding visualization")
|
||||
.Produces<EvidenceGraphResponse>(200)
|
||||
.Produces(404);
|
||||
|
||||
// GET /api/v1/findings/{findingId}/evidence/{nodeId}
|
||||
group.MapGet("/{findingId:guid}/evidence/{nodeId}", async Task<Results<Ok<object>, NotFound>> (
|
||||
Guid findingId,
|
||||
string nodeId,
|
||||
IEvidenceContentService contentService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var content = await contentService.GetContentAsync(findingId, nodeId, ct);
|
||||
return content is not null
|
||||
? TypedResults.Ok(content)
|
||||
: TypedResults.NotFound();
|
||||
})
|
||||
.WithName("GetEvidenceNodeContent")
|
||||
.WithDescription("Get raw content for an evidence node")
|
||||
.Produces<object>(200)
|
||||
.Produces(404);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for retrieving evidence node content.
|
||||
/// </summary>
|
||||
public interface IEvidenceContentService
|
||||
{
|
||||
Task<object?> GetContentAsync(Guid findingId, string nodeId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||
|
||||
public static class FindingSummaryEndpoints
|
||||
{
|
||||
public static void MapFindingSummaryEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/findings")
|
||||
.WithTags("Findings")
|
||||
.RequireAuthorization();
|
||||
|
||||
// GET /api/v1/findings/{findingId}/summary
|
||||
group.MapGet("/{findingId:guid}/summary", async Task<Results<Ok<FindingSummary>, NotFound>> (
|
||||
Guid findingId,
|
||||
IFindingSummaryService service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var summary = await service.GetSummaryAsync(findingId, ct);
|
||||
return summary is not null
|
||||
? TypedResults.Ok(summary)
|
||||
: TypedResults.NotFound();
|
||||
})
|
||||
.WithName("GetFindingSummary")
|
||||
.WithDescription("Get condensed finding summary for vulnerability-first UX")
|
||||
.Produces<FindingSummary>(200)
|
||||
.Produces(404);
|
||||
|
||||
// GET /api/v1/findings/summaries
|
||||
group.MapGet("/summaries", async Task<Ok<FindingSummaryPage>> (
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] string? severity = null,
|
||||
[FromQuery] decimal? minConfidence = null,
|
||||
IFindingSummaryService service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var filter = new FindingSummaryFilter
|
||||
{
|
||||
Page = page,
|
||||
PageSize = Math.Clamp(pageSize, 1, 100),
|
||||
Status = status,
|
||||
Severity = severity,
|
||||
MinConfidence = minConfidence
|
||||
};
|
||||
|
||||
var result = await service.GetSummariesAsync(filter, ct);
|
||||
return TypedResults.Ok(result);
|
||||
})
|
||||
.WithName("GetFindingSummaries")
|
||||
.WithDescription("Get paginated list of finding summaries")
|
||||
.Produces<FindingSummaryPage>(200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Scanner.Reachability.MiniMap;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||
|
||||
public static class ReachabilityMapEndpoints
|
||||
{
|
||||
public static void MapReachabilityMapEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/findings")
|
||||
.WithTags("Reachability")
|
||||
.RequireAuthorization();
|
||||
|
||||
// GET /api/v1/findings/{findingId}/reachability-map
|
||||
group.MapGet("/{findingId:guid}/reachability-map", async Task<Results<Ok<ReachabilityMiniMap>, NotFound>> (
|
||||
Guid findingId,
|
||||
[FromQuery] int maxPaths = 10,
|
||||
IReachabilityMapService service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var map = await service.GetMiniMapAsync(findingId, maxPaths, ct);
|
||||
return map is not null
|
||||
? TypedResults.Ok(map)
|
||||
: TypedResults.NotFound();
|
||||
})
|
||||
.WithName("GetReachabilityMiniMap")
|
||||
.WithDescription("Get condensed reachability visualization")
|
||||
.Produces<ReachabilityMiniMap>(200)
|
||||
.Produces(404);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for retrieving reachability mini-maps for findings.
|
||||
/// </summary>
|
||||
public interface IReachabilityMapService
|
||||
{
|
||||
Task<ReachabilityMiniMap?> GetMiniMapAsync(Guid findingId, int maxPaths, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Scanner.Analyzers.Native.RuntimeCapture.Timeline;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||
|
||||
public static class RuntimeTimelineEndpoints
|
||||
{
|
||||
public static void MapRuntimeTimelineEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/findings")
|
||||
.WithTags("Runtime")
|
||||
.RequireAuthorization();
|
||||
|
||||
// GET /api/v1/findings/{findingId}/runtime-timeline
|
||||
group.MapGet("/{findingId:guid}/runtime-timeline", async Task<Results<Ok<RuntimeTimeline>, NotFound>> (
|
||||
Guid findingId,
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to,
|
||||
[FromQuery] int bucketHours = 1,
|
||||
IRuntimeTimelineService service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var options = new TimelineOptions
|
||||
{
|
||||
WindowStart = from,
|
||||
WindowEnd = to,
|
||||
BucketSize = TimeSpan.FromHours(Math.Clamp(bucketHours, 1, 24))
|
||||
};
|
||||
|
||||
var timeline = await service.GetTimelineAsync(findingId, options, ct);
|
||||
return timeline is not null
|
||||
? TypedResults.Ok(timeline)
|
||||
: TypedResults.NotFound();
|
||||
})
|
||||
.WithName("GetRuntimeTimeline")
|
||||
.WithDescription("Get runtime corroboration timeline")
|
||||
.Produces<RuntimeTimeline>(200)
|
||||
.Produces(404);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for retrieving runtime timelines for findings.
|
||||
/// </summary>
|
||||
public interface IRuntimeTimelineService
|
||||
{
|
||||
Task<RuntimeTimeline?> GetTimelineAsync(Guid findingId, TimelineOptions options, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
public interface IEvidenceGraphBuilder
|
||||
{
|
||||
Task<EvidenceGraphResponse?> BuildAsync(Guid findingId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class EvidenceGraphBuilder : IEvidenceGraphBuilder
|
||||
{
|
||||
private readonly IEvidenceRepository _evidenceRepo;
|
||||
private readonly IAttestationVerifier _attestationVerifier;
|
||||
|
||||
public EvidenceGraphBuilder(
|
||||
IEvidenceRepository evidenceRepo,
|
||||
IAttestationVerifier attestationVerifier)
|
||||
{
|
||||
_evidenceRepo = evidenceRepo;
|
||||
_attestationVerifier = attestationVerifier;
|
||||
}
|
||||
|
||||
public async Task<EvidenceGraphResponse?> BuildAsync(
|
||||
Guid findingId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var evidence = await _evidenceRepo.GetFullEvidenceAsync(findingId, ct);
|
||||
if (evidence is null)
|
||||
return null;
|
||||
|
||||
var nodes = new List<EvidenceNode>();
|
||||
var edges = new List<EvidenceEdge>();
|
||||
|
||||
// Build verdict node (root)
|
||||
var verdictNode = BuildVerdictNode(evidence.Verdict);
|
||||
nodes.Add(verdictNode);
|
||||
|
||||
// Build policy trace node
|
||||
if (evidence.PolicyTrace is not null)
|
||||
{
|
||||
var policyNode = await BuildPolicyNodeAsync(evidence.PolicyTrace, ct);
|
||||
nodes.Add(policyNode);
|
||||
edges.Add(new EvidenceEdge
|
||||
{
|
||||
From = policyNode.Id,
|
||||
To = verdictNode.Id,
|
||||
Relation = EvidenceRelation.DerivedFrom,
|
||||
Label = "policy evaluation"
|
||||
});
|
||||
}
|
||||
|
||||
// Build VEX nodes
|
||||
foreach (var vex in evidence.VexStatements)
|
||||
{
|
||||
var vexNode = await BuildVexNodeAsync(vex, ct);
|
||||
nodes.Add(vexNode);
|
||||
edges.Add(new EvidenceEdge
|
||||
{
|
||||
From = vexNode.Id,
|
||||
To = verdictNode.Id,
|
||||
Relation = EvidenceRelation.DerivedFrom,
|
||||
Label = vex.Status.ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
|
||||
// Build reachability node
|
||||
if (evidence.Reachability is not null)
|
||||
{
|
||||
var reachNode = await BuildReachabilityNodeAsync(evidence.Reachability, ct);
|
||||
nodes.Add(reachNode);
|
||||
edges.Add(new EvidenceEdge
|
||||
{
|
||||
From = reachNode.Id,
|
||||
To = verdictNode.Id,
|
||||
Relation = EvidenceRelation.Corroborates,
|
||||
Label = "reachability analysis"
|
||||
});
|
||||
}
|
||||
|
||||
// Build runtime nodes
|
||||
foreach (var runtime in evidence.RuntimeObservations)
|
||||
{
|
||||
var runtimeNode = await BuildRuntimeNodeAsync(runtime, ct);
|
||||
nodes.Add(runtimeNode);
|
||||
edges.Add(new EvidenceEdge
|
||||
{
|
||||
From = runtimeNode.Id,
|
||||
To = verdictNode.Id,
|
||||
Relation = EvidenceRelation.Corroborates,
|
||||
Label = "runtime observation"
|
||||
});
|
||||
}
|
||||
|
||||
// Build SBOM node
|
||||
if (evidence.SbomComponent is not null)
|
||||
{
|
||||
var sbomNode = BuildSbomNode(evidence.SbomComponent);
|
||||
nodes.Add(sbomNode);
|
||||
edges.Add(new EvidenceEdge
|
||||
{
|
||||
From = sbomNode.Id,
|
||||
To = verdictNode.Id,
|
||||
Relation = EvidenceRelation.References,
|
||||
Label = "component"
|
||||
});
|
||||
}
|
||||
|
||||
// Build provenance node
|
||||
if (evidence.Provenance is not null)
|
||||
{
|
||||
var provNode = await BuildProvenanceNodeAsync(evidence.Provenance, ct);
|
||||
nodes.Add(provNode);
|
||||
edges.Add(new EvidenceEdge
|
||||
{
|
||||
From = provNode.Id,
|
||||
To = verdictNode.Id,
|
||||
Relation = EvidenceRelation.VerifiedBy,
|
||||
Label = "provenance"
|
||||
});
|
||||
}
|
||||
|
||||
return new EvidenceGraphResponse
|
||||
{
|
||||
FindingId = findingId,
|
||||
VulnerabilityId = evidence.VulnerabilityId,
|
||||
Nodes = nodes,
|
||||
Edges = edges,
|
||||
RootNodeId = verdictNode.Id,
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private EvidenceNode BuildVerdictNode(VerdictEvidence verdict)
|
||||
{
|
||||
return new EvidenceNode
|
||||
{
|
||||
Id = $"verdict:{verdict.Digest}",
|
||||
Type = EvidenceNodeType.Verdict,
|
||||
Label = $"Verdict: {verdict.Status}",
|
||||
Digest = verdict.Digest,
|
||||
Issuer = verdict.Issuer,
|
||||
Timestamp = verdict.Timestamp,
|
||||
Signature = new SignatureStatus { IsSigned = false },
|
||||
ContentUrl = $"/api/v1/evidence/{verdict.Digest}"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<EvidenceNode> BuildPolicyNodeAsync(
|
||||
PolicyTraceEvidence policy,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var signature = await VerifySignatureAsync(policy.AttestationDigest, ct);
|
||||
return new EvidenceNode
|
||||
{
|
||||
Id = $"policy:{policy.Digest}",
|
||||
Type = EvidenceNodeType.PolicyTrace,
|
||||
Label = $"Policy: {policy.PolicyName}",
|
||||
Digest = policy.Digest,
|
||||
Issuer = policy.Issuer,
|
||||
Timestamp = policy.Timestamp,
|
||||
Signature = signature,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["policyName"] = policy.PolicyName,
|
||||
["policyVersion"] = policy.PolicyVersion
|
||||
},
|
||||
ContentUrl = $"/api/v1/evidence/{policy.Digest}"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<EvidenceNode> BuildVexNodeAsync(
|
||||
VexEvidence vex,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var signature = await VerifySignatureAsync(vex.AttestationDigest, ct);
|
||||
return new EvidenceNode
|
||||
{
|
||||
Id = $"vex:{vex.Digest}",
|
||||
Type = EvidenceNodeType.VexStatement,
|
||||
Label = $"VEX: {vex.Status}",
|
||||
Digest = vex.Digest,
|
||||
Issuer = vex.Issuer,
|
||||
Timestamp = vex.Timestamp,
|
||||
Signature = signature,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["status"] = vex.Status,
|
||||
["justification"] = vex.Justification ?? string.Empty
|
||||
},
|
||||
ContentUrl = $"/api/v1/evidence/{vex.Digest}"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<EvidenceNode> BuildReachabilityNodeAsync(
|
||||
ReachabilityEvidence reach,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var signature = await VerifySignatureAsync(reach.AttestationDigest, ct);
|
||||
return new EvidenceNode
|
||||
{
|
||||
Id = $"reach:{reach.Digest}",
|
||||
Type = EvidenceNodeType.Reachability,
|
||||
Label = $"Reachability: {reach.State}",
|
||||
Digest = reach.Digest,
|
||||
Issuer = reach.Issuer,
|
||||
Timestamp = reach.Timestamp,
|
||||
Signature = signature,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["state"] = reach.State,
|
||||
["confidence"] = reach.Confidence.ToString("F2")
|
||||
},
|
||||
ContentUrl = $"/api/v1/evidence/{reach.Digest}"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<EvidenceNode> BuildRuntimeNodeAsync(
|
||||
RuntimeEvidence runtime,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var signature = await VerifySignatureAsync(runtime.AttestationDigest, ct);
|
||||
return new EvidenceNode
|
||||
{
|
||||
Id = $"runtime:{runtime.Digest}",
|
||||
Type = EvidenceNodeType.RuntimeObservation,
|
||||
Label = $"Runtime: {runtime.ObservationType}",
|
||||
Digest = runtime.Digest,
|
||||
Issuer = runtime.Issuer,
|
||||
Timestamp = runtime.Timestamp,
|
||||
Signature = signature,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["observationType"] = runtime.ObservationType,
|
||||
["durationMinutes"] = runtime.DurationMinutes.ToString()
|
||||
},
|
||||
ContentUrl = $"/api/v1/evidence/{runtime.Digest}"
|
||||
};
|
||||
}
|
||||
|
||||
private EvidenceNode BuildSbomNode(SbomComponentEvidence sbom)
|
||||
{
|
||||
return new EvidenceNode
|
||||
{
|
||||
Id = $"sbom:{sbom.Digest}",
|
||||
Type = EvidenceNodeType.SbomComponent,
|
||||
Label = $"Component: {sbom.ComponentName}",
|
||||
Digest = sbom.Digest,
|
||||
Issuer = sbom.Issuer,
|
||||
Timestamp = sbom.Timestamp,
|
||||
Signature = new SignatureStatus { IsSigned = false },
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["purl"] = sbom.Purl,
|
||||
["version"] = sbom.Version
|
||||
},
|
||||
ContentUrl = $"/api/v1/evidence/{sbom.Digest}"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<EvidenceNode> BuildProvenanceNodeAsync(
|
||||
ProvenanceEvidence prov,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var signature = await VerifySignatureAsync(prov.AttestationDigest, ct);
|
||||
return new EvidenceNode
|
||||
{
|
||||
Id = $"prov:{prov.Digest}",
|
||||
Type = EvidenceNodeType.Provenance,
|
||||
Label = $"Provenance: {prov.BuilderType}",
|
||||
Digest = prov.Digest,
|
||||
Issuer = prov.Issuer,
|
||||
Timestamp = prov.Timestamp,
|
||||
Signature = signature,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["builderType"] = prov.BuilderType,
|
||||
["repoUrl"] = prov.RepoUrl ?? string.Empty
|
||||
},
|
||||
ContentUrl = $"/api/v1/evidence/{prov.Digest}"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SignatureStatus> VerifySignatureAsync(
|
||||
string? attestationDigest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (attestationDigest is null)
|
||||
{
|
||||
return new SignatureStatus { IsSigned = false };
|
||||
}
|
||||
|
||||
var result = await _attestationVerifier.VerifyAsync(attestationDigest, ct);
|
||||
return new SignatureStatus
|
||||
{
|
||||
IsSigned = true,
|
||||
IsValid = result.IsValid,
|
||||
SignerIdentity = result.SignerIdentity,
|
||||
SignedAt = result.SignedAt,
|
||||
KeyId = result.KeyId,
|
||||
RekorLogIndex = result.RekorLogIndex
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository for evidence retrieval.
|
||||
/// </summary>
|
||||
public interface IEvidenceRepository
|
||||
{
|
||||
Task<FullEvidence?> GetFullEvidenceAsync(Guid findingId, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for attestation verification.
|
||||
/// </summary>
|
||||
public interface IAttestationVerifier
|
||||
{
|
||||
Task<AttestationVerificationResult> VerifyAsync(string digest, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full evidence bundle for a finding.
|
||||
/// </summary>
|
||||
public sealed record FullEvidence
|
||||
{
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required VerdictEvidence Verdict { get; init; }
|
||||
public PolicyTraceEvidence? PolicyTrace { get; init; }
|
||||
public IReadOnlyList<VexEvidence> VexStatements { get; init; } = Array.Empty<VexEvidence>();
|
||||
public ReachabilityEvidence? Reachability { get; init; }
|
||||
public IReadOnlyList<RuntimeEvidence> RuntimeObservations { get; init; } = Array.Empty<RuntimeEvidence>();
|
||||
public SbomComponentEvidence? SbomComponent { get; init; }
|
||||
public ProvenanceEvidence? Provenance { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VerdictEvidence
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required string Issuer { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyTraceEvidence
|
||||
{
|
||||
public required string PolicyName { get; init; }
|
||||
public required string PolicyVersion { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required string Issuer { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? AttestationDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VexEvidence
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required string Issuer { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? AttestationDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReachabilityEvidence
|
||||
{
|
||||
public required string State { get; init; }
|
||||
public required decimal Confidence { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required string Issuer { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? AttestationDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RuntimeEvidence
|
||||
{
|
||||
public required string ObservationType { get; init; }
|
||||
public required int DurationMinutes { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required string Issuer { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? AttestationDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SbomComponentEvidence
|
||||
{
|
||||
public required string ComponentName { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required string Issuer { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ProvenanceEvidence
|
||||
{
|
||||
public required string BuilderType { get; init; }
|
||||
public string? RepoUrl { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required string Issuer { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? AttestationDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AttestationVerificationResult
|
||||
{
|
||||
public required bool IsValid { get; init; }
|
||||
public string? SignerIdentity { get; init; }
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public long? RekorLogIndex { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
public interface IFindingSummaryBuilder
|
||||
{
|
||||
FindingSummary Build(FindingData finding);
|
||||
}
|
||||
|
||||
public sealed class FindingSummaryBuilder : IFindingSummaryBuilder
|
||||
{
|
||||
public FindingSummary Build(FindingData finding)
|
||||
{
|
||||
var status = DetermineStatus(finding);
|
||||
var chip = BuildChip(status, finding);
|
||||
var oneLiner = GenerateOneLiner(status, finding);
|
||||
var badges = ComputeBadges(finding);
|
||||
|
||||
return new FindingSummary
|
||||
{
|
||||
FindingId = finding.Id,
|
||||
VulnerabilityId = finding.VulnerabilityId,
|
||||
Title = finding.Title ?? finding.VulnerabilityId,
|
||||
Component = finding.ComponentPurl,
|
||||
Status = status,
|
||||
Chip = chip,
|
||||
Confidence = finding.Confidence,
|
||||
OneLiner = oneLiner,
|
||||
Badges = badges,
|
||||
CvssScore = finding.CvssScore,
|
||||
Severity = finding.Severity,
|
||||
FirstSeen = finding.FirstSeen,
|
||||
LastUpdated = finding.LastUpdated
|
||||
};
|
||||
}
|
||||
|
||||
private static VerdictStatus DetermineStatus(FindingData finding)
|
||||
{
|
||||
if (finding.IsMitigated)
|
||||
return VerdictStatus.Mitigated;
|
||||
|
||||
if (finding.IsAffected.HasValue)
|
||||
return finding.IsAffected.Value ? VerdictStatus.Affected : VerdictStatus.NotAffected;
|
||||
|
||||
return VerdictStatus.UnderReview;
|
||||
}
|
||||
|
||||
private static VerdictChip BuildChip(VerdictStatus status, FindingData finding)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
VerdictStatus.Affected => new VerdictChip
|
||||
{
|
||||
Label = "AFFECTED",
|
||||
Color = ChipColor.Red,
|
||||
Icon = "alert-circle"
|
||||
},
|
||||
VerdictStatus.NotAffected => new VerdictChip
|
||||
{
|
||||
Label = "NOT AFFECTED",
|
||||
Color = ChipColor.Green,
|
||||
Icon = "check-circle"
|
||||
},
|
||||
VerdictStatus.UnderReview => new VerdictChip
|
||||
{
|
||||
Label = "REVIEW NEEDED",
|
||||
Color = ChipColor.Yellow,
|
||||
Icon = "help-circle"
|
||||
},
|
||||
VerdictStatus.Mitigated => new VerdictChip
|
||||
{
|
||||
Label = "MITIGATED",
|
||||
Color = ChipColor.Blue,
|
||||
Icon = "shield-check"
|
||||
},
|
||||
_ => new VerdictChip
|
||||
{
|
||||
Label = "UNKNOWN",
|
||||
Color = ChipColor.Yellow,
|
||||
Icon = "question"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateOneLiner(VerdictStatus status, FindingData finding)
|
||||
{
|
||||
var componentName = ExtractComponentName(finding.ComponentPurl);
|
||||
|
||||
return status switch
|
||||
{
|
||||
VerdictStatus.Affected when finding.IsReachable == true =>
|
||||
$"Vulnerable code in {componentName} is reachable and actively used",
|
||||
|
||||
VerdictStatus.Affected when finding.IsReachable == false =>
|
||||
$"Vulnerability present in {componentName} but code is unreachable",
|
||||
|
||||
VerdictStatus.Affected =>
|
||||
$"Vulnerability affects {componentName} (reachability unknown)",
|
||||
|
||||
VerdictStatus.NotAffected when finding.HasRuntimeEvidence =>
|
||||
$"Component {componentName} not loaded during runtime observation",
|
||||
|
||||
VerdictStatus.NotAffected =>
|
||||
$"Vulnerability does not affect this version of {componentName}",
|
||||
|
||||
VerdictStatus.Mitigated when !string.IsNullOrEmpty(finding.MitigationReason) =>
|
||||
$"Mitigated: {finding.MitigationReason}",
|
||||
|
||||
VerdictStatus.Mitigated =>
|
||||
$"Vulnerability in {componentName} has been mitigated",
|
||||
|
||||
VerdictStatus.UnderReview when finding.Confidence < 0.5m =>
|
||||
$"Low confidence verdict for {componentName} requires manual review",
|
||||
|
||||
VerdictStatus.UnderReview =>
|
||||
$"Analysis incomplete for {componentName}",
|
||||
|
||||
_ => $"Status unknown for {componentName}"
|
||||
};
|
||||
}
|
||||
|
||||
private static ProofBadges ComputeBadges(FindingData finding)
|
||||
{
|
||||
var reachability = finding.IsReachable switch
|
||||
{
|
||||
true when finding.HasCallGraph => BadgeStatus.Strong,
|
||||
true => BadgeStatus.Partial,
|
||||
false when finding.HasCallGraph => BadgeStatus.Strong,
|
||||
_ => BadgeStatus.None
|
||||
};
|
||||
|
||||
var runtime = finding.HasRuntimeEvidence switch
|
||||
{
|
||||
true when finding.RuntimeConfirmed => BadgeStatus.Verified,
|
||||
true => BadgeStatus.Strong,
|
||||
_ => BadgeStatus.None
|
||||
};
|
||||
|
||||
var policy = finding.HasPolicyEvaluation switch
|
||||
{
|
||||
true when finding.PolicyPassed => BadgeStatus.Strong,
|
||||
true => BadgeStatus.Partial,
|
||||
_ => BadgeStatus.None
|
||||
};
|
||||
|
||||
var provenance = finding.HasAttestation switch
|
||||
{
|
||||
true when finding.AttestationVerified => BadgeStatus.Verified,
|
||||
true => BadgeStatus.Strong,
|
||||
_ => BadgeStatus.None
|
||||
};
|
||||
|
||||
return new ProofBadges
|
||||
{
|
||||
Reachability = reachability,
|
||||
Runtime = runtime,
|
||||
Policy = policy,
|
||||
Provenance = provenance
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtractComponentName(string purl)
|
||||
{
|
||||
var parts = purl.Split('/');
|
||||
var namePart = parts.LastOrDefault() ?? purl;
|
||||
return namePart.Split('@').FirstOrDefault() ?? namePart;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal finding data structure.
|
||||
/// </summary>
|
||||
public sealed record FindingData
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public required string ComponentPurl { get; init; }
|
||||
public bool? IsAffected { get; init; }
|
||||
public bool IsMitigated { get; init; }
|
||||
public string? MitigationReason { get; init; }
|
||||
public required decimal Confidence { get; init; }
|
||||
public bool? IsReachable { get; init; }
|
||||
public bool HasCallGraph { get; init; }
|
||||
public bool HasRuntimeEvidence { get; init; }
|
||||
public bool RuntimeConfirmed { get; init; }
|
||||
public bool HasPolicyEvaluation { get; init; }
|
||||
public bool PolicyPassed { get; init; }
|
||||
public bool HasAttestation { get; init; }
|
||||
public bool AttestationVerified { get; init; }
|
||||
public decimal? CvssScore { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public required DateTimeOffset FirstSeen { get; init; }
|
||||
public required DateTimeOffset LastUpdated { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
public interface IFindingSummaryService
|
||||
{
|
||||
Task<FindingSummary?> GetSummaryAsync(Guid findingId, CancellationToken ct);
|
||||
Task<FindingSummaryPage> GetSummariesAsync(FindingSummaryFilter filter, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class FindingSummaryService : IFindingSummaryService
|
||||
{
|
||||
private readonly IFindingSummaryBuilder _builder;
|
||||
private readonly IFindingRepository _repository;
|
||||
|
||||
public FindingSummaryService(
|
||||
IFindingSummaryBuilder builder,
|
||||
IFindingRepository repository)
|
||||
{
|
||||
_builder = builder;
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<FindingSummary?> GetSummaryAsync(Guid findingId, CancellationToken ct)
|
||||
{
|
||||
var finding = await _repository.GetByIdAsync(findingId, ct);
|
||||
if (finding is null)
|
||||
return null;
|
||||
|
||||
return _builder.Build(finding);
|
||||
}
|
||||
|
||||
public async Task<FindingSummaryPage> GetSummariesAsync(FindingSummaryFilter filter, CancellationToken ct)
|
||||
{
|
||||
var (findings, totalCount) = await _repository.GetPagedAsync(
|
||||
page: filter.Page,
|
||||
pageSize: filter.PageSize,
|
||||
status: filter.Status,
|
||||
severity: filter.Severity,
|
||||
minConfidence: filter.MinConfidence,
|
||||
ct);
|
||||
|
||||
var summaries = findings.Select(f => _builder.Build(f)).ToList();
|
||||
|
||||
return new FindingSummaryPage
|
||||
{
|
||||
Items = summaries,
|
||||
TotalCount = totalCount,
|
||||
Page = filter.Page,
|
||||
PageSize = filter.PageSize
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository for finding data access.
|
||||
/// </summary>
|
||||
public interface IFindingRepository
|
||||
{
|
||||
Task<FindingData?> GetByIdAsync(Guid id, CancellationToken ct);
|
||||
Task<(IReadOnlyList<FindingData> findings, int totalCount)> GetPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? status,
|
||||
string? severity,
|
||||
decimal? minConfidence,
|
||||
CancellationToken ct);
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
|
||||
<ProjectReference Include="..\..\Scanner\__Libraries\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Services;
|
||||
|
||||
public class EvidenceGraphBuilderTests
|
||||
{
|
||||
private readonly Mock<IEvidenceRepository> _evidenceRepo = new();
|
||||
private readonly Mock<IAttestationVerifier> _attestationVerifier = new();
|
||||
private readonly EvidenceGraphBuilder _builder;
|
||||
|
||||
public EvidenceGraphBuilderTests()
|
||||
{
|
||||
_builder = new EvidenceGraphBuilder(_evidenceRepo.Object, _attestationVerifier.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_FindingNotFound_ReturnsNull()
|
||||
{
|
||||
_evidenceRepo.Setup(r => r.GetFullEvidenceAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FullEvidence?)null);
|
||||
|
||||
var result = await _builder.BuildAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_WithAllEvidence_ReturnsCompleteGraph()
|
||||
{
|
||||
var evidence = CreateFullEvidence();
|
||||
_evidenceRepo.Setup(r => r.GetFullEvidenceAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(evidence);
|
||||
_attestationVerifier.Setup(v => v.VerifyAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AttestationVerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
SignerIdentity = "test-signer",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
KeyId = "key-123",
|
||||
RekorLogIndex = 12345
|
||||
});
|
||||
|
||||
var findingId = Guid.NewGuid();
|
||||
var result = await _builder.BuildAsync(findingId, CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.FindingId.Should().Be(findingId);
|
||||
result.VulnerabilityId.Should().Be("CVE-2024-1234");
|
||||
result.Nodes.Should().HaveCountGreaterThan(1);
|
||||
result.Edges.Should().NotBeEmpty();
|
||||
result.RootNodeId.Should().NotBeNullOrEmpty();
|
||||
result.RootNodeId.Should().StartWith("verdict:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_SignedAttestation_IncludesSignatureStatus()
|
||||
{
|
||||
var evidence = CreateEvidenceWithSignedAttestation();
|
||||
_evidenceRepo.Setup(r => r.GetFullEvidenceAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(evidence);
|
||||
_attestationVerifier.Setup(v => v.VerifyAsync("attestation-digest-123", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AttestationVerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
SignerIdentity = "trusted-signer@example.com",
|
||||
SignedAt = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
KeyId = "key-abc",
|
||||
RekorLogIndex = 54321
|
||||
});
|
||||
|
||||
var result = await _builder.BuildAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
var signedNode = result!.Nodes.FirstOrDefault(n => n.Signature.IsSigned);
|
||||
signedNode.Should().NotBeNull();
|
||||
signedNode!.Signature.IsValid.Should().BeTrue();
|
||||
signedNode.Signature.SignerIdentity.Should().Be("trusted-signer@example.com");
|
||||
signedNode.Signature.KeyId.Should().Be("key-abc");
|
||||
signedNode.Signature.RekorLogIndex.Should().Be(54321);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_EdgeRelationships_CorrectlyLinked()
|
||||
{
|
||||
var evidence = CreateFullEvidence();
|
||||
_evidenceRepo.Setup(r => r.GetFullEvidenceAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(evidence);
|
||||
_attestationVerifier.Setup(v => v.VerifyAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AttestationVerificationResult { IsValid = true });
|
||||
|
||||
var result = await _builder.BuildAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
|
||||
// Verify policy trace edge
|
||||
var policyEdge = result!.Edges.FirstOrDefault(e => e.Label == "policy evaluation");
|
||||
policyEdge.Should().NotBeNull();
|
||||
policyEdge!.Relation.Should().Be(EvidenceRelation.DerivedFrom);
|
||||
policyEdge.To.Should().Be(result.RootNodeId);
|
||||
|
||||
// Verify VEX edge
|
||||
var vexEdge = result.Edges.FirstOrDefault(e => e.Label == "affected");
|
||||
vexEdge.Should().NotBeNull();
|
||||
vexEdge!.Relation.Should().Be(EvidenceRelation.DerivedFrom);
|
||||
|
||||
// Verify reachability edge
|
||||
var reachEdge = result.Edges.FirstOrDefault(e => e.Label == "reachability analysis");
|
||||
reachEdge.Should().NotBeNull();
|
||||
reachEdge!.Relation.Should().Be(EvidenceRelation.Corroborates);
|
||||
|
||||
// Verify runtime edge
|
||||
var runtimeEdge = result.Edges.FirstOrDefault(e => e.Label == "runtime observation");
|
||||
runtimeEdge.Should().NotBeNull();
|
||||
runtimeEdge!.Relation.Should().Be(EvidenceRelation.Corroborates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_NodeTypes_CorrectlyAssigned()
|
||||
{
|
||||
var evidence = CreateFullEvidence();
|
||||
_evidenceRepo.Setup(r => r.GetFullEvidenceAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(evidence);
|
||||
_attestationVerifier.Setup(v => v.VerifyAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AttestationVerificationResult { IsValid = true });
|
||||
|
||||
var result = await _builder.BuildAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Nodes.Should().Contain(n => n.Type == EvidenceNodeType.Verdict);
|
||||
result.Nodes.Should().Contain(n => n.Type == EvidenceNodeType.PolicyTrace);
|
||||
result.Nodes.Should().Contain(n => n.Type == EvidenceNodeType.VexStatement);
|
||||
result.Nodes.Should().Contain(n => n.Type == EvidenceNodeType.Reachability);
|
||||
result.Nodes.Should().Contain(n => n.Type == EvidenceNodeType.RuntimeObservation);
|
||||
result.Nodes.Should().Contain(n => n.Type == EvidenceNodeType.SbomComponent);
|
||||
result.Nodes.Should().Contain(n => n.Type == EvidenceNodeType.Provenance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_MinimalEvidence_CreatesVerdictOnly()
|
||||
{
|
||||
var evidence = CreateMinimalEvidence();
|
||||
_evidenceRepo.Setup(r => r.GetFullEvidenceAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(evidence);
|
||||
|
||||
var result = await _builder.BuildAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Nodes.Should().HaveCount(1);
|
||||
result.Nodes[0].Type.Should().Be(EvidenceNodeType.Verdict);
|
||||
result.Edges.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_UnsignedEvidence_HasUnsignedStatus()
|
||||
{
|
||||
var evidence = CreateMinimalEvidence();
|
||||
_evidenceRepo.Setup(r => r.GetFullEvidenceAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(evidence);
|
||||
|
||||
var result = await _builder.BuildAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Nodes.Should().AllSatisfy(n =>
|
||||
{
|
||||
if (n.Type == EvidenceNodeType.Verdict || n.Type == EvidenceNodeType.SbomComponent)
|
||||
{
|
||||
n.Signature.IsSigned.Should().BeFalse();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_NodeMetadata_PopulatedCorrectly()
|
||||
{
|
||||
var evidence = CreateFullEvidence();
|
||||
_evidenceRepo.Setup(r => r.GetFullEvidenceAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(evidence);
|
||||
_attestationVerifier.Setup(v => v.VerifyAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AttestationVerificationResult { IsValid = true });
|
||||
|
||||
var result = await _builder.BuildAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
|
||||
var policyNode = result!.Nodes.First(n => n.Type == EvidenceNodeType.PolicyTrace);
|
||||
policyNode.Metadata.Should().ContainKey("policyName");
|
||||
policyNode.Metadata["policyName"].Should().Be("security-baseline");
|
||||
|
||||
var vexNode = result.Nodes.First(n => n.Type == EvidenceNodeType.VexStatement);
|
||||
vexNode.Metadata.Should().ContainKey("status");
|
||||
vexNode.Metadata["status"].Should().Be("affected");
|
||||
|
||||
var sbomNode = result.Nodes.First(n => n.Type == EvidenceNodeType.SbomComponent);
|
||||
sbomNode.Metadata.Should().ContainKey("purl");
|
||||
sbomNode.Metadata["purl"].Should().Be("pkg:npm/lodash@4.17.20");
|
||||
}
|
||||
|
||||
private static FullEvidence CreateFullEvidence()
|
||||
{
|
||||
return new FullEvidence
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
Verdict = new VerdictEvidence
|
||||
{
|
||||
Status = "Affected",
|
||||
Digest = "sha256:verdict123",
|
||||
Issuer = "stellaops",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
},
|
||||
PolicyTrace = new PolicyTraceEvidence
|
||||
{
|
||||
PolicyName = "security-baseline",
|
||||
PolicyVersion = "v1.0.0",
|
||||
Digest = "sha256:policy123",
|
||||
Issuer = "stellaops",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
AttestationDigest = "attestation-policy-123"
|
||||
},
|
||||
VexStatements = new[]
|
||||
{
|
||||
new VexEvidence
|
||||
{
|
||||
Status = "affected",
|
||||
Justification = "vulnerable_code_path_reachable",
|
||||
Digest = "sha256:vex123",
|
||||
Issuer = "vendor",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-2),
|
||||
AttestationDigest = "attestation-vex-123"
|
||||
}
|
||||
},
|
||||
Reachability = new ReachabilityEvidence
|
||||
{
|
||||
State = "StaticReachable",
|
||||
Confidence = 0.92m,
|
||||
Digest = "sha256:reach123",
|
||||
Issuer = "stellaops",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
AttestationDigest = "attestation-reach-123"
|
||||
},
|
||||
RuntimeObservations = new[]
|
||||
{
|
||||
new RuntimeEvidence
|
||||
{
|
||||
ObservationType = "ComponentLoaded",
|
||||
DurationMinutes = 120,
|
||||
Digest = "sha256:runtime123",
|
||||
Issuer = "stellaops",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddHours(-2),
|
||||
AttestationDigest = "attestation-runtime-123"
|
||||
}
|
||||
},
|
||||
SbomComponent = new SbomComponentEvidence
|
||||
{
|
||||
ComponentName = "lodash",
|
||||
Purl = "pkg:npm/lodash@4.17.20",
|
||||
Version = "4.17.20",
|
||||
Digest = "sha256:sbom123",
|
||||
Issuer = "stellaops",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-3)
|
||||
},
|
||||
Provenance = new ProvenanceEvidence
|
||||
{
|
||||
BuilderType = "github-actions",
|
||||
RepoUrl = "https://github.com/lodash/lodash",
|
||||
Digest = "sha256:prov123",
|
||||
Issuer = "github",
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-30),
|
||||
AttestationDigest = "attestation-prov-123"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static FullEvidence CreateEvidenceWithSignedAttestation()
|
||||
{
|
||||
return new FullEvidence
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-5678",
|
||||
Verdict = new VerdictEvidence
|
||||
{
|
||||
Status = "Affected",
|
||||
Digest = "sha256:verdict456",
|
||||
Issuer = "stellaops",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
VexStatements = new[]
|
||||
{
|
||||
new VexEvidence
|
||||
{
|
||||
Status = "affected",
|
||||
Digest = "sha256:vex456",
|
||||
Issuer = "vendor",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
AttestationDigest = "attestation-digest-123"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static FullEvidence CreateMinimalEvidence()
|
||||
{
|
||||
return new FullEvidence
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-9999",
|
||||
Verdict = new VerdictEvidence
|
||||
{
|
||||
Status = "UnderReview",
|
||||
Digest = "sha256:verdict999",
|
||||
Issuer = "stellaops",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Services;
|
||||
|
||||
public class FindingSummaryBuilderTests
|
||||
{
|
||||
private readonly FindingSummaryBuilder _builder = new();
|
||||
|
||||
[Fact]
|
||||
public void Build_AffectedFinding_ReturnsRedChip()
|
||||
{
|
||||
var finding = CreateFinding(isAffected: true);
|
||||
|
||||
var result = _builder.Build(finding);
|
||||
|
||||
result.Status.Should().Be(VerdictStatus.Affected);
|
||||
result.Chip.Color.Should().Be(ChipColor.Red);
|
||||
result.Chip.Label.Should().Be("AFFECTED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NotAffectedFinding_ReturnsGreenChip()
|
||||
{
|
||||
var finding = CreateFinding(isAffected: false);
|
||||
|
||||
var result = _builder.Build(finding);
|
||||
|
||||
result.Status.Should().Be(VerdictStatus.NotAffected);
|
||||
result.Chip.Color.Should().Be(ChipColor.Green);
|
||||
result.Chip.Label.Should().Be("NOT AFFECTED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_MitigatedFinding_ReturnsBlueChip()
|
||||
{
|
||||
var finding = CreateFinding(isAffected: true, isMitigated: true);
|
||||
|
||||
var result = _builder.Build(finding);
|
||||
|
||||
result.Status.Should().Be(VerdictStatus.Mitigated);
|
||||
result.Chip.Color.Should().Be(ChipColor.Blue);
|
||||
result.Chip.Label.Should().Be("MITIGATED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_UnknownStatus_ReturnsYellowChip()
|
||||
{
|
||||
var finding = CreateFinding(isAffected: null);
|
||||
|
||||
var result = _builder.Build(finding);
|
||||
|
||||
result.Status.Should().Be(VerdictStatus.UnderReview);
|
||||
result.Chip.Color.Should().Be(ChipColor.Yellow);
|
||||
result.Chip.Label.Should().Be("REVIEW NEEDED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ReachableVulnerability_GeneratesAppropriateOneLiner()
|
||||
{
|
||||
var finding = CreateFinding(isAffected: true, isReachable: true);
|
||||
|
||||
var result = _builder.Build(finding);
|
||||
|
||||
result.OneLiner.Should().Contain("reachable");
|
||||
result.OneLiner.Should().Contain("actively used");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithCallGraph_StrongReachabilityBadge()
|
||||
{
|
||||
var finding = CreateFinding(isAffected: true, isReachable: true, hasCallGraph: true);
|
||||
|
||||
var result = _builder.Build(finding);
|
||||
|
||||
result.Badges.Reachability.Should().Be(BadgeStatus.Strong);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithRuntimeEvidence_StrongRuntimeBadge()
|
||||
{
|
||||
var finding = CreateFinding(isAffected: true, hasRuntimeEvidence: true);
|
||||
|
||||
var result = _builder.Build(finding);
|
||||
|
||||
result.Badges.Runtime.Should().Be(BadgeStatus.Strong);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithVerifiedRuntime_VerifiedBadge()
|
||||
{
|
||||
var finding = CreateFinding(
|
||||
isAffected: true,
|
||||
hasRuntimeEvidence: true,
|
||||
runtimeConfirmed: true);
|
||||
|
||||
var result = _builder.Build(finding);
|
||||
|
||||
result.Badges.Runtime.Should().Be(BadgeStatus.Verified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithAttestation_StrongProvenanceBadge()
|
||||
{
|
||||
var finding = CreateFinding(isAffected: true, hasAttestation: true);
|
||||
|
||||
var result = _builder.Build(finding);
|
||||
|
||||
result.Badges.Provenance.Should().Be(BadgeStatus.Strong);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithVerifiedAttestation_VerifiedProvenanceBadge()
|
||||
{
|
||||
var finding = CreateFinding(
|
||||
isAffected: true,
|
||||
hasAttestation: true,
|
||||
attestationVerified: true);
|
||||
|
||||
var result = _builder.Build(finding);
|
||||
|
||||
result.Badges.Provenance.Should().Be(BadgeStatus.Verified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CopiesAllBasicFields()
|
||||
{
|
||||
var finding = CreateFinding(isAffected: true);
|
||||
|
||||
var result = _builder.Build(finding);
|
||||
|
||||
result.FindingId.Should().Be(finding.Id);
|
||||
result.VulnerabilityId.Should().Be(finding.VulnerabilityId);
|
||||
result.Component.Should().Be(finding.ComponentPurl);
|
||||
result.Confidence.Should().Be(finding.Confidence);
|
||||
result.CvssScore.Should().Be(finding.CvssScore);
|
||||
result.Severity.Should().Be(finding.Severity);
|
||||
}
|
||||
|
||||
private static FindingData CreateFinding(
|
||||
bool? isAffected = true,
|
||||
bool isMitigated = false,
|
||||
bool? isReachable = null,
|
||||
bool hasCallGraph = false,
|
||||
bool hasRuntimeEvidence = false,
|
||||
bool runtimeConfirmed = false,
|
||||
bool hasAttestation = false,
|
||||
bool attestationVerified = false)
|
||||
{
|
||||
return new FindingData
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
Title = "Test Vulnerability",
|
||||
ComponentPurl = "pkg:npm/test-package@1.0.0",
|
||||
IsAffected = isAffected,
|
||||
IsMitigated = isMitigated,
|
||||
MitigationReason = isMitigated ? "Test mitigation" : null,
|
||||
Confidence = 0.85m,
|
||||
IsReachable = isReachable,
|
||||
HasCallGraph = hasCallGraph,
|
||||
HasRuntimeEvidence = hasRuntimeEvidence,
|
||||
RuntimeConfirmed = runtimeConfirmed,
|
||||
HasPolicyEvaluation = false,
|
||||
PolicyPassed = false,
|
||||
HasAttestation = hasAttestation,
|
||||
AttestationVerified = attestationVerified,
|
||||
CvssScore = 7.5m,
|
||||
Severity = "High",
|
||||
FirstSeen = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
LastUpdated = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Findings.Ledger\StellaOps.Findings.Ledger.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Findings.Ledger.WebService\StellaOps.Findings.Ledger.WebService.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
@@ -17,5 +18,6 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user