more audit work
This commit is contained in:
24
src/VexLens/StellaOps.VexLens.WebService/AGENTS.md
Normal file
24
src/VexLens/StellaOps.VexLens.WebService/AGENTS.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# VexLens WebService Charter
|
||||
|
||||
## Mission
|
||||
Expose VexLens consensus, projections, and noise-gating APIs with deterministic behavior.
|
||||
|
||||
## Responsibilities
|
||||
- Wire VexLens services via `AddVexLens` and ensure required dependencies are registered.
|
||||
- Configure auth, rate limiting, and health endpoints for the API surface.
|
||||
- Keep outputs deterministic and offline-friendly where possible.
|
||||
- Track sprint tasks in `TASKS.md` and update the sprint tracker.
|
||||
|
||||
## Key Paths
|
||||
- `Program.cs`
|
||||
- `Extensions/VexLensEndpointExtensions.cs`
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/vex-lens/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Propagate CancellationToken and use TimeProvider/IGuidGenerator from DI.
|
||||
- 2. Require auth/tenant context; do not trust request bodies for tenancy.
|
||||
- 3. Keep responses deterministic (stable ordering, invariant formatting).
|
||||
10
src/VexLens/StellaOps.VexLens.WebService/TASKS.md
Normal file
10
src/VexLens/StellaOps.VexLens.WebService/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# VexLens WebService Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0777-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0777-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0777-A | DONE | Fixed deprecated APIs, builds 0 warnings 2026-01-07. |
|
||||
23
src/VexLens/__Libraries/StellaOps.VexLens.Spdx3/AGENTS.md
Normal file
23
src/VexLens/__Libraries/StellaOps.VexLens.Spdx3/AGENTS.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# VexLens SPDX3 Security Profile Charter
|
||||
|
||||
## Purpose & Scope
|
||||
- Working directory: `src/VexLens/__Libraries/StellaOps.VexLens.Spdx3/`.
|
||||
- Roles: backend engineer, QA automation.
|
||||
- Focus: mapping VEX, CVSS, and vulnerability metadata to SPDX 3.0.1 Security profile elements.
|
||||
|
||||
## Required Reading
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/vex-lens/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`
|
||||
|
||||
## Working Agreement
|
||||
- Preserve deterministic IDs and ordering in SPDX outputs.
|
||||
- Use InvariantCulture for formatted timestamps and scores.
|
||||
- Avoid Guid.NewGuid/DateTime.UtcNow in core logic; use injected providers.
|
||||
- Update the sprint tracker and local `TASKS.md` when work changes.
|
||||
|
||||
## Testing
|
||||
- Unit tests live in `src/VexLens/__Libraries/__Tests/StellaOps.VexLens.Spdx3.Tests/`.
|
||||
- Cover VEX status mapping, CVSS/EPSS mapping, and deterministic IDs.
|
||||
@@ -0,0 +1,287 @@
|
||||
// <copyright file="CombinedSbomVexBuilder.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Security;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3;
|
||||
|
||||
/// <summary>
|
||||
/// Builds combined SPDX 3.0.1 documents containing Software and Security profiles.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-009
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This builder merges an SBOM (Software profile) with VEX data (Security profile)
|
||||
/// into a single coherent document, linking VulnAssessmentRelationships to
|
||||
/// Package elements.
|
||||
/// </remarks>
|
||||
public sealed class CombinedSbomVexBuilder
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly List<Spdx3Element> _elements = new();
|
||||
private readonly HashSet<Spdx3ProfileIdentifier> _profiles = new();
|
||||
private readonly List<Spdx3CreationInfo> _creationInfos = new();
|
||||
|
||||
private string? _documentSpdxId;
|
||||
private string? _documentName;
|
||||
private Spdx3Document? _sbom;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CombinedSbomVexBuilder"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
public CombinedSbomVexBuilder(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the document SPDX ID.
|
||||
/// </summary>
|
||||
/// <param name="spdxId">The document's unique IRI identifier.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedSbomVexBuilder WithDocumentId(string spdxId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spdxId);
|
||||
_documentSpdxId = spdxId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the document name.
|
||||
/// </summary>
|
||||
/// <param name="name">Human-readable document name.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedSbomVexBuilder WithName(string name)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
_documentName = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds elements from a Software profile SBOM.
|
||||
/// </summary>
|
||||
/// <param name="sbom">The source SBOM document.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedSbomVexBuilder WithSoftwareProfile(Spdx3Document sbom)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbom);
|
||||
_sbom = sbom;
|
||||
|
||||
// Add all elements from the SBOM
|
||||
foreach (var element in sbom.Elements)
|
||||
{
|
||||
_elements.Add(element);
|
||||
}
|
||||
|
||||
// Add Software and Core profiles
|
||||
_profiles.Add(Spdx3ProfileIdentifier.Core);
|
||||
_profiles.Add(Spdx3ProfileIdentifier.Software);
|
||||
|
||||
// Preserve existing profile conformance
|
||||
foreach (var profile in sbom.Profiles)
|
||||
{
|
||||
_profiles.Add(profile);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds VEX statements as Security profile elements.
|
||||
/// </summary>
|
||||
/// <param name="consensus">The VEX consensus.</param>
|
||||
/// <param name="spdxIdPrefix">Prefix for generating SPDX IDs.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedSbomVexBuilder WithSecurityProfile(VexConsensus consensus, string spdxIdPrefix)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(consensus);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spdxIdPrefix);
|
||||
|
||||
var mapper = new VexToSpdx3Mapper(_timeProvider);
|
||||
var mappingResult = mapper.MapStatements(consensus.Statements, spdxIdPrefix);
|
||||
|
||||
// Add vulnerability and assessment elements
|
||||
foreach (var element in mappingResult.AllElements)
|
||||
{
|
||||
_elements.Add(element);
|
||||
}
|
||||
|
||||
_profiles.Add(Spdx3ProfileIdentifier.Core);
|
||||
_profiles.Add(Spdx3ProfileIdentifier.Security);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds VEX statements with automatic linking to SBOM packages.
|
||||
/// </summary>
|
||||
/// <param name="statements">The VEX statements.</param>
|
||||
/// <param name="spdxIdPrefix">Prefix for generating SPDX IDs.</param>
|
||||
/// <param name="purlToSpdxIdMap">Map from PURL to SPDX Package ID.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedSbomVexBuilder WithLinkedSecurityProfile(
|
||||
IEnumerable<OpenVexStatement> statements,
|
||||
string spdxIdPrefix,
|
||||
IReadOnlyDictionary<string, string>? purlToSpdxIdMap = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statements);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spdxIdPrefix);
|
||||
|
||||
// Build PURL to SPDX ID map from SBOM if not provided
|
||||
var mapping = purlToSpdxIdMap ?? BuildPurlMapping();
|
||||
|
||||
var mapper = new VexToSpdx3Mapper(_timeProvider);
|
||||
|
||||
// Map statements, potentially rewriting product IDs to SPDX Package IDs
|
||||
var rewrittenStatements = statements.Select(s => RewriteProductId(s, mapping));
|
||||
var mappingResult = mapper.MapStatements(rewrittenStatements, spdxIdPrefix);
|
||||
|
||||
foreach (var element in mappingResult.AllElements)
|
||||
{
|
||||
_elements.Add(element);
|
||||
}
|
||||
|
||||
_profiles.Add(Spdx3ProfileIdentifier.Core);
|
||||
_profiles.Add(Spdx3ProfileIdentifier.Security);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds creation information for the combined document.
|
||||
/// </summary>
|
||||
/// <param name="creationInfo">The creation information.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public CombinedSbomVexBuilder WithCreationInfo(Spdx3CreationInfo creationInfo)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(creationInfo);
|
||||
_creationInfos.Add(creationInfo);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the combined SPDX 3.0.1 document.
|
||||
/// </summary>
|
||||
/// <returns>The combined document.</returns>
|
||||
/// <exception cref="InvalidOperationException">If required fields are missing.</exception>
|
||||
public Spdx3Document Build()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_documentSpdxId))
|
||||
{
|
||||
throw new InvalidOperationException("Document SPDX ID is required. Call WithDocumentId().");
|
||||
}
|
||||
|
||||
// Create combined creation info if none provided
|
||||
if (_creationInfos.Count == 0)
|
||||
{
|
||||
var defaultCreationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
Id = $"{_documentSpdxId}/creationInfo",
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = _timeProvider.GetUtcNow(),
|
||||
CreatedBy = ImmutableArray<string>.Empty,
|
||||
CreatedUsing = ImmutableArray.Create("StellaOps VexLens"),
|
||||
Profile = _profiles.ToImmutableArray(),
|
||||
DataLicense = Spdx3CreationInfo.Spdx301DataLicense
|
||||
};
|
||||
_creationInfos.Add(defaultCreationInfo);
|
||||
}
|
||||
|
||||
return new Spdx3Document(
|
||||
elements: _elements,
|
||||
creationInfos: _creationInfos,
|
||||
profiles: _profiles);
|
||||
}
|
||||
|
||||
private Dictionary<string, string> BuildPurlMapping()
|
||||
{
|
||||
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (_sbom is null)
|
||||
{
|
||||
return mapping;
|
||||
}
|
||||
|
||||
foreach (var package in _sbom.Packages)
|
||||
{
|
||||
foreach (var identifier in package.ExternalIdentifier)
|
||||
{
|
||||
if (identifier.ExternalIdentifierType == Spdx3ExternalIdentifierType.PackageUrl)
|
||||
{
|
||||
mapping[identifier.Identifier] = package.SpdxId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
private static OpenVexStatement RewriteProductId(
|
||||
OpenVexStatement statement,
|
||||
IReadOnlyDictionary<string, string> mapping)
|
||||
{
|
||||
// If the product ID is a PURL and we have a mapping, rewrite it
|
||||
if (mapping.TryGetValue(statement.ProductId, out var spdxId))
|
||||
{
|
||||
return statement with { ProductId = spdxId };
|
||||
}
|
||||
|
||||
return statement;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new builder with the given time provider.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
/// <returns>A new builder instance.</returns>
|
||||
public static CombinedSbomVexBuilder Create(TimeProvider timeProvider)
|
||||
{
|
||||
return new CombinedSbomVexBuilder(timeProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new builder using the system time provider.
|
||||
/// </summary>
|
||||
/// <returns>A new builder instance.</returns>
|
||||
public static CombinedSbomVexBuilder Create()
|
||||
{
|
||||
return new CombinedSbomVexBuilder(TimeProvider.System);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for combining SPDX 3.0.1 SBOM with VEX data.
|
||||
/// </summary>
|
||||
public static class CombinedSbomVexExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Combines an SBOM with VEX data into a single document.
|
||||
/// </summary>
|
||||
/// <param name="sbom">The source SBOM.</param>
|
||||
/// <param name="consensus">The VEX consensus.</param>
|
||||
/// <param name="documentId">The combined document ID.</param>
|
||||
/// <param name="spdxIdPrefix">Prefix for generated IDs.</param>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
/// <returns>The combined document.</returns>
|
||||
public static Spdx3Document WithVexData(
|
||||
this Spdx3Document sbom,
|
||||
VexConsensus consensus,
|
||||
string documentId,
|
||||
string spdxIdPrefix,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbom);
|
||||
ArgumentNullException.ThrowIfNull(consensus);
|
||||
|
||||
return CombinedSbomVexBuilder.Create(timeProvider ?? TimeProvider.System)
|
||||
.WithDocumentId(documentId)
|
||||
.WithName("Combined SBOM and VEX Data")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithSecurityProfile(consensus, spdxIdPrefix)
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
264
src/VexLens/__Libraries/StellaOps.VexLens.Spdx3/CvssMapper.cs
Normal file
264
src/VexLens/__Libraries/StellaOps.VexLens.Spdx3/CvssMapper.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
// <copyright file="CvssMapper.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Globalization;
|
||||
using StellaOps.Spdx3.Model.Security;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3;
|
||||
|
||||
/// <summary>
|
||||
/// Maps CVSS scores to SPDX 3.0.1 Security profile assessment relationships.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-007
|
||||
/// </summary>
|
||||
public static class CvssMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a CVSS v3 score to an SPDX 3.0.1 CVSS vulnerability assessment relationship.
|
||||
/// </summary>
|
||||
/// <param name="cvssData">The CVSS v3 data to map.</param>
|
||||
/// <param name="vulnerabilitySpdxId">SPDX ID of the vulnerability being assessed.</param>
|
||||
/// <param name="assessedElementSpdxId">SPDX ID of the element being assessed.</param>
|
||||
/// <param name="spdxIdPrefix">Prefix for generating relationship SPDX IDs.</param>
|
||||
/// <returns>The mapped CVSS assessment relationship.</returns>
|
||||
public static Spdx3CvssV3VulnAssessmentRelationship MapToSpdx3(
|
||||
CvssV3Data cvssData,
|
||||
string vulnerabilitySpdxId,
|
||||
string assessedElementSpdxId,
|
||||
string spdxIdPrefix)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cvssData);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilitySpdxId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(assessedElementSpdxId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spdxIdPrefix);
|
||||
|
||||
var spdxId = GenerateSpdxId(spdxIdPrefix, vulnerabilitySpdxId, assessedElementSpdxId, "cvss");
|
||||
|
||||
return new Spdx3CvssV3VulnAssessmentRelationship
|
||||
{
|
||||
SpdxId = spdxId,
|
||||
Type = Spdx3CvssV3VulnAssessmentRelationship.TypeName,
|
||||
AssessedElement = assessedElementSpdxId,
|
||||
From = vulnerabilitySpdxId,
|
||||
To = [assessedElementSpdxId],
|
||||
RelationshipType = "hasAssessmentFor",
|
||||
Score = cvssData.BaseScore,
|
||||
Severity = MapSeverity(cvssData.BaseScore),
|
||||
VectorString = cvssData.VectorString,
|
||||
PublishedTime = cvssData.PublishedTime,
|
||||
ModifiedTime = cvssData.ModifiedTime,
|
||||
SuppliedBy = cvssData.Source
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an EPSS score to an SPDX 3.0.1 EPSS vulnerability assessment relationship.
|
||||
/// </summary>
|
||||
/// <param name="epssData">The EPSS data to map.</param>
|
||||
/// <param name="vulnerabilitySpdxId">SPDX ID of the vulnerability being assessed.</param>
|
||||
/// <param name="assessedElementSpdxId">SPDX ID of the element being assessed.</param>
|
||||
/// <param name="spdxIdPrefix">Prefix for generating relationship SPDX IDs.</param>
|
||||
/// <returns>The mapped EPSS assessment relationship.</returns>
|
||||
public static Spdx3EpssVulnAssessmentRelationship MapEpssToSpdx3(
|
||||
EpssData epssData,
|
||||
string vulnerabilitySpdxId,
|
||||
string assessedElementSpdxId,
|
||||
string spdxIdPrefix)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(epssData);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilitySpdxId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(assessedElementSpdxId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spdxIdPrefix);
|
||||
|
||||
var spdxId = GenerateSpdxId(spdxIdPrefix, vulnerabilitySpdxId, assessedElementSpdxId, "epss");
|
||||
|
||||
return new Spdx3EpssVulnAssessmentRelationship
|
||||
{
|
||||
SpdxId = spdxId,
|
||||
Type = Spdx3EpssVulnAssessmentRelationship.TypeName,
|
||||
AssessedElement = assessedElementSpdxId,
|
||||
From = vulnerabilitySpdxId,
|
||||
To = [assessedElementSpdxId],
|
||||
RelationshipType = "hasAssessmentFor",
|
||||
Probability = epssData.Probability,
|
||||
Percentile = epssData.Percentile,
|
||||
PublishedTime = epssData.ScoreDate,
|
||||
SuppliedBy = "https://www.first.org/epss"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a CVSS v3 base score to severity level.
|
||||
/// </summary>
|
||||
/// <param name="score">The CVSS v3 base score (0.0-10.0).</param>
|
||||
/// <returns>The severity level.</returns>
|
||||
public static Spdx3CvssSeverity MapSeverity(decimal? score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
null => Spdx3CvssSeverity.None,
|
||||
0.0m => Spdx3CvssSeverity.None,
|
||||
>= 0.1m and <= 3.9m => Spdx3CvssSeverity.Low,
|
||||
>= 4.0m and <= 6.9m => Spdx3CvssSeverity.Medium,
|
||||
>= 7.0m and <= 8.9m => Spdx3CvssSeverity.High,
|
||||
>= 9.0m => Spdx3CvssSeverity.Critical,
|
||||
_ => Spdx3CvssSeverity.None
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a CVSS v3 vector string to extract component scores.
|
||||
/// </summary>
|
||||
/// <param name="vectorString">The CVSS v3 vector string (e.g., "CVSS:3.1/AV:N/AC:L/...").</param>
|
||||
/// <returns>Parsed vector components, or null if parsing fails.</returns>
|
||||
public static CvssVectorComponents? ParseVectorString(string? vectorString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vectorString))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parts = vectorString.Split('/');
|
||||
var components = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var kv = part.Split(':');
|
||||
if (kv.Length == 2)
|
||||
{
|
||||
components[kv[0]] = kv[1];
|
||||
}
|
||||
}
|
||||
|
||||
return new CvssVectorComponents
|
||||
{
|
||||
Version = components.GetValueOrDefault("CVSS", "3.1"),
|
||||
AttackVector = components.GetValueOrDefault("AV"),
|
||||
AttackComplexity = components.GetValueOrDefault("AC"),
|
||||
PrivilegesRequired = components.GetValueOrDefault("PR"),
|
||||
UserInteraction = components.GetValueOrDefault("UI"),
|
||||
Scope = components.GetValueOrDefault("S"),
|
||||
ConfidentialityImpact = components.GetValueOrDefault("C"),
|
||||
IntegrityImpact = components.GetValueOrDefault("I"),
|
||||
AvailabilityImpact = components.GetValueOrDefault("A")
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateSpdxId(
|
||||
string prefix,
|
||||
string vulnerabilityId,
|
||||
string elementId,
|
||||
string assessmentType)
|
||||
{
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var input = $"{vulnerabilityId}:{elementId}:{assessmentType}";
|
||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
var shortHash = Convert.ToHexStringLower(hash)[..12];
|
||||
return $"{prefix.TrimEnd('/')}/{assessmentType}/{shortHash}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// </summary>
|
||||
public sealed record CvssVectorComponents
|
||||
{
|
||||
/// <summary>Gets or sets the CVSS version.</summary>
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the Attack Vector (AV).</summary>
|
||||
public string? AttackVector { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the Attack Complexity (AC).</summary>
|
||||
public string? AttackComplexity { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the Privileges Required (PR).</summary>
|
||||
public string? PrivilegesRequired { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the User Interaction (UI).</summary>
|
||||
public string? UserInteraction { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the Scope (S).</summary>
|
||||
public string? Scope { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the Confidentiality Impact (C).</summary>
|
||||
public string? ConfidentialityImpact { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the Integrity Impact (I).</summary>
|
||||
public string? IntegrityImpact { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the Availability Impact (A).</summary>
|
||||
public string? AvailabilityImpact { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// <copyright file="IVexToSpdx3Mapper.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.Spdx3.Model;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for mapping VEX consensus results to SPDX 3.0.1 Security profile documents.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-005
|
||||
/// </summary>
|
||||
public interface IVexToSpdx3Mapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a VEX consensus result to an SPDX 3.0.1 document with Security profile.
|
||||
/// </summary>
|
||||
/// <param name="consensus">The VEX consensus containing statements.</param>
|
||||
/// <param name="options">Mapping options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The mapped SPDX 3.0.1 document.</returns>
|
||||
Task<Spdx3Document> MapConsensusAsync(
|
||||
VexConsensus consensus,
|
||||
VexToSpdx3Options options,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Maps individual VEX statements to SPDX 3.0.1 elements.
|
||||
/// </summary>
|
||||
/// <param name="statements">The VEX statements to map.</param>
|
||||
/// <param name="spdxIdPrefix">Prefix for generating SPDX IDs.</param>
|
||||
/// <returns>The mapped elements including vulnerabilities and assessment relationships.</returns>
|
||||
VexMappingResult MapStatements(
|
||||
IEnumerable<OpenVexStatement> statements,
|
||||
string spdxIdPrefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for VEX to SPDX 3.0.1 mapping.
|
||||
/// </summary>
|
||||
public sealed record VexToSpdx3Options
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the SPDX ID prefix for generated elements.
|
||||
/// </summary>
|
||||
public required string SpdxIdPrefix { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the document name.
|
||||
/// </summary>
|
||||
public string? DocumentName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to include CVSS scores.
|
||||
/// </summary>
|
||||
public bool IncludeCvss { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to include EPSS scores.
|
||||
/// </summary>
|
||||
public bool IncludeEpss { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tool identifier to include in creation info.
|
||||
/// </summary>
|
||||
public string ToolId { get; init; } = "StellaOps VexLens";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a filter for specific products (null = all).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? ProductFilter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a filter for specific CVEs (null = all).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? VulnerabilityFilter { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of mapping VEX statements to SPDX 3.0.1 elements.
|
||||
/// </summary>
|
||||
public sealed record VexMappingResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the vulnerability elements.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<Spdx3Element> Vulnerabilities { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the VEX assessment relationships.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<Spdx3Element> Assessments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVSS assessment relationships.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Spdx3Element> CvssAssessments { get; init; } = Array.Empty<Spdx3Element>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the EPSS assessment relationships.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Spdx3Element> EpssAssessments { get; init; } = Array.Empty<Spdx3Element>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all elements combined.
|
||||
/// </summary>
|
||||
public IEnumerable<Spdx3Element> AllElements =>
|
||||
Vulnerabilities
|
||||
.Concat(Assessments)
|
||||
.Concat(CvssAssessments)
|
||||
.Concat(EpssAssessments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a VEX consensus containing multiple statements.
|
||||
/// </summary>
|
||||
public sealed record VexConsensus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the document identifier.
|
||||
/// </summary>
|
||||
public required string DocumentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the document version.
|
||||
/// </summary>
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the statements in this consensus.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<OpenVexStatement> Statements { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the supplier/author.
|
||||
/// </summary>
|
||||
public string? Author { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OpenVEX statement.
|
||||
/// </summary>
|
||||
public sealed record OpenVexStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the vulnerability ID (e.g., CVE-2026-1234).
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the product ID (e.g., PURL).
|
||||
/// </summary>
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the VEX status.
|
||||
/// </summary>
|
||||
public required VexStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the justification for not_affected status.
|
||||
/// </summary>
|
||||
public VexJustification? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status notes.
|
||||
/// </summary>
|
||||
public string? StatusNotes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the impact statement.
|
||||
/// </summary>
|
||||
public string? ImpactStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action statement for affected status.
|
||||
/// </summary>
|
||||
public string? ActionStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action statement deadline.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ActionStatementTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the statement timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the supplier/author.
|
||||
/// </summary>
|
||||
public string? Supplier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets CVSS v3 data if available.
|
||||
/// </summary>
|
||||
public CvssV3Data? CvssV3 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets EPSS data if available.
|
||||
/// </summary>
|
||||
public EpssData? Epss { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX status values.
|
||||
/// </summary>
|
||||
public enum VexStatus
|
||||
{
|
||||
/// <summary>The product is affected by the vulnerability.</summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>The product is not affected by the vulnerability.</summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>The vulnerability is fixed in the product.</summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>The status is under investigation.</summary>
|
||||
UnderInvestigation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX justification values for not_affected status.
|
||||
/// </summary>
|
||||
public enum VexJustification
|
||||
{
|
||||
/// <summary>The component is not present.</summary>
|
||||
ComponentNotPresent,
|
||||
|
||||
/// <summary>The vulnerable code is not present.</summary>
|
||||
VulnerableCodeNotPresent,
|
||||
|
||||
/// <summary>The vulnerable code cannot be controlled by an adversary.</summary>
|
||||
VulnerableCodeCannotBeControlledByAdversary,
|
||||
|
||||
/// <summary>The vulnerable code is not in the execute path.</summary>
|
||||
VulnerableCodeNotInExecutePath,
|
||||
|
||||
/// <summary>Inline mitigations already exist.</summary>
|
||||
InlineMitigationsAlreadyExist
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v3 scoring data.
|
||||
/// </summary>
|
||||
public sealed record CvssV3Data
|
||||
{
|
||||
/// <summary>Gets the CVSS v3 base score (0.0-10.0).</summary>
|
||||
public required double BaseScore { get; init; }
|
||||
|
||||
/// <summary>Gets the CVSS v3 vector string.</summary>
|
||||
public required string VectorString { get; init; }
|
||||
|
||||
/// <summary>Gets the temporal score if available.</summary>
|
||||
public double? TemporalScore { get; init; }
|
||||
|
||||
/// <summary>Gets the environmental score if available.</summary>
|
||||
public double? EnvironmentalScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EPSS (Exploit Prediction Scoring System) data.
|
||||
/// </summary>
|
||||
public sealed record EpssData
|
||||
{
|
||||
/// <summary>Gets the EPSS probability (0.0-1.0).</summary>
|
||||
public required double Probability { get; init; }
|
||||
|
||||
/// <summary>Gets the EPSS percentile (0.0-1.0).</summary>
|
||||
public required double Percentile { get; init; }
|
||||
|
||||
/// <summary>Gets when the score was assessed.</summary>
|
||||
public DateTimeOffset? AssessedOn { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.VexLens.Spdx3</RootNamespace>
|
||||
<Description>SPDX 3.0.1 Security profile integration for StellaOps VexLens</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Spdx3\StellaOps.Spdx3.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
10
src/VexLens/__Libraries/StellaOps.VexLens.Spdx3/TASKS.md
Normal file
10
src/VexLens/__Libraries/StellaOps.VexLens.Spdx3/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# VexLens SPDX3 Security Profile Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0850-M | DONE | Revalidated 2026-01-08. |
|
||||
| AUDIT-0850-T | DONE | Revalidated 2026-01-08. |
|
||||
| AUDIT-0850-A | TODO | Open findings; apply pending approval. |
|
||||
@@ -0,0 +1,282 @@
|
||||
// <copyright file="VexStatusMapper.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.Spdx3.Model.Security;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3;
|
||||
|
||||
/// <summary>
|
||||
/// Maps OpenVEX status values to SPDX 3.0.1 Security profile assessment relationships.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-002
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mapping Table:
|
||||
/// | OpenVEX | SPDX 3.0.1 Security |
|
||||
/// |---------|---------------------|
|
||||
/// | status: affected | VexAffectedVulnAssessmentRelationship |
|
||||
/// | status: not_affected | VexNotAffectedVulnAssessmentRelationship |
|
||||
/// | status: fixed | VexFixedVulnAssessmentRelationship |
|
||||
/// | status: under_investigation | VexUnderInvestigationVulnAssessmentRelationship |
|
||||
/// | justification | statusNotes |
|
||||
/// | impact_statement | statusNotes (combined) |
|
||||
/// | action_statement | actionStatement |
|
||||
/// </remarks>
|
||||
public static class VexStatusMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps an OpenVEX statement to an SPDX 3.0.1 VEX assessment relationship.
|
||||
/// </summary>
|
||||
/// <param name="statement">The OpenVEX statement to map.</param>
|
||||
/// <param name="spdxIdPrefix">Prefix for generating SPDX IDs.</param>
|
||||
/// <returns>The mapped SPDX 3.0.1 assessment relationship.</returns>
|
||||
public static Spdx3VulnAssessmentRelationship MapToSpdx3(
|
||||
OpenVexStatement statement,
|
||||
string spdxIdPrefix)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statement);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spdxIdPrefix);
|
||||
|
||||
var spdxId = GenerateSpdxId(spdxIdPrefix, statement);
|
||||
|
||||
return statement.Status switch
|
||||
{
|
||||
VexStatus.Affected => MapAffected(statement, spdxId),
|
||||
VexStatus.NotAffected => MapNotAffected(statement, spdxId),
|
||||
VexStatus.Fixed => MapFixed(statement, spdxId),
|
||||
VexStatus.UnderInvestigation => MapUnderInvestigation(statement, spdxId),
|
||||
_ => throw new ArgumentException($"Unknown VEX status: {statement.Status}", nameof(statement))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an OpenVEX justification to SPDX 3.0.1 justification type.
|
||||
/// </summary>
|
||||
/// <param name="justification">The OpenVEX justification.</param>
|
||||
/// <returns>The SPDX 3.0.1 justification type, or null if no mapping exists.</returns>
|
||||
public static Spdx3VexJustificationType? MapJustification(VexJustification? justification)
|
||||
{
|
||||
return justification switch
|
||||
{
|
||||
VexJustification.ComponentNotPresent => Spdx3VexJustificationType.ComponentNotPresent,
|
||||
VexJustification.VulnerableCodeNotPresent => Spdx3VexJustificationType.VulnerableCodeNotPresent,
|
||||
VexJustification.VulnerableCodeCannotBeControlledByAdversary =>
|
||||
Spdx3VexJustificationType.VulnerableCodeCannotBeControlledByAdversary,
|
||||
VexJustification.VulnerableCodeNotInExecutePath =>
|
||||
Spdx3VexJustificationType.VulnerableCodeNotInExecutePath,
|
||||
VexJustification.InlineMitigationsAlreadyExist =>
|
||||
Spdx3VexJustificationType.InlineMitigationsAlreadyExist,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static Spdx3VexAffectedVulnAssessmentRelationship MapAffected(
|
||||
OpenVexStatement statement,
|
||||
string spdxId)
|
||||
{
|
||||
return new Spdx3VexAffectedVulnAssessmentRelationship
|
||||
{
|
||||
SpdxId = spdxId,
|
||||
Type = Spdx3VexAffectedVulnAssessmentRelationship.TypeName,
|
||||
AssessedElement = statement.ProductId,
|
||||
From = statement.VulnerabilityId,
|
||||
To = [statement.ProductId],
|
||||
RelationshipType = "affects",
|
||||
VexVersion = "1.0.0",
|
||||
StatusNotes = statement.StatusNotes,
|
||||
ActionStatement = statement.ActionStatement,
|
||||
ActionStatementTime = statement.ActionStatementTime,
|
||||
PublishedTime = statement.Timestamp,
|
||||
SuppliedBy = statement.Supplier
|
||||
};
|
||||
}
|
||||
|
||||
private static Spdx3VexNotAffectedVulnAssessmentRelationship MapNotAffected(
|
||||
OpenVexStatement statement,
|
||||
string spdxId)
|
||||
{
|
||||
var statusNotes = CombineStatusNotes(statement.StatusNotes, statement.ImpactStatement);
|
||||
|
||||
return new Spdx3VexNotAffectedVulnAssessmentRelationship
|
||||
{
|
||||
SpdxId = spdxId,
|
||||
Type = Spdx3VexNotAffectedVulnAssessmentRelationship.TypeName,
|
||||
AssessedElement = statement.ProductId,
|
||||
From = statement.VulnerabilityId,
|
||||
To = [statement.ProductId],
|
||||
RelationshipType = "doesNotAffect",
|
||||
VexVersion = "1.0.0",
|
||||
StatusNotes = statusNotes,
|
||||
JustificationType = MapJustification(statement.Justification),
|
||||
ImpactStatement = statement.ImpactStatement,
|
||||
PublishedTime = statement.Timestamp,
|
||||
SuppliedBy = statement.Supplier
|
||||
};
|
||||
}
|
||||
|
||||
private static Spdx3VexFixedVulnAssessmentRelationship MapFixed(
|
||||
OpenVexStatement statement,
|
||||
string spdxId)
|
||||
{
|
||||
return new Spdx3VexFixedVulnAssessmentRelationship
|
||||
{
|
||||
SpdxId = spdxId,
|
||||
Type = Spdx3VexFixedVulnAssessmentRelationship.TypeName,
|
||||
AssessedElement = statement.ProductId,
|
||||
From = statement.VulnerabilityId,
|
||||
To = [statement.ProductId],
|
||||
RelationshipType = "fixedIn",
|
||||
VexVersion = "1.0.0",
|
||||
StatusNotes = statement.StatusNotes,
|
||||
PublishedTime = statement.Timestamp,
|
||||
SuppliedBy = statement.Supplier
|
||||
};
|
||||
}
|
||||
|
||||
private static Spdx3VexUnderInvestigationVulnAssessmentRelationship MapUnderInvestigation(
|
||||
OpenVexStatement statement,
|
||||
string spdxId)
|
||||
{
|
||||
return new Spdx3VexUnderInvestigationVulnAssessmentRelationship
|
||||
{
|
||||
SpdxId = spdxId,
|
||||
Type = Spdx3VexUnderInvestigationVulnAssessmentRelationship.TypeName,
|
||||
AssessedElement = statement.ProductId,
|
||||
From = statement.VulnerabilityId,
|
||||
To = [statement.ProductId],
|
||||
RelationshipType = "underInvestigationFor",
|
||||
VexVersion = "1.0.0",
|
||||
StatusNotes = statement.StatusNotes,
|
||||
PublishedTime = statement.Timestamp,
|
||||
SuppliedBy = statement.Supplier
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateSpdxId(string prefix, OpenVexStatement statement)
|
||||
{
|
||||
// Generate a deterministic SPDX ID from the statement
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var input = $"{statement.VulnerabilityId}:{statement.ProductId}:{statement.Status}:{statement.Timestamp:O}";
|
||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
var shortHash = Convert.ToHexStringLower(hash)[..12];
|
||||
return $"{prefix.TrimEnd('/')}/vex/{shortHash}";
|
||||
}
|
||||
|
||||
private static string? CombineStatusNotes(string? statusNotes, string? impactStatement)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(statusNotes) && string.IsNullOrWhiteSpace(impactStatement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(impactStatement))
|
||||
{
|
||||
return statusNotes;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(statusNotes))
|
||||
{
|
||||
return $"Impact: {impactStatement}";
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// <copyright file="VexToSpdx3Mapper.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Security;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3;
|
||||
|
||||
/// <summary>
|
||||
/// Maps VEX consensus results to SPDX 3.0.1 Security profile documents.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-006
|
||||
/// </summary>
|
||||
public sealed class VexToSpdx3Mapper : IVexToSpdx3Mapper
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="VexToSpdx3Mapper"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
public VexToSpdx3Mapper(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance using the system time provider.
|
||||
/// </summary>
|
||||
public VexToSpdx3Mapper() : this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Spdx3Document> MapConsensusAsync(
|
||||
VexConsensus consensus,
|
||||
VexToSpdx3Options options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(consensus);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
// Filter statements if filters are specified
|
||||
var statements = FilterStatements(consensus.Statements, options);
|
||||
|
||||
// Map statements to SPDX 3.0.1 elements
|
||||
var mappingResult = MapStatements(statements, options.SpdxIdPrefix);
|
||||
|
||||
// Build creation info
|
||||
var creationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
Id = $"{options.SpdxIdPrefix}/creationInfo/{consensus.DocumentId}",
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = consensus.Timestamp ?? _timeProvider.GetUtcNow(),
|
||||
CreatedBy = consensus.Author is not null
|
||||
? ImmutableArray.Create(consensus.Author)
|
||||
: ImmutableArray<string>.Empty,
|
||||
CreatedUsing = ImmutableArray.Create(options.ToolId),
|
||||
Profile = ImmutableArray.Create(
|
||||
Spdx3ProfileIdentifier.Core,
|
||||
Spdx3ProfileIdentifier.Security),
|
||||
DataLicense = Spdx3CreationInfo.Spdx301DataLicense
|
||||
};
|
||||
|
||||
// Include CVSS assessments if requested
|
||||
var allElements = mappingResult.AllElements.ToList();
|
||||
|
||||
if (options.IncludeCvss)
|
||||
{
|
||||
var cvssAssessments = BuildCvssAssessments(statements, options.SpdxIdPrefix);
|
||||
allElements.AddRange(cvssAssessments);
|
||||
}
|
||||
|
||||
if (options.IncludeEpss)
|
||||
{
|
||||
var epssAssessments = BuildEpssAssessments(statements, options.SpdxIdPrefix);
|
||||
allElements.AddRange(epssAssessments);
|
||||
}
|
||||
|
||||
var document = new Spdx3Document(
|
||||
elements: allElements,
|
||||
creationInfos: new[] { creationInfo },
|
||||
profiles: new[] { Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Security });
|
||||
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public VexMappingResult MapStatements(
|
||||
IEnumerable<OpenVexStatement> statements,
|
||||
string spdxIdPrefix)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statements);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spdxIdPrefix);
|
||||
|
||||
var vulnerabilities = new List<Spdx3Element>();
|
||||
var assessments = new List<Spdx3Element>();
|
||||
var seenVulnerabilities = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var statement in statements)
|
||||
{
|
||||
// Create vulnerability element if not already created
|
||||
if (!seenVulnerabilities.Contains(statement.VulnerabilityId))
|
||||
{
|
||||
var vulnerability = BuildVulnerability(statement, spdxIdPrefix);
|
||||
vulnerabilities.Add(vulnerability);
|
||||
seenVulnerabilities.Add(statement.VulnerabilityId);
|
||||
}
|
||||
|
||||
// Create VEX assessment relationship
|
||||
var assessment = VexStatusMapper.MapToSpdx3(statement, spdxIdPrefix);
|
||||
assessments.Add(assessment);
|
||||
}
|
||||
|
||||
return new VexMappingResult
|
||||
{
|
||||
Vulnerabilities = vulnerabilities,
|
||||
Assessments = assessments
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<OpenVexStatement> FilterStatements(
|
||||
IReadOnlyList<OpenVexStatement> statements,
|
||||
VexToSpdx3Options options)
|
||||
{
|
||||
IEnumerable<OpenVexStatement> result = statements;
|
||||
|
||||
if (options.ProductFilter is { Count: > 0 })
|
||||
{
|
||||
var productSet = new HashSet<string>(options.ProductFilter, StringComparer.OrdinalIgnoreCase);
|
||||
result = result.Where(s => productSet.Contains(s.ProductId));
|
||||
}
|
||||
|
||||
if (options.VulnerabilityFilter is { Count: > 0 })
|
||||
{
|
||||
var vulnSet = new HashSet<string>(options.VulnerabilityFilter, StringComparer.OrdinalIgnoreCase);
|
||||
result = result.Where(s => vulnSet.Contains(s.VulnerabilityId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Spdx3Vulnerability BuildVulnerability(OpenVexStatement statement, string spdxIdPrefix)
|
||||
{
|
||||
return new VulnerabilityElementBuilder(spdxIdPrefix)
|
||||
.WithVulnerabilityId(statement.VulnerabilityId)
|
||||
.WithPublishedTime(statement.Timestamp)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static IEnumerable<Spdx3Element> BuildCvssAssessments(
|
||||
IEnumerable<OpenVexStatement> statements,
|
||||
string spdxIdPrefix)
|
||||
{
|
||||
foreach (var statement in statements.Where(s => s.CvssV3 is not null))
|
||||
{
|
||||
var cvss = CvssMapper.MapToSpdx3(
|
||||
statement.VulnerabilityId,
|
||||
statement.ProductId,
|
||||
statement.CvssV3!,
|
||||
spdxIdPrefix);
|
||||
|
||||
yield return cvss;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Spdx3Element> BuildEpssAssessments(
|
||||
IEnumerable<OpenVexStatement> statements,
|
||||
string spdxIdPrefix)
|
||||
{
|
||||
foreach (var statement in statements.Where(s => s.Epss is not null))
|
||||
{
|
||||
var epss = CvssMapper.MapEpssToSpdx3(
|
||||
statement.VulnerabilityId,
|
||||
statement.ProductId,
|
||||
statement.Epss!,
|
||||
spdxIdPrefix);
|
||||
|
||||
yield return epss;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// <copyright file="VulnerabilityElementBuilder.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Security;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3;
|
||||
|
||||
/// <summary>
|
||||
/// Builds SPDX 3.0.1 Vulnerability elements from CVE and other vulnerability data.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-004
|
||||
/// </summary>
|
||||
public sealed class VulnerabilityElementBuilder
|
||||
{
|
||||
private readonly string _spdxIdPrefix;
|
||||
private readonly List<Spdx3ExternalIdentifier> _externalIdentifiers = new();
|
||||
private readonly List<Spdx3ExternalRef> _externalRefs = new();
|
||||
|
||||
private string? _vulnerabilityId;
|
||||
private string? _description;
|
||||
private DateTimeOffset? _publishedTime;
|
||||
private DateTimeOffset? _modifiedTime;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="VulnerabilityElementBuilder"/> class.
|
||||
/// </summary>
|
||||
/// <param name="spdxIdPrefix">Prefix for generating SPDX IDs.</param>
|
||||
public VulnerabilityElementBuilder(string spdxIdPrefix)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spdxIdPrefix);
|
||||
_spdxIdPrefix = spdxIdPrefix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the vulnerability ID (e.g., CVE-2026-1234).
|
||||
/// </summary>
|
||||
/// <param name="vulnerabilityId">The vulnerability ID.</param>
|
||||
/// <returns>This builder for fluent chaining.</returns>
|
||||
public VulnerabilityElementBuilder WithVulnerabilityId(string vulnerabilityId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
_vulnerabilityId = vulnerabilityId;
|
||||
|
||||
// Auto-detect identifier type and add external identifier
|
||||
if (vulnerabilityId.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_externalIdentifiers.Add(new Spdx3ExternalIdentifier
|
||||
{
|
||||
ExternalIdentifierType = "cve",
|
||||
Identifier = vulnerabilityId,
|
||||
IdentifierLocator = ImmutableArray.Create($"https://nvd.nist.gov/vuln/detail/{vulnerabilityId}")
|
||||
});
|
||||
}
|
||||
else if (vulnerabilityId.StartsWith("GHSA-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_externalIdentifiers.Add(new Spdx3ExternalIdentifier
|
||||
{
|
||||
ExternalIdentifierType = "ghsa",
|
||||
Identifier = vulnerabilityId,
|
||||
IdentifierLocator = ImmutableArray.Create($"https://github.com/advisories/{vulnerabilityId}")
|
||||
});
|
||||
}
|
||||
else if (vulnerabilityId.StartsWith("OSV-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_externalIdentifiers.Add(new Spdx3ExternalIdentifier
|
||||
{
|
||||
ExternalIdentifierType = "osv",
|
||||
Identifier = vulnerabilityId,
|
||||
IdentifierLocator = ImmutableArray.Create($"https://osv.dev/vulnerability/{vulnerabilityId}")
|
||||
});
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the vulnerability description.
|
||||
/// </summary>
|
||||
/// <param name="description">The description text.</param>
|
||||
/// <returns>This builder for fluent chaining.</returns>
|
||||
public VulnerabilityElementBuilder WithDescription(string? description)
|
||||
{
|
||||
_description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the published time.
|
||||
/// </summary>
|
||||
/// <param name="publishedTime">When the vulnerability was published.</param>
|
||||
/// <returns>This builder for fluent chaining.</returns>
|
||||
public VulnerabilityElementBuilder WithPublishedTime(DateTimeOffset? publishedTime)
|
||||
{
|
||||
_publishedTime = publishedTime;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the modified time.
|
||||
/// </summary>
|
||||
/// <param name="modifiedTime">When the vulnerability was last modified.</param>
|
||||
/// <returns>This builder for fluent chaining.</returns>
|
||||
public VulnerabilityElementBuilder WithModifiedTime(DateTimeOffset? modifiedTime)
|
||||
{
|
||||
_modifiedTime = modifiedTime;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a reference to NVD.
|
||||
/// </summary>
|
||||
/// <param name="cveId">The CVE ID for NVD lookup.</param>
|
||||
/// <returns>This builder for fluent chaining.</returns>
|
||||
public VulnerabilityElementBuilder WithNvdReference(string cveId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
|
||||
_externalRefs.Add(new Spdx3ExternalRef
|
||||
{
|
||||
ExternalRefType = "securityAdvisory",
|
||||
Locator = ImmutableArray.Create($"https://nvd.nist.gov/vuln/detail/{cveId}")
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a reference to OSV.
|
||||
/// </summary>
|
||||
/// <param name="vulnerabilityId">The vulnerability ID for OSV lookup.</param>
|
||||
/// <returns>This builder for fluent chaining.</returns>
|
||||
public VulnerabilityElementBuilder WithOsvReference(string vulnerabilityId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
_externalRefs.Add(new Spdx3ExternalRef
|
||||
{
|
||||
ExternalRefType = "securityAdvisory",
|
||||
Locator = ImmutableArray.Create($"https://osv.dev/vulnerability/{vulnerabilityId}")
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom external reference.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(refType);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(locator);
|
||||
_externalRefs.Add(new Spdx3ExternalRef
|
||||
{
|
||||
ExternalRefType = refType,
|
||||
Locator = ImmutableArray.Create(locator)
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the SPDX 3.0.1 Vulnerability element.
|
||||
/// </summary>
|
||||
/// <returns>The constructed Vulnerability element.</returns>
|
||||
/// <exception cref="InvalidOperationException">If vulnerability ID is not set.</exception>
|
||||
public Spdx3Vulnerability Build()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_vulnerabilityId))
|
||||
{
|
||||
throw new InvalidOperationException("Vulnerability ID is required. Call WithVulnerabilityId() first.");
|
||||
}
|
||||
|
||||
return new Spdx3Vulnerability
|
||||
{
|
||||
SpdxId = GenerateSpdxId(),
|
||||
Type = Spdx3Vulnerability.TypeName,
|
||||
Name = _vulnerabilityId,
|
||||
Description = _description,
|
||||
PublishedTime = _publishedTime,
|
||||
ModifiedTime = _modifiedTime,
|
||||
ExternalIdentifiers = _externalIdentifiers.ToImmutableArray(),
|
||||
ExternalRefs = _externalRefs.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Vulnerability element from a CVE ID with NVD reference.
|
||||
/// </summary>
|
||||
/// <param name="cveId">The CVE ID.</param>
|
||||
/// <param name="spdxIdPrefix">Prefix for SPDX ID generation.</param>
|
||||
/// <param name="description">Optional description.</param>
|
||||
/// <returns>The constructed Vulnerability element.</returns>
|
||||
public static Spdx3Vulnerability FromCve(
|
||||
string cveId,
|
||||
string spdxIdPrefix,
|
||||
string? description = null)
|
||||
{
|
||||
return new VulnerabilityElementBuilder(spdxIdPrefix)
|
||||
.WithVulnerabilityId(cveId)
|
||||
.WithDescription(description)
|
||||
.WithNvdReference(cveId)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private string GenerateSpdxId()
|
||||
{
|
||||
// Generate a deterministic SPDX ID from the vulnerability ID
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var input = _vulnerabilityId ?? string.Empty;
|
||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
var shortHash = Convert.ToHexStringLower(hash)[..12];
|
||||
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,19 @@
|
||||
# VexLens SPDX3 Security Profile Tests Charter
|
||||
|
||||
## Purpose & Scope
|
||||
- Working directory: `src/VexLens/__Libraries/__Tests/StellaOps.VexLens.Spdx3.Tests/`.
|
||||
- Roles: QA automation, backend engineer.
|
||||
- Focus: deterministic unit tests for SPDX3 VEX and CVSS mappings.
|
||||
|
||||
## Required Reading
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/vex-lens/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`
|
||||
|
||||
## Working Agreement
|
||||
- Use fixed timestamps and IDs in fixtures.
|
||||
- Avoid Random, Guid.NewGuid, DateTime.UtcNow in tests.
|
||||
- Cover error paths and deterministic ID generation.
|
||||
- Update `TASKS.md` and sprint tracker as statuses change.
|
||||
@@ -0,0 +1,368 @@
|
||||
// <copyright file="CombinedSbomVexBuilderTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Security;
|
||||
using StellaOps.Spdx3.Model.Software;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="CombinedSbomVexBuilder"/>.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CombinedSbomVexBuilderTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private const string SpdxIdPrefix = "https://stellaops.io/spdx/test";
|
||||
|
||||
private static readonly DateTimeOffset FixedTimestamp =
|
||||
new(2026, 1, 8, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public CombinedSbomVexBuilderTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithSoftwareAndSecurityProfiles_CreatesCombinedDocument()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var consensus = CreateTestConsensus();
|
||||
|
||||
// Act
|
||||
var document = CombinedSbomVexBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/combined/12345")
|
||||
.WithName("Combined SBOM and VEX")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithSecurityProfile(consensus, SpdxIdPrefix)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.Should().NotBeNull();
|
||||
document.Profiles.Should().Contain(Spdx3ProfileIdentifier.Core);
|
||||
document.Profiles.Should().Contain(Spdx3ProfileIdentifier.Software);
|
||||
document.Profiles.Should().Contain(Spdx3ProfileIdentifier.Security);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ContainsBothSbomAndVexElements()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var consensus = CreateTestConsensus();
|
||||
|
||||
// Act
|
||||
var document = CombinedSbomVexBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/combined/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithSecurityProfile(consensus, SpdxIdPrefix)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.Packages.Should().NotBeEmpty();
|
||||
document.Elements.OfType<Spdx3Vulnerability>().Should().NotBeEmpty();
|
||||
document.Elements.OfType<Spdx3VulnAssessmentRelationship>().Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithoutDocumentId_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
|
||||
// Act
|
||||
var act = () => CombinedSbomVexBuilder.Create(_timeProvider)
|
||||
.WithSoftwareProfile(sbom)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*Document SPDX ID is required*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CreatesDefaultCreationInfo()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var consensus = CreateTestConsensus();
|
||||
|
||||
// Act
|
||||
var document = CombinedSbomVexBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/combined/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithSecurityProfile(consensus, SpdxIdPrefix)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.CreationInfos.Should().HaveCount(1);
|
||||
var creationInfo = document.CreationInfos.First();
|
||||
creationInfo.SpecVersion.Should().Be(Spdx3CreationInfo.Spdx301Version);
|
||||
creationInfo.Created.Should().Be(FixedTimestamp);
|
||||
creationInfo.CreatedUsing.Should().Contain("StellaOps VexLens");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithLinkedSecurityProfile_RewritesPurlToSpdxId()
|
||||
{
|
||||
// Arrange
|
||||
var packagePurl = "pkg:npm/example@1.0.0";
|
||||
var packageSpdxId = "https://stellaops.io/spdx/pkg/example";
|
||||
|
||||
var sbom = CreateTestSbomWithPurl(packageSpdxId, packagePurl);
|
||||
var statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = packagePurl, // Uses PURL
|
||||
Status = VexStatus.Affected
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var document = CombinedSbomVexBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/combined/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithLinkedSecurityProfile(statements, SpdxIdPrefix)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
var assessment = document.Elements
|
||||
.OfType<Spdx3VexAffectedVulnAssessmentRelationship>()
|
||||
.First();
|
||||
|
||||
// Product ID should be rewritten to SPDX ID
|
||||
assessment.AssessedElement.Should().Be(packageSpdxId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithVexData_ExtensionMethod_CreatesCombinedDocument()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var consensus = CreateTestConsensus();
|
||||
|
||||
// Act
|
||||
var combined = sbom.WithVexData(
|
||||
consensus,
|
||||
documentId: "https://stellaops.io/spdx/combined/ext-12345",
|
||||
spdxIdPrefix: SpdxIdPrefix,
|
||||
timeProvider: _timeProvider);
|
||||
|
||||
// Assert
|
||||
combined.Should().NotBeNull();
|
||||
combined.Profiles.Should().Contain(Spdx3ProfileIdentifier.Security);
|
||||
combined.Elements.OfType<Spdx3Vulnerability>().Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithCustomCreationInfo_UsesProvidedInfo()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var customCreationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
Id = "custom-creation-info",
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = FixedTimestamp.AddHours(-2),
|
||||
CreatedBy = ImmutableArray.Create("custom-security-team"),
|
||||
Profile = ImmutableArray.Create(Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Security)
|
||||
};
|
||||
|
||||
// Act
|
||||
var document = CombinedSbomVexBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/combined/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithCreationInfo(customCreationInfo)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.CreationInfos.Should().Contain(customCreationInfo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_PreservesAllSbomPackages()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbomWithMultiplePackages();
|
||||
var consensus = CreateTestConsensus();
|
||||
|
||||
// Act
|
||||
var document = CombinedSbomVexBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/combined/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithSecurityProfile(consensus, SpdxIdPrefix)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.Packages.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithMultipleVexStatements_CreatesMultipleAssessments()
|
||||
{
|
||||
// Arrange
|
||||
var sbom = CreateTestSbom();
|
||||
var consensus = new VexConsensus
|
||||
{
|
||||
DocumentId = "vex-doc-multi",
|
||||
Statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1111",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.Affected
|
||||
},
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-2222",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.NotAffected,
|
||||
Justification = VexJustification.VulnerableCodeNotPresent
|
||||
},
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-3333",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.Fixed
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var document = CombinedSbomVexBuilder.Create(_timeProvider)
|
||||
.WithDocumentId("https://stellaops.io/spdx/combined/12345")
|
||||
.WithSoftwareProfile(sbom)
|
||||
.WithSecurityProfile(consensus, SpdxIdPrefix)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
document.Elements.OfType<Spdx3Vulnerability>().Should().HaveCount(3);
|
||||
document.Elements.OfType<Spdx3VulnAssessmentRelationship>().Should().HaveCount(3);
|
||||
}
|
||||
|
||||
private static Spdx3Document CreateTestSbom()
|
||||
{
|
||||
var creationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = FixedTimestamp.AddDays(-1),
|
||||
CreatedBy = ImmutableArray<string>.Empty,
|
||||
Profile = ImmutableArray.Create(Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software)
|
||||
};
|
||||
|
||||
var rootPackage = new Spdx3Package
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/pkg/root",
|
||||
Type = "software_Package",
|
||||
Name = "test-root-package",
|
||||
PackageVersion = "1.0.0"
|
||||
};
|
||||
|
||||
return new Spdx3Document(
|
||||
elements: new Spdx3Element[] { rootPackage },
|
||||
creationInfos: new[] { creationInfo },
|
||||
profiles: new[] { Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software });
|
||||
}
|
||||
|
||||
private static Spdx3Document CreateTestSbomWithPurl(string spdxId, string purl)
|
||||
{
|
||||
var creationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = FixedTimestamp.AddDays(-1),
|
||||
CreatedBy = ImmutableArray<string>.Empty,
|
||||
Profile = ImmutableArray.Create(Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software)
|
||||
};
|
||||
|
||||
var package = new Spdx3Package
|
||||
{
|
||||
SpdxId = spdxId,
|
||||
Type = "software_Package",
|
||||
Name = "example",
|
||||
PackageVersion = "1.0.0",
|
||||
ExternalIdentifier = ImmutableArray.Create(new Spdx3ExternalIdentifier
|
||||
{
|
||||
ExternalIdentifierType = Spdx3ExternalIdentifierType.PackageUrl,
|
||||
Identifier = purl
|
||||
})
|
||||
};
|
||||
|
||||
return new Spdx3Document(
|
||||
elements: new Spdx3Element[] { package },
|
||||
creationInfos: new[] { creationInfo },
|
||||
profiles: new[] { Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software });
|
||||
}
|
||||
|
||||
private static Spdx3Document CreateTestSbomWithMultiplePackages()
|
||||
{
|
||||
var creationInfo = new Spdx3CreationInfo
|
||||
{
|
||||
SpecVersion = Spdx3CreationInfo.Spdx301Version,
|
||||
Created = FixedTimestamp.AddDays(-1),
|
||||
CreatedBy = ImmutableArray<string>.Empty,
|
||||
Profile = ImmutableArray.Create(Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software)
|
||||
};
|
||||
|
||||
var packages = new Spdx3Package[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/pkg/root",
|
||||
Type = "software_Package",
|
||||
Name = "root-package",
|
||||
PackageVersion = "1.0.0"
|
||||
},
|
||||
new()
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/pkg/dep1",
|
||||
Type = "software_Package",
|
||||
Name = "dependency-1",
|
||||
PackageVersion = "2.0.0"
|
||||
},
|
||||
new()
|
||||
{
|
||||
SpdxId = "https://stellaops.io/spdx/pkg/dep2",
|
||||
Type = "software_Package",
|
||||
Name = "dependency-2",
|
||||
PackageVersion = "3.0.0"
|
||||
}
|
||||
};
|
||||
|
||||
return new Spdx3Document(
|
||||
elements: packages,
|
||||
creationInfos: new[] { creationInfo },
|
||||
profiles: new[] { Spdx3ProfileIdentifier.Core, Spdx3ProfileIdentifier.Software });
|
||||
}
|
||||
|
||||
private static VexConsensus CreateTestConsensus()
|
||||
{
|
||||
return new VexConsensus
|
||||
{
|
||||
DocumentId = "vex-doc-12345",
|
||||
Author = "security@stellaops.io",
|
||||
Timestamp = FixedTimestamp,
|
||||
Statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.Affected,
|
||||
ActionStatement = "Upgrade to version 2.0.0",
|
||||
ActionStatementTime = FixedTimestamp.AddDays(7)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// <copyright file="CvssMapperTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Spdx3.Model.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="CvssMapper"/>.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-010
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CvssMapperTests
|
||||
{
|
||||
private const string SpdxIdPrefix = "https://stellaops.io/spdx/test";
|
||||
private const string VulnSpdxId = "https://stellaops.io/spdx/test/vuln/123";
|
||||
private const string ElementSpdxId = "https://stellaops.io/spdx/test/pkg/456";
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, Spdx3CvssSeverity.None)]
|
||||
[InlineData(0.1, Spdx3CvssSeverity.Low)]
|
||||
[InlineData(3.9, Spdx3CvssSeverity.Low)]
|
||||
[InlineData(4.0, Spdx3CvssSeverity.Medium)]
|
||||
[InlineData(6.9, Spdx3CvssSeverity.Medium)]
|
||||
[InlineData(7.0, Spdx3CvssSeverity.High)]
|
||||
[InlineData(8.9, Spdx3CvssSeverity.High)]
|
||||
[InlineData(9.0, Spdx3CvssSeverity.Critical)]
|
||||
[InlineData(10.0, Spdx3CvssSeverity.Critical)]
|
||||
public void MapSeverity_ReturnsCorrectSeverity(decimal score, Spdx3CvssSeverity expected)
|
||||
{
|
||||
// Act
|
||||
var result = CvssMapper.MapSeverity(score);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSeverity_WithNull_ReturnsNone()
|
||||
{
|
||||
// Act
|
||||
var result = CvssMapper.MapSeverity(null);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(Spdx3CvssSeverity.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_CreatesCvssRelationship()
|
||||
{
|
||||
// Arrange
|
||||
var cvssData = new CvssV3Data
|
||||
{
|
||||
BaseScore = 7.5m,
|
||||
VectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
|
||||
Source = "https://nvd.nist.gov"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CvssMapper.MapToSpdx3(cvssData, VulnSpdxId, ElementSpdxId, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().Be(7.5m);
|
||||
result.Severity.Should().Be(Spdx3CvssSeverity.High);
|
||||
result.VectorString.Should().Be("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H");
|
||||
result.RelationshipType.Should().Be("hasAssessmentFor");
|
||||
result.AssessedElement.Should().Be(ElementSpdxId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapEpssToSpdx3_CreatesEpssRelationship()
|
||||
{
|
||||
// Arrange
|
||||
var epssData = new EpssData
|
||||
{
|
||||
Probability = 0.75m,
|
||||
Percentile = 0.95m,
|
||||
ScoreDate = new DateTimeOffset(2026, 1, 7, 0, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CvssMapper.MapEpssToSpdx3(epssData, VulnSpdxId, ElementSpdxId, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
result.Probability.Should().Be(0.75m);
|
||||
result.Percentile.Should().Be(0.95m);
|
||||
result.RelationshipType.Should().Be("hasAssessmentFor");
|
||||
result.SuppliedBy.Should().Contain("first.org");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseVectorString_ParsesValidVector()
|
||||
{
|
||||
// Arrange
|
||||
var vectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H";
|
||||
|
||||
// Act
|
||||
var result = CvssMapper.ParseVectorString(vectorString);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Version.Should().Be("3.1");
|
||||
result.AttackVector.Should().Be("N");
|
||||
result.AttackComplexity.Should().Be("L");
|
||||
result.PrivilegesRequired.Should().Be("N");
|
||||
result.UserInteraction.Should().Be("N");
|
||||
result.Scope.Should().Be("U");
|
||||
result.ConfidentialityImpact.Should().Be("H");
|
||||
result.IntegrityImpact.Should().Be("H");
|
||||
result.AvailabilityImpact.Should().Be("H");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void ParseVectorString_WithNullOrEmpty_ReturnsNull(string? vectorString)
|
||||
{
|
||||
// Act
|
||||
var result = CvssMapper.ParseVectorString(vectorString);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_GeneratesDeterministicSpdxId()
|
||||
{
|
||||
// Arrange
|
||||
var cvssData = new CvssV3Data { BaseScore = 7.5m };
|
||||
|
||||
// Act
|
||||
var result1 = CvssMapper.MapToSpdx3(cvssData, VulnSpdxId, ElementSpdxId, SpdxIdPrefix);
|
||||
var result2 = CvssMapper.MapToSpdx3(cvssData, VulnSpdxId, ElementSpdxId, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
result1.SpdxId.Should().Be(result2.SpdxId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.VexLens.Spdx3\StellaOps.VexLens.Spdx3.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
# VexLens SPDX3 Security Profile Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0851-M | DONE | Revalidated 2026-01-08. |
|
||||
| AUDIT-0851-T | DONE | Revalidated 2026-01-08. |
|
||||
| AUDIT-0851-A | DONE | Waived (test project; revalidated 2026-01-08). |
|
||||
@@ -0,0 +1,206 @@
|
||||
// <copyright file="VexStatusMapperTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Spdx3.Model.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="VexStatusMapper"/>.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-010
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VexStatusMapperTests
|
||||
{
|
||||
private const string SpdxIdPrefix = "https://stellaops.io/spdx/test";
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_WithAffectedStatus_ReturnsAffectedRelationship()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/lodash@4.17.20",
|
||||
Status = VexStatus.Affected,
|
||||
Timestamp = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
|
||||
ActionStatement = "Upgrade to version 4.17.21",
|
||||
ActionStatementTime = new DateTimeOffset(2026, 1, 15, 0, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = VexStatusMapper.MapToSpdx3(statement, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
result.Should().BeOfType<Spdx3VexAffectedVulnAssessmentRelationship>();
|
||||
var affected = (Spdx3VexAffectedVulnAssessmentRelationship)result;
|
||||
affected.RelationshipType.Should().Be("affects");
|
||||
affected.ActionStatement.Should().Be("Upgrade to version 4.17.21");
|
||||
affected.ActionStatementTime.Should().Be(statement.ActionStatementTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_WithNotAffectedStatus_ReturnsNotAffectedRelationship()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/lodash@4.17.20",
|
||||
Status = VexStatus.NotAffected,
|
||||
Timestamp = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
|
||||
Justification = VexJustification.VulnerableCodeNotPresent,
|
||||
ImpactStatement = "The vulnerable function is not used"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = VexStatusMapper.MapToSpdx3(statement, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
result.Should().BeOfType<Spdx3VexNotAffectedVulnAssessmentRelationship>();
|
||||
var notAffected = (Spdx3VexNotAffectedVulnAssessmentRelationship)result;
|
||||
notAffected.RelationshipType.Should().Be("doesNotAffect");
|
||||
notAffected.JustificationType.Should().Be(Spdx3VexJustificationType.VulnerableCodeNotPresent);
|
||||
notAffected.ImpactStatement.Should().Be("The vulnerable function is not used");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_WithFixedStatus_ReturnsFixedRelationship()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/lodash@4.17.21",
|
||||
Status = VexStatus.Fixed,
|
||||
Timestamp = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
|
||||
StatusNotes = "Fixed in version 4.17.21"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = VexStatusMapper.MapToSpdx3(statement, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
result.Should().BeOfType<Spdx3VexFixedVulnAssessmentRelationship>();
|
||||
var fixed_ = (Spdx3VexFixedVulnAssessmentRelationship)result;
|
||||
fixed_.RelationshipType.Should().Be("fixedIn");
|
||||
fixed_.StatusNotes.Should().Be("Fixed in version 4.17.21");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_WithUnderInvestigationStatus_ReturnsUnderInvestigationRelationship()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/lodash@4.17.20",
|
||||
Status = VexStatus.UnderInvestigation,
|
||||
Timestamp = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
|
||||
StatusNotes = "Investigating impact"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = VexStatusMapper.MapToSpdx3(statement, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
result.Should().BeOfType<Spdx3VexUnderInvestigationVulnAssessmentRelationship>();
|
||||
var underInvestigation = (Spdx3VexUnderInvestigationVulnAssessmentRelationship)result;
|
||||
underInvestigation.RelationshipType.Should().Be("underInvestigationFor");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(VexJustification.ComponentNotPresent, Spdx3VexJustificationType.ComponentNotPresent)]
|
||||
[InlineData(VexJustification.VulnerableCodeNotPresent, Spdx3VexJustificationType.VulnerableCodeNotPresent)]
|
||||
[InlineData(VexJustification.VulnerableCodeCannotBeControlledByAdversary, Spdx3VexJustificationType.VulnerableCodeCannotBeControlledByAdversary)]
|
||||
[InlineData(VexJustification.VulnerableCodeNotInExecutePath, Spdx3VexJustificationType.VulnerableCodeNotInExecutePath)]
|
||||
[InlineData(VexJustification.InlineMitigationsAlreadyExist, Spdx3VexJustificationType.InlineMitigationsAlreadyExist)]
|
||||
public void MapJustification_MapsCorrectly(VexJustification input, Spdx3VexJustificationType expected)
|
||||
{
|
||||
// Act
|
||||
var result = VexStatusMapper.MapJustification(input);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapJustification_WithNull_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = VexStatusMapper.MapJustification(null);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_GeneratesDeterministicSpdxId()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/lodash@4.17.20",
|
||||
Status = VexStatus.Affected,
|
||||
Timestamp = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = VexStatusMapper.MapToSpdx3(statement, SpdxIdPrefix);
|
||||
var result2 = VexStatusMapper.MapToSpdx3(statement, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
result1.SpdxId.Should().Be(result2.SpdxId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_SetsCommonFields()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/lodash@4.17.20",
|
||||
Status = VexStatus.Affected,
|
||||
Timestamp = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero),
|
||||
Supplier = "https://stellaops.io"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = VexStatusMapper.MapToSpdx3(statement, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
result.SpdxId.Should().StartWith(SpdxIdPrefix);
|
||||
result.AssessedElement.Should().Be("pkg:npm/lodash@4.17.20");
|
||||
result.From.Should().Be("CVE-2026-1234");
|
||||
result.To.Should().ContainSingle().Which.Should().Be("pkg:npm/lodash@4.17.20");
|
||||
result.PublishedTime.Should().Be(statement.Timestamp);
|
||||
result.SuppliedBy.Should().Be("https://stellaops.io");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToSpdx3_CombinesStatusNotesAndImpactStatement()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/lodash@4.17.20",
|
||||
Status = VexStatus.NotAffected,
|
||||
StatusNotes = "Reviewed by security team",
|
||||
ImpactStatement = "Function not used in production code"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = VexStatusMapper.MapToSpdx3(statement, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
var notAffected = (Spdx3VexNotAffectedVulnAssessmentRelationship)result;
|
||||
notAffected.StatusNotes.Should().Contain("Reviewed by security team");
|
||||
notAffected.StatusNotes.Should().Contain("Function not used in production code");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// <copyright file="VexToSpdx3MapperTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Spdx3.Model;
|
||||
using StellaOps.Spdx3.Model.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="VexToSpdx3Mapper"/>.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-006
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VexToSpdx3MapperTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly VexToSpdx3Mapper _mapper;
|
||||
private const string SpdxIdPrefix = "https://stellaops.io/spdx/test";
|
||||
|
||||
private static readonly DateTimeOffset FixedTimestamp =
|
||||
new(2026, 1, 8, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public VexToSpdx3MapperTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(FixedTimestamp);
|
||||
_mapper = new VexToSpdx3Mapper(_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapConsensusAsync_WithStatements_CreatesDocument()
|
||||
{
|
||||
// Arrange
|
||||
var consensus = new VexConsensus
|
||||
{
|
||||
DocumentId = "vex-doc-12345",
|
||||
Author = "security@stellaops.io",
|
||||
Timestamp = FixedTimestamp,
|
||||
Statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.Affected,
|
||||
ActionStatement = "Upgrade to 2.0.0"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var options = new VexToSpdx3Options
|
||||
{
|
||||
SpdxIdPrefix = SpdxIdPrefix,
|
||||
IncludeCvss = false,
|
||||
IncludeEpss = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var document = await _mapper.MapConsensusAsync(consensus, options);
|
||||
|
||||
// Assert
|
||||
document.Should().NotBeNull();
|
||||
document.Profiles.Should().Contain(Spdx3ProfileIdentifier.Security);
|
||||
document.Elements.Should().Contain(e => e is Spdx3Vulnerability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapStatements_CreatesVulnerabilityElements()
|
||||
{
|
||||
// Arrange
|
||||
var statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.Affected
|
||||
},
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-5678",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.NotAffected,
|
||||
Justification = VexJustification.VulnerableCodeNotPresent
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _mapper.MapStatements(statements, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
result.Vulnerabilities.Should().HaveCount(2);
|
||||
result.Assessments.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapStatements_DeduplicatesVulnerabilities()
|
||||
{
|
||||
// Arrange - same CVE for multiple products
|
||||
var statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.Affected
|
||||
},
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/other@2.0.0",
|
||||
Status = VexStatus.NotAffected,
|
||||
Justification = VexJustification.ComponentNotPresent
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _mapper.MapStatements(statements, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
result.Vulnerabilities.Should().HaveCount(1); // Deduplicated
|
||||
result.Assessments.Should().HaveCount(2); // Both assessments kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapConsensusAsync_WithProductFilter_FiltersStatements()
|
||||
{
|
||||
// Arrange
|
||||
var consensus = new VexConsensus
|
||||
{
|
||||
DocumentId = "vex-doc-12345",
|
||||
Statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/include-me@1.0.0",
|
||||
Status = VexStatus.Affected
|
||||
},
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-5678",
|
||||
ProductId = "pkg:npm/exclude-me@1.0.0",
|
||||
Status = VexStatus.Affected
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var options = new VexToSpdx3Options
|
||||
{
|
||||
SpdxIdPrefix = SpdxIdPrefix,
|
||||
ProductFilter = new[] { "pkg:npm/include-me@1.0.0" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var document = await _mapper.MapConsensusAsync(consensus, options);
|
||||
|
||||
// Assert
|
||||
document.Elements.OfType<Spdx3Vulnerability>().Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapConsensusAsync_WithVulnerabilityFilter_FiltersStatements()
|
||||
{
|
||||
// Arrange
|
||||
var consensus = new VexConsensus
|
||||
{
|
||||
DocumentId = "vex-doc-12345",
|
||||
Statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.Affected
|
||||
},
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-5678",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.NotAffected
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var options = new VexToSpdx3Options
|
||||
{
|
||||
SpdxIdPrefix = SpdxIdPrefix,
|
||||
VulnerabilityFilter = new[] { "CVE-2026-1234" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var document = await _mapper.MapConsensusAsync(consensus, options);
|
||||
|
||||
// Assert
|
||||
var vulnerabilities = document.Elements.OfType<Spdx3Vulnerability>().ToList();
|
||||
vulnerabilities.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapStatements_AffectedStatus_CreatesAffectedRelationship()
|
||||
{
|
||||
// Arrange
|
||||
var statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.Affected,
|
||||
ActionStatement = "Upgrade to version 2.0.0",
|
||||
ActionStatementTime = FixedTimestamp.AddDays(7)
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _mapper.MapStatements(statements, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
var assessment = result.Assessments
|
||||
.OfType<Spdx3VexAffectedVulnAssessmentRelationship>()
|
||||
.Should().ContainSingle().Subject;
|
||||
|
||||
assessment.ActionStatement.Should().Be("Upgrade to version 2.0.0");
|
||||
assessment.ActionStatementTime.Should().Be(FixedTimestamp.AddDays(7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapStatements_NotAffectedStatus_CreatesNotAffectedRelationship()
|
||||
{
|
||||
// Arrange
|
||||
var statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.NotAffected,
|
||||
Justification = VexJustification.VulnerableCodeNotInExecutePath,
|
||||
ImpactStatement = "Code path is never executed in our usage"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _mapper.MapStatements(statements, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
var assessment = result.Assessments
|
||||
.OfType<Spdx3VexNotAffectedVulnAssessmentRelationship>()
|
||||
.Should().ContainSingle().Subject;
|
||||
|
||||
assessment.JustificationType.Should().Be(Spdx3VexJustificationType.VulnerableCodeNotInExecutePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapStatements_FixedStatus_CreatesFixedRelationship()
|
||||
{
|
||||
// Arrange
|
||||
var statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/example@2.0.0",
|
||||
Status = VexStatus.Fixed,
|
||||
StatusNotes = "Fixed in version 2.0.0"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _mapper.MapStatements(statements, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
result.Assessments
|
||||
.OfType<Spdx3VexFixedVulnAssessmentRelationship>()
|
||||
.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapStatements_UnderInvestigationStatus_CreatesInvestigationRelationship()
|
||||
{
|
||||
// Arrange
|
||||
var statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.UnderInvestigation,
|
||||
StatusNotes = "Currently analyzing impact"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _mapper.MapStatements(statements, SpdxIdPrefix);
|
||||
|
||||
// Assert
|
||||
result.Assessments
|
||||
.OfType<Spdx3VexUnderInvestigationVulnAssessmentRelationship>()
|
||||
.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapConsensusAsync_IncludesCvssAssessments()
|
||||
{
|
||||
// Arrange
|
||||
var consensus = new VexConsensus
|
||||
{
|
||||
DocumentId = "vex-doc-12345",
|
||||
Statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.Affected,
|
||||
CvssV3 = new CvssV3Data
|
||||
{
|
||||
BaseScore = 9.8,
|
||||
VectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var options = new VexToSpdx3Options
|
||||
{
|
||||
SpdxIdPrefix = SpdxIdPrefix,
|
||||
IncludeCvss = true,
|
||||
IncludeEpss = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var document = await _mapper.MapConsensusAsync(consensus, options);
|
||||
|
||||
// Assert
|
||||
document.Elements
|
||||
.OfType<Spdx3CvssV3VulnAssessmentRelationship>()
|
||||
.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapConsensusAsync_IncludesEpssAssessments()
|
||||
{
|
||||
// Arrange
|
||||
var consensus = new VexConsensus
|
||||
{
|
||||
DocumentId = "vex-doc-12345",
|
||||
Statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.Affected,
|
||||
Epss = new EpssData
|
||||
{
|
||||
Probability = 0.85,
|
||||
Percentile = 0.97
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var options = new VexToSpdx3Options
|
||||
{
|
||||
SpdxIdPrefix = SpdxIdPrefix,
|
||||
IncludeCvss = false,
|
||||
IncludeEpss = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var document = await _mapper.MapConsensusAsync(consensus, options);
|
||||
|
||||
// Assert
|
||||
document.Elements
|
||||
.OfType<Spdx3EpssVulnAssessmentRelationship>()
|
||||
.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapConsensusAsync_SetsCorrectCreationInfo()
|
||||
{
|
||||
// Arrange
|
||||
var consensus = new VexConsensus
|
||||
{
|
||||
DocumentId = "vex-doc-12345",
|
||||
Author = "security@stellaops.io",
|
||||
Timestamp = FixedTimestamp.AddDays(-1),
|
||||
Statements = new[]
|
||||
{
|
||||
new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-1234",
|
||||
ProductId = "pkg:npm/example@1.0.0",
|
||||
Status = VexStatus.Affected
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var options = new VexToSpdx3Options
|
||||
{
|
||||
SpdxIdPrefix = SpdxIdPrefix,
|
||||
ToolId = "StellaOps VexLens Test"
|
||||
};
|
||||
|
||||
// Act
|
||||
var document = await _mapper.MapConsensusAsync(consensus, options);
|
||||
|
||||
// Assert
|
||||
var creationInfo = document.CreationInfos.First();
|
||||
creationInfo.Created.Should().Be(FixedTimestamp.AddDays(-1));
|
||||
creationInfo.CreatedBy.Should().Contain("security@stellaops.io");
|
||||
creationInfo.CreatedUsing.Should().Contain("StellaOps VexLens Test");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// <copyright file="VulnerabilityElementBuilderTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Spdx3.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="VulnerabilityElementBuilder"/>.
|
||||
/// Sprint: SPRINT_20260107_004_004 Task SP-010
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VulnerabilityElementBuilderTests
|
||||
{
|
||||
private const string SpdxIdPrefix = "https://stellaops.io/spdx/test";
|
||||
|
||||
[Fact]
|
||||
public void Build_WithCveId_CreatesVulnerabilityElement()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new VulnerabilityElementBuilder(SpdxIdPrefix)
|
||||
.WithVulnerabilityId("CVE-2026-1234")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
result.Name.Should().Be("CVE-2026-1234");
|
||||
result.SpdxId.Should().StartWith(SpdxIdPrefix);
|
||||
result.ExternalIdentifiers.Should().ContainSingle(x => x.ExternalIdentifierType == "cve");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithGhsaId_CreatesVulnerabilityWithGhsaIdentifier()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new VulnerabilityElementBuilder(SpdxIdPrefix)
|
||||
.WithVulnerabilityId("GHSA-1234-5678-abcd")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
result.Name.Should().Be("GHSA-1234-5678-abcd");
|
||||
result.ExternalIdentifiers.Should().ContainSingle(x => x.ExternalIdentifierType == "ghsa");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithDescription_IncludesDescription()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new VulnerabilityElementBuilder(SpdxIdPrefix)
|
||||
.WithVulnerabilityId("CVE-2026-1234")
|
||||
.WithDescription("A remote code execution vulnerability")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
result.Description.Should().Be("A remote code execution vulnerability");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithNvdReference_IncludesExternalRef()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new VulnerabilityElementBuilder(SpdxIdPrefix)
|
||||
.WithVulnerabilityId("CVE-2026-1234")
|
||||
.WithNvdReference("CVE-2026-1234")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
result.ExternalRefs.Should().ContainSingle(x =>
|
||||
x.ExternalRefType == "securityAdvisory" &&
|
||||
x.Locator[0].Contains("nvd.nist.gov"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithoutVulnerabilityId_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VulnerabilityElementBuilder(SpdxIdPrefix);
|
||||
|
||||
// Act & Assert
|
||||
var act = () => builder.Build();
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*Vulnerability ID*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromCve_CreatesCompleteVulnerability()
|
||||
{
|
||||
// Act
|
||||
var result = VulnerabilityElementBuilder.FromCve(
|
||||
"CVE-2026-1234",
|
||||
SpdxIdPrefix,
|
||||
"Test vulnerability");
|
||||
|
||||
// Assert
|
||||
result.Name.Should().Be("CVE-2026-1234");
|
||||
result.Description.Should().Be("Test vulnerability");
|
||||
result.ExternalRefs.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_GeneratesDeterministicSpdxId()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result1 = new VulnerabilityElementBuilder(SpdxIdPrefix)
|
||||
.WithVulnerabilityId("CVE-2026-1234")
|
||||
.Build();
|
||||
var result2 = new VulnerabilityElementBuilder(SpdxIdPrefix)
|
||||
.WithVulnerabilityId("CVE-2026-1234")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
result1.SpdxId.Should().Be(result2.SpdxId);
|
||||
}
|
||||
}
|
||||
22
src/VexLens/__Tests/StellaOps.VexLens.Tests/AGENTS.md
Normal file
22
src/VexLens/__Tests/StellaOps.VexLens.Tests/AGENTS.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# VexLens Tests Charter
|
||||
|
||||
## Mission
|
||||
Validate consensus, noise-gating, and delta workflows for VexLens.
|
||||
|
||||
## Responsibilities
|
||||
- Maintain unit tests for consensus, delta reports, and noise gating.
|
||||
- Keep fixtures deterministic and offline-friendly.
|
||||
- Track sprint tasks in `TASKS.md` and update the sprint tracker.
|
||||
|
||||
## Key Paths
|
||||
- `Delta/DeltaReportBuilderTests.cs`
|
||||
- `NoiseGate/NoiseGateServiceTests.cs`
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/vex-lens/architecture.md`
|
||||
- `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Use fixed TimeProvider in tests.
|
||||
- 2. Keep ordering deterministic in assertions.
|
||||
- 3. Update `TASKS.md` and sprint statuses when work changes.
|
||||
13
src/VexLens/__Tests/StellaOps.VexLens.Tests/TASKS.md
Normal file
13
src/VexLens/__Tests/StellaOps.VexLens.Tests/TASKS.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# VexLens Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0778-M | DONE | Revalidated 2026-01-07 (test project). |
|
||||
| AUDIT-0778-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0778-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| AUDIT-0779-M | DONE | Revalidated 2026-01-07 (duplicate entry). |
|
||||
| AUDIT-0779-T | DONE | Revalidated 2026-01-07 (duplicate entry). |
|
||||
| AUDIT-0779-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
Reference in New Issue
Block a user