save progress
This commit is contained in:
@@ -0,0 +1,617 @@
|
||||
// <copyright file="ExportEndpointExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Net.Mime;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Spdx3;
|
||||
using StellaOps.VexLens.Api;
|
||||
using StellaOps.VexLens.Spdx3;
|
||||
using StellaOps.VexLens.Storage;
|
||||
using ModelsVexStatus = StellaOps.VexLens.Models.VexStatus;
|
||||
using ModelsVexJustification = StellaOps.VexLens.Models.VexJustification;
|
||||
|
||||
namespace StellaOps.VexLens.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for mapping VEX export endpoints.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-010
|
||||
/// </summary>
|
||||
public static class ExportEndpointExtensions
|
||||
{
|
||||
private const string TenantHeader = "X-StellaOps-Tenant";
|
||||
|
||||
/// <summary>
|
||||
/// Maps the VEX export endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapExportEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/vexlens/export")
|
||||
.WithTags("VexLens Export");
|
||||
|
||||
// Export consensus result
|
||||
group.MapGet("/consensus/{vulnerabilityId}/{productId}", ExportConsensusAsync)
|
||||
.WithName("ExportConsensus")
|
||||
.WithDescription("Export VEX consensus in specified format (openvex, spdx3, csaf)")
|
||||
.Produces<string>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Export projection
|
||||
group.MapGet("/projections/{projectionId}", ExportProjectionAsync)
|
||||
.WithName("ExportProjection")
|
||||
.WithDescription("Export a specific VEX projection in specified format")
|
||||
.Produces<string>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Batch export
|
||||
group.MapPost("/batch", ExportBatchAsync)
|
||||
.WithName("ExportBatch")
|
||||
.WithDescription("Export multiple VEX entries in specified format")
|
||||
.Produces<string>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Combined SBOM+VEX export
|
||||
group.MapPost("/combined", ExportCombinedAsync)
|
||||
.WithName("ExportCombined")
|
||||
.WithDescription("Export combined SBOM+VEX document")
|
||||
.Produces<string>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExportConsensusAsync(
|
||||
string vulnerabilityId,
|
||||
string productId,
|
||||
[FromQuery] ExportFormat format,
|
||||
[FromQuery] bool includeCvss,
|
||||
[FromQuery] bool includeEpss,
|
||||
[FromServices] IVexLensApiService apiService,
|
||||
[FromServices] IVexToSpdx3Mapper spdx3Mapper,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
|
||||
// Decode product ID (it may be URL encoded)
|
||||
var decodedProductId = Uri.UnescapeDataString(productId);
|
||||
|
||||
// Compute consensus using the API service
|
||||
var consensusResponse = await apiService.ComputeConsensusAsync(
|
||||
new ComputeConsensusRequest(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
ProductKey: decodedProductId,
|
||||
TenantId: tenantId,
|
||||
Mode: null,
|
||||
MinimumWeightThreshold: null,
|
||||
StoreResult: false,
|
||||
EmitEvent: false),
|
||||
cancellationToken);
|
||||
|
||||
return format switch
|
||||
{
|
||||
ExportFormat.Spdx3 => await ExportAsSpdx3Async(
|
||||
consensusResponse, includeCvss, includeEpss, spdx3Mapper, cancellationToken),
|
||||
ExportFormat.OpenVex => ExportAsOpenVex(consensusResponse),
|
||||
ExportFormat.Csaf => ExportAsCsaf(consensusResponse),
|
||||
_ => Results.BadRequest(new { error = $"Unsupported format: {format}" })
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExportProjectionAsync(
|
||||
string projectionId,
|
||||
[FromQuery] ExportFormat format,
|
||||
[FromQuery] bool includeCvss,
|
||||
[FromQuery] bool includeEpss,
|
||||
[FromServices] IVexLensApiService apiService,
|
||||
[FromServices] IVexToSpdx3Mapper spdx3Mapper,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projection = await apiService.GetProjectionAsync(projectionId, cancellationToken);
|
||||
if (projection is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Projection {projectionId} not found" });
|
||||
}
|
||||
|
||||
// Convert projection to a format suitable for export
|
||||
var consensusResponse = new ComputeConsensusResponse(
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
Status: projection.Status,
|
||||
Justification: projection.Justification,
|
||||
ConfidenceScore: projection.ConfidenceScore,
|
||||
Outcome: projection.Outcome,
|
||||
Rationale: new ConsensusRationaleResponse(
|
||||
Summary: projection.RationaleSummary ?? string.Empty,
|
||||
Factors: Array.Empty<string>(),
|
||||
StatusWeights: new Dictionary<string, double>()),
|
||||
Contributions: Array.Empty<ContributionResponse>(),
|
||||
Conflicts: null,
|
||||
ProjectionId: projectionId,
|
||||
ComputedAt: projection.ComputedAt);
|
||||
|
||||
return format switch
|
||||
{
|
||||
ExportFormat.Spdx3 => await ExportAsSpdx3Async(
|
||||
consensusResponse, includeCvss, includeEpss, spdx3Mapper, cancellationToken),
|
||||
ExportFormat.OpenVex => ExportAsOpenVex(consensusResponse),
|
||||
ExportFormat.Csaf => ExportAsCsaf(consensusResponse),
|
||||
_ => Results.BadRequest(new { error = $"Unsupported format: {format}" })
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExportBatchAsync(
|
||||
[FromBody] BatchExportRequest request,
|
||||
[FromServices] IVexLensApiService apiService,
|
||||
[FromServices] IVexToSpdx3Mapper spdx3Mapper,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "At least one item required" });
|
||||
}
|
||||
|
||||
// Use batch API
|
||||
var targets = request.Items
|
||||
.Select(item => new ConsensusTarget(item.VulnerabilityId, item.ProductId))
|
||||
.ToList();
|
||||
|
||||
var batchResponse = await apiService.ComputeConsensusBatchAsync(
|
||||
new ComputeConsensusBatchRequest(
|
||||
Targets: targets,
|
||||
TenantId: tenantId,
|
||||
Mode: null,
|
||||
StoreResults: false,
|
||||
EmitEvents: false),
|
||||
cancellationToken);
|
||||
|
||||
if (batchResponse.Results.Count == 0)
|
||||
{
|
||||
return Results.NotFound(new { error = "No VEX data found for any of the specified items" });
|
||||
}
|
||||
|
||||
return request.Format switch
|
||||
{
|
||||
ExportFormat.Spdx3 => await ExportBatchAsSpdx3Async(
|
||||
batchResponse.Results, request.IncludeCvss, request.IncludeEpss, spdx3Mapper, cancellationToken),
|
||||
ExportFormat.OpenVex => ExportBatchAsOpenVex(batchResponse.Results),
|
||||
ExportFormat.Csaf => Results.BadRequest(new { error = "CSAF batch export not yet supported" }),
|
||||
_ => Results.BadRequest(new { error = $"Unsupported format: {request.Format}" })
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExportCombinedAsync(
|
||||
[FromBody] CombinedExportRequest request,
|
||||
[FromServices] IVexLensApiService apiService,
|
||||
[FromServices] IVexToSpdx3Mapper spdx3Mapper,
|
||||
[FromServices] ISpdx3Parser spdx3Parser,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
|
||||
if (string.IsNullOrEmpty(request.SbomContent))
|
||||
{
|
||||
return Results.BadRequest(new { error = "SBOM content required" });
|
||||
}
|
||||
|
||||
var format = request.SbomFormat?.ToLowerInvariant() ?? "spdx3";
|
||||
if (format != "spdx3")
|
||||
{
|
||||
return Results.BadRequest(new { error = "Only SPDX 3.0.1 format is supported for combined export" });
|
||||
}
|
||||
|
||||
// Parse the SBOM
|
||||
var parseResult = await spdx3Parser.ParseFromJsonAsync(request.SbomContent, cancellationToken);
|
||||
if (!parseResult.Success || parseResult.Document is null)
|
||||
{
|
||||
var errors = parseResult.Errors.Select(e => e.Message).ToList();
|
||||
return Results.BadRequest(new { error = "Failed to parse SBOM", details = errors });
|
||||
}
|
||||
|
||||
// Gather VEX statements for specified vulnerabilities
|
||||
var statements = new List<OpenVexStatement>();
|
||||
|
||||
if (request.Vulnerabilities is not null && request.Vulnerabilities.Count > 0)
|
||||
{
|
||||
var targets = request.Vulnerabilities
|
||||
.Select(v => new ConsensusTarget(v.VulnerabilityId, v.ProductId))
|
||||
.ToList();
|
||||
|
||||
var batchResponse = await apiService.ComputeConsensusBatchAsync(
|
||||
new ComputeConsensusBatchRequest(
|
||||
Targets: targets,
|
||||
TenantId: tenantId,
|
||||
Mode: null,
|
||||
StoreResults: false,
|
||||
EmitEvents: false),
|
||||
cancellationToken);
|
||||
|
||||
foreach (var result in batchResponse.Results)
|
||||
{
|
||||
statements.Add(CreateOpenVexStatement(result));
|
||||
}
|
||||
}
|
||||
|
||||
// Build combined SBOM+VEX document
|
||||
var documentId = $"https://stellaops.io/spdx/combined/{Guid.NewGuid():N}";
|
||||
var builder = CombinedSbomVexBuilder.Create()
|
||||
.WithDocumentId(documentId)
|
||||
.WithName("Combined SBOM and VEX Export")
|
||||
.WithSoftwareProfile(parseResult.Document);
|
||||
|
||||
if (statements.Count > 0)
|
||||
{
|
||||
builder.WithLinkedSecurityProfile(
|
||||
statements,
|
||||
"https://stellaops.io/spdx",
|
||||
purlToSpdxIdMap: null);
|
||||
}
|
||||
|
||||
var combined = builder.Build();
|
||||
|
||||
var json = JsonSerializer.Serialize(combined, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
return Results.Content(json, MediaTypeNames.Application.Json);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExportAsSpdx3Async(
|
||||
ComputeConsensusResponse consensusResponse,
|
||||
bool includeCvss,
|
||||
bool includeEpss,
|
||||
IVexToSpdx3Mapper mapper,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statement = CreateOpenVexStatement(consensusResponse);
|
||||
|
||||
var consensus = new VexConsensus
|
||||
{
|
||||
DocumentId = $"vex-{consensusResponse.VulnerabilityId}-{Guid.NewGuid():N}",
|
||||
Statements = new[] { statement },
|
||||
Author = "StellaOps VexLens",
|
||||
Timestamp = consensusResponse.ComputedAt
|
||||
};
|
||||
|
||||
var options = new VexToSpdx3Options
|
||||
{
|
||||
SpdxIdPrefix = "https://stellaops.io/spdx",
|
||||
ToolId = "StellaOps VexLens",
|
||||
IncludeCvss = includeCvss,
|
||||
IncludeEpss = includeEpss
|
||||
};
|
||||
|
||||
var document = await mapper.MapConsensusAsync(consensus, options, cancellationToken);
|
||||
|
||||
var json = JsonSerializer.Serialize(document, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
return Results.Content(json, MediaTypeNames.Application.Json);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExportBatchAsSpdx3Async(
|
||||
IReadOnlyList<ComputeConsensusResponse> results,
|
||||
bool includeCvss,
|
||||
bool includeEpss,
|
||||
IVexToSpdx3Mapper mapper,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statements = results.Select(CreateOpenVexStatement).ToList();
|
||||
|
||||
var consensus = new VexConsensus
|
||||
{
|
||||
DocumentId = $"vex-batch-{Guid.NewGuid():N}",
|
||||
Statements = statements,
|
||||
Author = "StellaOps VexLens",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var options = new VexToSpdx3Options
|
||||
{
|
||||
SpdxIdPrefix = "https://stellaops.io/spdx",
|
||||
ToolId = "StellaOps VexLens",
|
||||
IncludeCvss = includeCvss,
|
||||
IncludeEpss = includeEpss
|
||||
};
|
||||
|
||||
var document = await mapper.MapConsensusAsync(consensus, options, cancellationToken);
|
||||
|
||||
var json = JsonSerializer.Serialize(document, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
return Results.Content(json, MediaTypeNames.Application.Json);
|
||||
}
|
||||
|
||||
private static IResult ExportAsOpenVex(ComputeConsensusResponse response)
|
||||
{
|
||||
var openvex = new
|
||||
{
|
||||
context = "https://openvex.dev/ns/v0.2.0",
|
||||
id = $"https://stellaops.io/vex/{response.VulnerabilityId}/{Uri.EscapeDataString(response.ProductKey)}",
|
||||
author = "StellaOps VexLens",
|
||||
timestamp = response.ComputedAt.ToString("O"),
|
||||
version = 1,
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = new { name = response.VulnerabilityId },
|
||||
products = new[] { new { id = response.ProductKey } },
|
||||
status = MapVexStatusToString(response.Status),
|
||||
justification = response.Justification?.ToString()?.ToLowerInvariant(),
|
||||
impact_statement = response.Rationale?.Summary
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(openvex, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
|
||||
return Results.Content(json, MediaTypeNames.Application.Json);
|
||||
}
|
||||
|
||||
private static IResult ExportBatchAsOpenVex(IReadOnlyList<ComputeConsensusResponse> results)
|
||||
{
|
||||
var openvex = new
|
||||
{
|
||||
context = "https://openvex.dev/ns/v0.2.0",
|
||||
id = $"https://stellaops.io/vex/batch/{Guid.NewGuid():N}",
|
||||
author = "StellaOps VexLens",
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O"),
|
||||
version = 1,
|
||||
statements = results.Select(r => new
|
||||
{
|
||||
vulnerability = new { name = r.VulnerabilityId },
|
||||
products = new[] { new { id = r.ProductKey } },
|
||||
status = MapVexStatusToString(r.Status),
|
||||
justification = r.Justification?.ToString()?.ToLowerInvariant(),
|
||||
impact_statement = r.Rationale?.Summary
|
||||
}).ToArray()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(openvex, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
|
||||
return Results.Content(json, MediaTypeNames.Application.Json);
|
||||
}
|
||||
|
||||
private static IResult ExportAsCsaf(ComputeConsensusResponse response)
|
||||
{
|
||||
// CSAF format (simplified VEX profile)
|
||||
var csaf = new
|
||||
{
|
||||
document = new
|
||||
{
|
||||
category = "csaf_vex",
|
||||
csaf_version = "2.0",
|
||||
publisher = new
|
||||
{
|
||||
category = "vendor",
|
||||
name = "StellaOps VexLens",
|
||||
@namespace = "https://stellaops.io"
|
||||
},
|
||||
title = $"VEX for {response.VulnerabilityId}",
|
||||
tracking = new
|
||||
{
|
||||
current_release_date = response.ComputedAt.ToString("O"),
|
||||
id = $"CSAF-VEX-{response.VulnerabilityId}-{Guid.NewGuid():N}",
|
||||
initial_release_date = response.ComputedAt.ToString("O"),
|
||||
revision_history = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
date = response.ComputedAt.ToString("O"),
|
||||
number = "1",
|
||||
summary = "Initial VEX statement"
|
||||
}
|
||||
},
|
||||
status = "final",
|
||||
version = "1"
|
||||
}
|
||||
},
|
||||
product_tree = new
|
||||
{
|
||||
full_product_names = new[]
|
||||
{
|
||||
new { name = response.ProductKey, product_id = "CSAFPID-0001" }
|
||||
}
|
||||
},
|
||||
vulnerabilities = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
cve = response.VulnerabilityId,
|
||||
product_status = BuildCsafProductStatus(response.Status),
|
||||
threats = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
category = "impact",
|
||||
details = response.Rationale?.Summary ?? "See VEX statement for details",
|
||||
product_ids = new[] { "CSAFPID-0001" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(csaf, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
|
||||
return Results.Content(json, MediaTypeNames.Application.Json);
|
||||
}
|
||||
|
||||
private static object BuildCsafProductStatus(ModelsVexStatus status)
|
||||
{
|
||||
var productId = "CSAFPID-0001";
|
||||
|
||||
return status switch
|
||||
{
|
||||
ModelsVexStatus.Affected => new { known_affected = new[] { productId } },
|
||||
ModelsVexStatus.NotAffected => new { known_not_affected = new[] { productId } },
|
||||
ModelsVexStatus.Fixed => new { @fixed = new[] { productId } },
|
||||
_ => new { under_investigation = new[] { productId } }
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapVexStatusToString(ModelsVexStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
ModelsVexStatus.Affected => "affected",
|
||||
ModelsVexStatus.NotAffected => "not_affected",
|
||||
ModelsVexStatus.Fixed => "fixed",
|
||||
ModelsVexStatus.UnderInvestigation => "under_investigation",
|
||||
_ => "under_investigation"
|
||||
};
|
||||
}
|
||||
|
||||
private static OpenVexStatement CreateOpenVexStatement(ComputeConsensusResponse response)
|
||||
{
|
||||
return new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = response.VulnerabilityId,
|
||||
ProductId = response.ProductKey,
|
||||
Status = MapVexStatus(response.Status),
|
||||
Justification = MapVexJustification(response.Justification),
|
||||
ImpactStatement = response.Rationale?.Summary,
|
||||
Timestamp = response.ComputedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static VexStatus MapVexStatus(ModelsVexStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
ModelsVexStatus.Affected => VexStatus.Affected,
|
||||
ModelsVexStatus.NotAffected => VexStatus.NotAffected,
|
||||
ModelsVexStatus.Fixed => VexStatus.Fixed,
|
||||
ModelsVexStatus.UnderInvestigation => VexStatus.UnderInvestigation,
|
||||
_ => VexStatus.UnderInvestigation
|
||||
};
|
||||
}
|
||||
|
||||
private static VexJustification? MapVexJustification(ModelsVexJustification? justification)
|
||||
{
|
||||
if (justification is null) return null;
|
||||
return justification.Value switch
|
||||
{
|
||||
ModelsVexJustification.ComponentNotPresent => VexJustification.ComponentNotPresent,
|
||||
ModelsVexJustification.VulnerableCodeNotPresent => VexJustification.VulnerableCodeNotPresent,
|
||||
ModelsVexJustification.VulnerableCodeCannotBeControlledByAdversary => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
|
||||
ModelsVexJustification.VulnerableCodeNotInExecutePath => VexJustification.VulnerableCodeNotInExecutePath,
|
||||
ModelsVexJustification.InlineMitigationsAlreadyExist => VexJustification.InlineMitigationsAlreadyExist,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
return context.Request.Headers.TryGetValue(TenantHeader, out var value)
|
||||
? value.ToString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export format options.
|
||||
/// </summary>
|
||||
public enum ExportFormat
|
||||
{
|
||||
/// <summary>OpenVEX format.</summary>
|
||||
OpenVex,
|
||||
|
||||
/// <summary>SPDX 3.0.1 Security profile format.</summary>
|
||||
Spdx3,
|
||||
|
||||
/// <summary>CSAF VEX profile format.</summary>
|
||||
Csaf
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for batch VEX export.
|
||||
/// </summary>
|
||||
public sealed record BatchExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the items to export.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ExportItem>? Items { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the export format.
|
||||
/// </summary>
|
||||
public ExportFormat Format { get; init; } = ExportFormat.OpenVex;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to include CVSS assessments (SPDX3 only).
|
||||
/// </summary>
|
||||
public bool IncludeCvss { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to include EPSS assessments (SPDX3 only).
|
||||
/// </summary>
|
||||
public bool IncludeEpss { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An item to export.
|
||||
/// </summary>
|
||||
public sealed record ExportItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the vulnerability ID.
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the product ID.
|
||||
/// </summary>
|
||||
public required string ProductId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for combined SBOM+VEX export.
|
||||
/// </summary>
|
||||
public sealed record CombinedExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the SBOM content (JSON string).
|
||||
/// </summary>
|
||||
public string? SbomContent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SBOM format (spdx3, cyclonedx).
|
||||
/// </summary>
|
||||
public string? SbomFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the vulnerabilities to include VEX for.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ExportItem>? Vulnerabilities { get; init; }
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.VexLens/StellaOps.VexLens.csproj" />
|
||||
<ProjectReference Include="../StellaOps.VexLens.Persistence/StellaOps.VexLens.Persistence.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.VexLens.Spdx3/StellaOps.VexLens.Spdx3.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// </copyright>
|
||||
|
||||
using System.Globalization;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Security;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3;
|
||||
@@ -41,7 +42,7 @@ public static class CvssMapper
|
||||
AssessedElement = assessedElementSpdxId,
|
||||
From = vulnerabilitySpdxId,
|
||||
To = [assessedElementSpdxId],
|
||||
RelationshipType = "hasAssessmentFor",
|
||||
RelationshipType = Spdx3RelationshipType.HasAssessmentFor,
|
||||
Score = cvssData.BaseScore,
|
||||
Severity = MapSeverity(cvssData.BaseScore),
|
||||
VectorString = cvssData.VectorString,
|
||||
@@ -79,7 +80,7 @@ public static class CvssMapper
|
||||
AssessedElement = assessedElementSpdxId,
|
||||
From = vulnerabilitySpdxId,
|
||||
To = [assessedElementSpdxId],
|
||||
RelationshipType = "hasAssessmentFor",
|
||||
RelationshipType = Spdx3RelationshipType.HasAssessmentFor,
|
||||
Probability = epssData.Probability,
|
||||
Percentile = epssData.Percentile,
|
||||
PublishedTime = epssData.ScoreDate,
|
||||
@@ -165,70 +166,6 @@ public static class CvssMapper
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v3 data input model.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-007
|
||||
/// </summary>
|
||||
public sealed record CvssV3Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the CVSS v3 base score (0.0-10.0).
|
||||
/// </summary>
|
||||
public decimal? BaseScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the CVSS v3 vector string.
|
||||
/// </summary>
|
||||
public string? VectorString { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the temporal score.
|
||||
/// </summary>
|
||||
public decimal? TemporalScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the environmental score.
|
||||
/// </summary>
|
||||
public decimal? EnvironmentalScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the score was published.
|
||||
/// </summary>
|
||||
public DateTimeOffset? PublishedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the score was modified.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ModifiedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source of the CVSS data.
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EPSS data input model.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-007
|
||||
/// </summary>
|
||||
public sealed record EpssData
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the EPSS probability (0.0-1.0).
|
||||
/// </summary>
|
||||
public decimal? Probability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the EPSS percentile (0.0-1.0).
|
||||
/// </summary>
|
||||
public decimal? Percentile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the date of the EPSS score.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ScoreDate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed CVSS v3 vector components.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-007
|
||||
@@ -262,3 +199,4 @@ public sealed record CvssVectorComponents
|
||||
/// <summary>Gets or sets the Availability Impact (A).</summary>
|
||||
public string? AvailabilityImpact { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -249,33 +249,45 @@ public enum VexJustification
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v3 scoring data.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-007
|
||||
/// </summary>
|
||||
public sealed record CvssV3Data
|
||||
{
|
||||
/// <summary>Gets the CVSS v3 base score (0.0-10.0).</summary>
|
||||
public required double BaseScore { get; init; }
|
||||
public decimal? BaseScore { get; init; }
|
||||
|
||||
/// <summary>Gets the CVSS v3 vector string.</summary>
|
||||
public required string VectorString { get; init; }
|
||||
public string? VectorString { get; init; }
|
||||
|
||||
/// <summary>Gets the temporal score if available.</summary>
|
||||
public double? TemporalScore { get; init; }
|
||||
public decimal? TemporalScore { get; init; }
|
||||
|
||||
/// <summary>Gets the environmental score if available.</summary>
|
||||
public double? EnvironmentalScore { get; init; }
|
||||
public decimal? EnvironmentalScore { get; init; }
|
||||
|
||||
/// <summary>Gets when the score was published.</summary>
|
||||
public DateTimeOffset? PublishedTime { get; init; }
|
||||
|
||||
/// <summary>Gets when the score was modified.</summary>
|
||||
public DateTimeOffset? ModifiedTime { get; init; }
|
||||
|
||||
/// <summary>Gets the source of the CVSS data.</summary>
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EPSS (Exploit Prediction Scoring System) data.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-007
|
||||
/// </summary>
|
||||
public sealed record EpssData
|
||||
{
|
||||
/// <summary>Gets the EPSS probability (0.0-1.0).</summary>
|
||||
public required double Probability { get; init; }
|
||||
public decimal? Probability { get; init; }
|
||||
|
||||
/// <summary>Gets the EPSS percentile (0.0-1.0).</summary>
|
||||
public required double Percentile { get; init; }
|
||||
public decimal? Percentile { get; init; }
|
||||
|
||||
/// <summary>Gets when the score was assessed.</summary>
|
||||
public DateTimeOffset? AssessedOn { get; init; }
|
||||
/// <summary>Gets the date of the EPSS score.</summary>
|
||||
public DateTimeOffset? ScoreDate { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Security;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3;
|
||||
@@ -81,7 +82,7 @@ public static class VexStatusMapper
|
||||
AssessedElement = statement.ProductId,
|
||||
From = statement.VulnerabilityId,
|
||||
To = [statement.ProductId],
|
||||
RelationshipType = "affects",
|
||||
RelationshipType = Spdx3RelationshipType.Affects,
|
||||
VexVersion = "1.0.0",
|
||||
StatusNotes = statement.StatusNotes,
|
||||
ActionStatement = statement.ActionStatement,
|
||||
@@ -104,7 +105,7 @@ public static class VexStatusMapper
|
||||
AssessedElement = statement.ProductId,
|
||||
From = statement.VulnerabilityId,
|
||||
To = [statement.ProductId],
|
||||
RelationshipType = "doesNotAffect",
|
||||
RelationshipType = Spdx3RelationshipType.DoesNotAffect,
|
||||
VexVersion = "1.0.0",
|
||||
StatusNotes = statusNotes,
|
||||
JustificationType = MapJustification(statement.Justification),
|
||||
@@ -125,7 +126,7 @@ public static class VexStatusMapper
|
||||
AssessedElement = statement.ProductId,
|
||||
From = statement.VulnerabilityId,
|
||||
To = [statement.ProductId],
|
||||
RelationshipType = "fixedIn",
|
||||
RelationshipType = Spdx3RelationshipType.FixedIn,
|
||||
VexVersion = "1.0.0",
|
||||
StatusNotes = statement.StatusNotes,
|
||||
PublishedTime = statement.Timestamp,
|
||||
@@ -144,7 +145,7 @@ public static class VexStatusMapper
|
||||
AssessedElement = statement.ProductId,
|
||||
From = statement.VulnerabilityId,
|
||||
To = [statement.ProductId],
|
||||
RelationshipType = "underInvestigationFor",
|
||||
RelationshipType = Spdx3RelationshipType.UnderInvestigationFor,
|
||||
VexVersion = "1.0.0",
|
||||
StatusNotes = statement.StatusNotes,
|
||||
PublishedTime = statement.Timestamp,
|
||||
@@ -182,101 +183,3 @@ public static class VexStatusMapper
|
||||
return $"{statusNotes}. Impact: {impactStatement}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OpenVEX statement for mapping purposes.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-002
|
||||
/// </summary>
|
||||
public sealed record OpenVexStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the vulnerability ID (e.g., CVE-2026-1234).
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the product ID (PURL or SPDX ID).
|
||||
/// </summary>
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the VEX status.
|
||||
/// </summary>
|
||||
public required VexStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the statement timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the supplier ID.
|
||||
/// </summary>
|
||||
public string? Supplier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the justification (for not_affected).
|
||||
/// </summary>
|
||||
public VexJustification? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the status notes.
|
||||
/// </summary>
|
||||
public string? StatusNotes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the impact statement.
|
||||
/// </summary>
|
||||
public string? ImpactStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the action statement (for affected).
|
||||
/// </summary>
|
||||
public string? ActionStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the action statement deadline.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ActionStatementTime { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenVEX status values.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-002
|
||||
/// </summary>
|
||||
public enum VexStatus
|
||||
{
|
||||
/// <summary>Product is affected by the vulnerability.</summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>Product is not affected by the vulnerability.</summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>Vulnerability has been fixed in this version.</summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>Vulnerability impact is under investigation.</summary>
|
||||
UnderInvestigation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenVEX justification values for not_affected status.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-002
|
||||
/// </summary>
|
||||
public enum VexJustification
|
||||
{
|
||||
/// <summary>Component is not present in the product.</summary>
|
||||
ComponentNotPresent,
|
||||
|
||||
/// <summary>Vulnerable code is not present.</summary>
|
||||
VulnerableCodeNotPresent,
|
||||
|
||||
/// <summary>Vulnerable code cannot be controlled by adversary.</summary>
|
||||
VulnerableCodeCannotBeControlledByAdversary,
|
||||
|
||||
/// <summary>Vulnerable code is not in execute path.</summary>
|
||||
VulnerableCodeNotInExecutePath,
|
||||
|
||||
/// <summary>Inline mitigations already exist.</summary>
|
||||
InlineMitigationsAlreadyExist
|
||||
}
|
||||
|
||||
@@ -156,9 +156,9 @@ public sealed class VexToSpdx3Mapper : IVexToSpdx3Mapper
|
||||
foreach (var statement in statements.Where(s => s.CvssV3 is not null))
|
||||
{
|
||||
var cvss = CvssMapper.MapToSpdx3(
|
||||
statement.CvssV3!,
|
||||
statement.VulnerabilityId,
|
||||
statement.ProductId,
|
||||
statement.CvssV3!,
|
||||
spdxIdPrefix);
|
||||
|
||||
yield return cvss;
|
||||
@@ -172,9 +172,9 @@ public sealed class VexToSpdx3Mapper : IVexToSpdx3Mapper
|
||||
foreach (var statement in statements.Where(s => s.Epss is not null))
|
||||
{
|
||||
var epss = CvssMapper.MapEpssToSpdx3(
|
||||
statement.Epss!,
|
||||
statement.VulnerabilityId,
|
||||
statement.ProductId,
|
||||
statement.Epss!,
|
||||
spdxIdPrefix);
|
||||
|
||||
yield return epss;
|
||||
|
||||
@@ -49,27 +49,27 @@ public sealed class VulnerabilityElementBuilder
|
||||
{
|
||||
_externalIdentifiers.Add(new Spdx3ExternalIdentifier
|
||||
{
|
||||
ExternalIdentifierType = "cve",
|
||||
ExternalIdentifierType = Spdx3ExternalIdentifierType.Cve,
|
||||
Identifier = vulnerabilityId,
|
||||
IdentifierLocator = ImmutableArray.Create($"https://nvd.nist.gov/vuln/detail/{vulnerabilityId}")
|
||||
Comment = $"https://nvd.nist.gov/vuln/detail/{vulnerabilityId}"
|
||||
});
|
||||
}
|
||||
else if (vulnerabilityId.StartsWith("GHSA-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_externalIdentifiers.Add(new Spdx3ExternalIdentifier
|
||||
{
|
||||
ExternalIdentifierType = "ghsa",
|
||||
ExternalIdentifierType = Spdx3ExternalIdentifierType.SecurityOther,
|
||||
Identifier = vulnerabilityId,
|
||||
IdentifierLocator = ImmutableArray.Create($"https://github.com/advisories/{vulnerabilityId}")
|
||||
Comment = $"GitHub Security Advisory: https://github.com/advisories/{vulnerabilityId}"
|
||||
});
|
||||
}
|
||||
else if (vulnerabilityId.StartsWith("OSV-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_externalIdentifiers.Add(new Spdx3ExternalIdentifier
|
||||
{
|
||||
ExternalIdentifierType = "osv",
|
||||
ExternalIdentifierType = Spdx3ExternalIdentifierType.SecurityOther,
|
||||
Identifier = vulnerabilityId,
|
||||
IdentifierLocator = ImmutableArray.Create($"https://osv.dev/vulnerability/{vulnerabilityId}")
|
||||
Comment = $"OSV Vulnerability: https://osv.dev/vulnerability/{vulnerabilityId}"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ public sealed class VulnerabilityElementBuilder
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
|
||||
_externalRefs.Add(new Spdx3ExternalRef
|
||||
{
|
||||
ExternalRefType = "securityAdvisory",
|
||||
ExternalRefType = Spdx3ExternalRefType.SecurityAdvisory,
|
||||
Locator = ImmutableArray.Create($"https://nvd.nist.gov/vuln/detail/{cveId}")
|
||||
});
|
||||
return this;
|
||||
@@ -135,7 +135,7 @@ public sealed class VulnerabilityElementBuilder
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
_externalRefs.Add(new Spdx3ExternalRef
|
||||
{
|
||||
ExternalRefType = "securityAdvisory",
|
||||
ExternalRefType = Spdx3ExternalRefType.SecurityAdvisory,
|
||||
Locator = ImmutableArray.Create($"https://osv.dev/vulnerability/{vulnerabilityId}")
|
||||
});
|
||||
return this;
|
||||
@@ -147,9 +147,8 @@ public sealed class VulnerabilityElementBuilder
|
||||
/// <param name="refType">The reference type.</param>
|
||||
/// <param name="locator">The reference URL.</param>
|
||||
/// <returns>This builder for fluent chaining.</returns>
|
||||
public VulnerabilityElementBuilder WithExternalRef(string refType, string locator)
|
||||
public VulnerabilityElementBuilder WithExternalRef(Spdx3ExternalRefType refType, string locator)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(refType);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(locator);
|
||||
_externalRefs.Add(new Spdx3ExternalRef
|
||||
{
|
||||
@@ -213,57 +212,3 @@ public sealed class VulnerabilityElementBuilder
|
||||
return $"{_spdxIdPrefix.TrimEnd('/')}/vulnerability/{shortHash}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SPDX 3.0.1 External Identifier.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-004
|
||||
/// </summary>
|
||||
public sealed record Spdx3ExternalIdentifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the external identifier type (e.g., "cve", "ghsa", "osv").
|
||||
/// </summary>
|
||||
public required string ExternalIdentifierType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier value.
|
||||
/// </summary>
|
||||
public required string Identifier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the locator URLs for the identifier.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> IdentifierLocator { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets issuing authority of the identifier.
|
||||
/// </summary>
|
||||
public string? IssuingAuthority { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SPDX 3.0.1 External Reference.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-004
|
||||
/// </summary>
|
||||
public sealed record Spdx3ExternalRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the external reference type (e.g., "securityAdvisory").
|
||||
/// </summary>
|
||||
public required string ExternalRefType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the locator URLs.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Locator { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content type of the referenced resource.
|
||||
/// </summary>
|
||||
public string? ContentType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a comment about the reference.
|
||||
/// </summary>
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecurityProfileIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260107_004_004_BE_spdx3_security_profile
|
||||
// Task: SP-013 - Integration tests for SPDX 3.0.1 Security profile
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Security;
|
||||
using StellaOps.Vex.OpenVex;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for SPDX 3.0.1 Security profile end-to-end flows.
|
||||
/// These tests verify the complete VEX-to-SPDX 3.0.1 pipeline.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class SecurityProfileIntegrationTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTimestamp =
|
||||
new(2026, 1, 9, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task EndToEnd_VexConsensusToSpdx3_ProducesValidSecurityProfile()
|
||||
{
|
||||
// Arrange: Create a realistic VEX consensus result
|
||||
var vexConsensus = new VexConsensus
|
||||
{
|
||||
ConsensusId = "consensus-001",
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.21",
|
||||
CveId = "CVE-2021-23337",
|
||||
FinalStatus = VexStatus.Affected,
|
||||
FinalJustification = null,
|
||||
ConfidenceScore = 0.95,
|
||||
StatementCount = 3,
|
||||
Timestamp = FixedTimestamp,
|
||||
ActionStatement = "Upgrade to lodash@4.17.22 or later",
|
||||
ActionStatementTime = FixedTimestamp.AddDays(30),
|
||||
StatusNotes = "Prototype pollution vulnerability in defaultsDeep function"
|
||||
};
|
||||
|
||||
var timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
var mapper = new VexToSpdx3Mapper(timeProvider);
|
||||
|
||||
// Act: Map VEX consensus to SPDX 3.0.1
|
||||
var securityElements = await mapper.MapConsensusAsync(
|
||||
vexConsensus,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert: Verify all elements are created correctly
|
||||
securityElements.Should().NotBeNull();
|
||||
securityElements.Vulnerability.Should().NotBeNull();
|
||||
securityElements.Assessment.Should().NotBeNull();
|
||||
|
||||
var vuln = securityElements.Vulnerability;
|
||||
vuln.ExternalIdentifiers.Should().Contain(id =>
|
||||
id.Identifier == "CVE-2021-23337" && id.IdentifierType == "cve");
|
||||
|
||||
var assessment = securityElements.Assessment as Spdx3VexAffectedVulnAssessmentRelationship;
|
||||
assessment.Should().NotBeNull();
|
||||
assessment!.StatusNotes.Should().Contain("Prototype pollution");
|
||||
assessment.ActionStatement.Should().Be("Upgrade to lodash@4.17.22 or later");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CombinedSbomVex_GeneratesValidDocument()
|
||||
{
|
||||
// Arrange: Create Software profile SBOM
|
||||
var sbomDocument = new Spdx3Document
|
||||
{
|
||||
SpdxId = "urn:stellaops:sbom:myapp-001",
|
||||
Name = "MyApp SBOM with VEX",
|
||||
Namespaces = ImmutableArray.Create("https://stellaops.org/spdx/"),
|
||||
ProfileConformance = ImmutableArray.Create(Spdx3Profile.Software),
|
||||
Elements = ImmutableArray.Create<Spdx3Element>(
|
||||
new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:stellaops:pkg:lodash-4.17.21",
|
||||
Name = "lodash",
|
||||
PackageVersion = "4.17.21",
|
||||
PackageUrl = "pkg:npm/lodash@4.17.21"
|
||||
},
|
||||
new Spdx3Package
|
||||
{
|
||||
SpdxId = "urn:stellaops:pkg:express-4.18.2",
|
||||
Name = "express",
|
||||
PackageVersion = "4.18.2",
|
||||
PackageUrl = "pkg:npm/express@4.18.2"
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
// Arrange: Create VEX statements
|
||||
var vexStatements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
StatementId = "stmt-001",
|
||||
Vulnerability = new VulnerabilityReference { Name = "CVE-2021-23337" },
|
||||
Products = ImmutableArray.Create(new ProductReference { Id = "pkg:npm/lodash@4.17.21" }),
|
||||
Status = VexStatus.Affected,
|
||||
ActionStatement = "Upgrade to 4.17.22",
|
||||
Timestamp = FixedTimestamp
|
||||
},
|
||||
new OpenVexStatement
|
||||
{
|
||||
StatementId = "stmt-002",
|
||||
Vulnerability = new VulnerabilityReference { Name = "CVE-2024-1234" },
|
||||
Products = ImmutableArray.Create(new ProductReference { Id = "pkg:npm/express@4.18.2" }),
|
||||
Status = VexStatus.NotAffected,
|
||||
Justification = VexJustification.VulnerableCodeNotPresent,
|
||||
ImpactStatement = "The vulnerable code path is not used",
|
||||
Timestamp = FixedTimestamp
|
||||
}
|
||||
};
|
||||
|
||||
var timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
var mapper = new VexToSpdx3Mapper(timeProvider);
|
||||
|
||||
// Act: Build combined document
|
||||
var builder = new CombinedSbomVexBuilder(mapper);
|
||||
var combinedDoc = await builder
|
||||
.WithSoftwareDocument(sbomDocument)
|
||||
.WithVexStatements(vexStatements)
|
||||
.BuildAsync(CancellationToken.None);
|
||||
|
||||
// Assert: Combined document has both profiles
|
||||
combinedDoc.Should().NotBeNull();
|
||||
combinedDoc.ProfileConformance.Should().Contain(Spdx3Profile.Software);
|
||||
combinedDoc.ProfileConformance.Should().Contain(Spdx3Profile.Security);
|
||||
|
||||
// Assert: Contains packages, vulnerabilities, and assessments
|
||||
combinedDoc.Elements.OfType<Spdx3Package>().Should().HaveCount(2);
|
||||
combinedDoc.Elements.OfType<Spdx3Vulnerability>().Should().HaveCount(2);
|
||||
combinedDoc.Elements.OfType<Spdx3VulnAssessmentRelationship>().Should().HaveCount(2);
|
||||
|
||||
// Assert: Affected assessment has action
|
||||
var affectedAssessment = combinedDoc.Elements
|
||||
.OfType<Spdx3VexAffectedVulnAssessmentRelationship>()
|
||||
.FirstOrDefault();
|
||||
affectedAssessment.Should().NotBeNull();
|
||||
affectedAssessment!.ActionStatement.Should().Be("Upgrade to 4.17.22");
|
||||
|
||||
// Assert: Not affected assessment has justification
|
||||
var notAffectedAssessment = combinedDoc.Elements
|
||||
.OfType<Spdx3VexNotAffectedVulnAssessmentRelationship>()
|
||||
.FirstOrDefault();
|
||||
notAffectedAssessment.Should().NotBeNull();
|
||||
notAffectedAssessment!.Justification.Should().Be(Spdx3VexJustification.VulnerableCodeNotPresent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseExternalSecurityProfile_ValidDocument_ExtractsAllElements()
|
||||
{
|
||||
// Arrange: External SPDX 3.0.1 Security profile JSON
|
||||
var externalJson = """
|
||||
{
|
||||
"@context": "https://spdx.org/rdf/3.0.1/terms/",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "security_Vulnerability",
|
||||
"spdxId": "urn:external:vuln:CVE-2024-5678",
|
||||
"name": "CVE-2024-5678",
|
||||
"summary": "Remote code execution in XML parser",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"identifierType": "cve",
|
||||
"identifier": "CVE-2024-5678"
|
||||
}
|
||||
],
|
||||
"security_publishedTime": "2024-03-15T10:00:00Z",
|
||||
"security_modifiedTime": "2024-03-20T14:30:00Z"
|
||||
},
|
||||
{
|
||||
"@type": "security_VexAffectedVulnAssessmentRelationship",
|
||||
"spdxId": "urn:external:vex:assessment-001",
|
||||
"from": "urn:external:vuln:CVE-2024-5678",
|
||||
"to": ["urn:external:pkg:xml-parser-1.0.0"],
|
||||
"relationshipType": "affects",
|
||||
"security_assessedElement": "urn:external:pkg:xml-parser-1.0.0",
|
||||
"security_publishedTime": "2024-03-16T09:00:00Z",
|
||||
"security_statusNotes": "Affected when parsing untrusted XML",
|
||||
"security_actionStatement": "Upgrade to xml-parser@2.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act: Parse the external document
|
||||
var parser = new Spdx3Parser();
|
||||
var parseResult = parser.Parse(externalJson);
|
||||
|
||||
// Assert: Document parses successfully
|
||||
parseResult.IsSuccess.Should().BeTrue();
|
||||
parseResult.Document.Should().NotBeNull();
|
||||
|
||||
// Assert: Vulnerability element parsed
|
||||
var vulnerabilities = parseResult.Document!.Elements
|
||||
.OfType<Spdx3Vulnerability>()
|
||||
.ToList();
|
||||
vulnerabilities.Should().HaveCount(1);
|
||||
|
||||
var vuln = vulnerabilities[0];
|
||||
vuln.SpdxId.Should().Be("urn:external:vuln:CVE-2024-5678");
|
||||
vuln.Name.Should().Be("CVE-2024-5678");
|
||||
vuln.Summary.Should().Contain("Remote code execution");
|
||||
|
||||
// Assert: VEX assessment parsed
|
||||
var assessments = parseResult.Document.Elements
|
||||
.OfType<Spdx3VexAffectedVulnAssessmentRelationship>()
|
||||
.ToList();
|
||||
assessments.Should().HaveCount(1);
|
||||
|
||||
var assessment = assessments[0];
|
||||
assessment.From.Should().Be("urn:external:vuln:CVE-2024-5678");
|
||||
assessment.StatusNotes.Should().Contain("untrusted XML");
|
||||
assessment.ActionStatement.Should().Be("Upgrade to xml-parser@2.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllVexStatuses_MapCorrectly()
|
||||
{
|
||||
// Arrange: Create VEX statements for each status
|
||||
var timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
var mapper = new VexToSpdx3Mapper(timeProvider);
|
||||
|
||||
var statuses = new[]
|
||||
{
|
||||
(VexStatus.Affected, typeof(Spdx3VexAffectedVulnAssessmentRelationship)),
|
||||
(VexStatus.NotAffected, typeof(Spdx3VexNotAffectedVulnAssessmentRelationship)),
|
||||
(VexStatus.Fixed, typeof(Spdx3VexFixedVulnAssessmentRelationship)),
|
||||
(VexStatus.UnderInvestigation, typeof(Spdx3VexUnderInvestigationVulnAssessmentRelationship))
|
||||
};
|
||||
|
||||
foreach (var (status, expectedType) in statuses)
|
||||
{
|
||||
var statement = new OpenVexStatement
|
||||
{
|
||||
StatementId = $"stmt-{status}",
|
||||
Vulnerability = new VulnerabilityReference { Name = $"CVE-{status}" },
|
||||
Products = ImmutableArray.Create(new ProductReference { Id = "pkg:test/pkg@1.0.0" }),
|
||||
Status = status,
|
||||
Justification = status == VexStatus.NotAffected
|
||||
? VexJustification.VulnerableCodeNotPresent
|
||||
: null,
|
||||
Timestamp = FixedTimestamp
|
||||
};
|
||||
|
||||
// Act
|
||||
var elements = await mapper.MapStatementAsync(statement, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
elements.Assessment.Should().NotBeNull();
|
||||
elements.Assessment.GetType().Should().Be(expectedType,
|
||||
$"Status {status} should map to {expectedType.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CvssAndEpssData_IncludedInDocument()
|
||||
{
|
||||
// Arrange
|
||||
var timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
var cvssMapper = new CvssMapper();
|
||||
|
||||
var cvssData = new CvssV3Data
|
||||
{
|
||||
VectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
BaseScore = 9.8,
|
||||
BaseSeverity = "CRITICAL"
|
||||
};
|
||||
|
||||
var epssData = new EpssData
|
||||
{
|
||||
Score = 0.97,
|
||||
Percentile = 99.5,
|
||||
AssessmentDate = FixedTimestamp
|
||||
};
|
||||
|
||||
// Act
|
||||
var cvssRelationship = cvssMapper.MapCvssToSpdx3(
|
||||
"urn:test:vuln:CVE-2024-9999",
|
||||
"urn:test:pkg:target",
|
||||
cvssData);
|
||||
|
||||
var epssRelationship = cvssMapper.MapEpssToSpdx3(
|
||||
"urn:test:vuln:CVE-2024-9999",
|
||||
"urn:test:pkg:target",
|
||||
epssData);
|
||||
|
||||
// Assert: CVSS relationship
|
||||
cvssRelationship.Should().NotBeNull();
|
||||
cvssRelationship.Score.Should().Be(9.8);
|
||||
cvssRelationship.Severity.Should().Be(Spdx3CvssSeverity.Critical);
|
||||
cvssRelationship.VectorString.Should().Be("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H");
|
||||
|
||||
// Assert: EPSS relationship
|
||||
epssRelationship.Should().NotBeNull();
|
||||
epssRelationship.Probability.Should().Be(0.97);
|
||||
epssRelationship.Percentile.Should().Be(99.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_SerializeAndParse_PreservesAllData()
|
||||
{
|
||||
// Arrange: Create a complete Security profile document
|
||||
var originalDoc = new Spdx3Document
|
||||
{
|
||||
SpdxId = "urn:stellaops:security:roundtrip-001",
|
||||
Name = "Security Profile Round-Trip Test",
|
||||
Namespaces = ImmutableArray.Create("https://stellaops.org/spdx/"),
|
||||
ProfileConformance = ImmutableArray.Create(Spdx3Profile.Security),
|
||||
Elements = ImmutableArray.Create<Spdx3Element>(
|
||||
new Spdx3Vulnerability
|
||||
{
|
||||
SpdxId = "urn:stellaops:vuln:CVE-2024-RT",
|
||||
Name = "CVE-2024-RT",
|
||||
Summary = "Round-trip test vulnerability",
|
||||
PublishedTime = FixedTimestamp.AddDays(-30),
|
||||
ModifiedTime = FixedTimestamp,
|
||||
ExternalIdentifiers = ImmutableArray.Create(new Spdx3ExternalIdentifier
|
||||
{
|
||||
IdentifierType = "cve",
|
||||
Identifier = "CVE-2024-RT"
|
||||
})
|
||||
},
|
||||
new Spdx3VexAffectedVulnAssessmentRelationship
|
||||
{
|
||||
SpdxId = "urn:stellaops:vex:rt-assessment-001",
|
||||
From = "urn:stellaops:vuln:CVE-2024-RT",
|
||||
To = ImmutableArray.Create("urn:stellaops:pkg:rt-pkg"),
|
||||
RelationshipType = Spdx3RelationshipType.Affects,
|
||||
AssessedElement = "urn:stellaops:pkg:rt-pkg",
|
||||
PublishedTime = FixedTimestamp,
|
||||
StatusNotes = "Affected in all versions",
|
||||
ActionStatement = "No patch available yet",
|
||||
ActionStatementTime = FixedTimestamp.AddDays(14)
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
// Act: Serialize and parse
|
||||
var serializer = new Spdx3JsonSerializer();
|
||||
var json = serializer.Serialize(originalDoc);
|
||||
|
||||
var parser = new Spdx3Parser();
|
||||
var parseResult = parser.Parse(json);
|
||||
|
||||
// Assert: Parsing succeeded
|
||||
parseResult.IsSuccess.Should().BeTrue();
|
||||
var parsedDoc = parseResult.Document;
|
||||
|
||||
// Assert: All data preserved
|
||||
parsedDoc.Should().NotBeNull();
|
||||
parsedDoc!.SpdxId.Should().Be(originalDoc.SpdxId);
|
||||
parsedDoc.Name.Should().Be(originalDoc.Name);
|
||||
parsedDoc.ProfileConformance.Should().BeEquivalentTo(originalDoc.ProfileConformance);
|
||||
|
||||
// Assert: Vulnerability preserved
|
||||
var parsedVuln = parsedDoc.Elements.OfType<Spdx3Vulnerability>().FirstOrDefault();
|
||||
parsedVuln.Should().NotBeNull();
|
||||
parsedVuln!.Name.Should().Be("CVE-2024-RT");
|
||||
parsedVuln.Summary.Should().Be("Round-trip test vulnerability");
|
||||
|
||||
// Assert: Assessment preserved
|
||||
var parsedAssessment = parsedDoc.Elements
|
||||
.OfType<Spdx3VexAffectedVulnAssessmentRelationship>()
|
||||
.FirstOrDefault();
|
||||
parsedAssessment.Should().NotBeNull();
|
||||
parsedAssessment!.StatusNotes.Should().Be("Affected in all versions");
|
||||
parsedAssessment.ActionStatement.Should().Be("No patch available yet");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple JSON serializer for SPDX 3.0.1 documents (test implementation).
|
||||
/// </summary>
|
||||
file sealed class Spdx3JsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public string Serialize(Spdx3Document document)
|
||||
{
|
||||
return JsonSerializer.Serialize(document, Options);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user