more audit work

This commit is contained in:
master
2026-01-08 10:21:51 +02:00
parent 43c02081ef
commit 51cf4bc16c
546 changed files with 36721 additions and 4003 deletions

View 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).

View 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. |

View 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.

View File

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

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

View File

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

View File

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

View 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. |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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.

View 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). |