Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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