tests fixes and sprints work
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
@@ -386,31 +386,31 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.SourceI
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Cache.Valkey", "StellaOps.Concelier.Cache.Valkey", "{324F477A-FE74-38E4-389C-4A9E698C9143}"
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Acsc", "StellaOps.Concelier.Connector.Acsc", "{E5BC431A-1523-A08E-61C3-0E8D8953E083}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Cccs", "StellaOps.Concelier.Connector.Cccs", "{ACF6DC4C-02EF-2726-40B5-FF2230135C31}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.CertBund", "StellaOps.Concelier.Connector.CertBund", "{70B43A66-D43B-D36A-65D2-036BD265A6FE}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.CertCc", "StellaOps.Concelier.Connector.CertCc", "{D58954A7-FFEE-6789-F14D-26E647D6F0FB}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.CertFr", "StellaOps.Concelier.Connector.CertFr", "{46D3B3B9-443E-9077-0B96-8AD48F348ECD}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.CertIn", "StellaOps.Concelier.Connector.CertIn", "{295BC4E8-D2EB-B85E-CC8B-8E93915CECFA}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Common", "StellaOps.Concelier.Connector.Common", "{7D67AA5A-133D-5805-5C47-D4F2838C34EA}"
|
||||
|
||||
EndProject
|
||||
@@ -606,117 +606,117 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.SourceI
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Common.Tests", "StellaOps.Concelier.Connector.Common.Tests", "{9B774235-979D-D143-9CB8-D4E30735D127}"
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Cve.Tests", "StellaOps.Concelier.Connector.Cve.Tests", "{8619E478-6DE0-63F2-3A59-6BEDC3E83EDB}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Distro.Alpine.Tests", "StellaOps.Concelier.Connector.Distro.Alpine.Tests", "{014C26D3-5CED-6B1E-60CD-27DF7415E181}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Distro.Debian.Tests", "StellaOps.Concelier.Connector.Distro.Debian.Tests", "{7AADCF94-1F5A-93EC-D3EE-24C8A82D35E0}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Distro.RedHat.Tests", "StellaOps.Concelier.Connector.Distro.RedHat.Tests", "{1C4F7826-1688-76C9-BFD3-63506064EA11}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Distro.Suse.Tests", "StellaOps.Concelier.Connector.Distro.Suse.Tests", "{722E3E8E-79D6-8B39-9E81-647787C34EE5}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Distro.Ubuntu.Tests", "StellaOps.Concelier.Connector.Distro.Ubuntu.Tests", "{BB0CCB9D-BFCB-F667-166A-F269E0D50FEC}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Epss.Tests", "StellaOps.Concelier.Connector.Epss.Tests", "{6301439F-6CFE-D2E1-8533-11D998009AD6}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Ghsa.Tests", "StellaOps.Concelier.Connector.Ghsa.Tests", "{4ADDE790-2B7D-763F-E29A-EBA90CC5B668}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Ics.Cisa.Tests", "StellaOps.Concelier.Connector.Ics.Cisa.Tests", "{BB7B3202-07EF-9D28-C27B-13C47DC19719}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Ics.Kaspersky.Tests", "StellaOps.Concelier.Connector.Ics.Kaspersky.Tests", "{1D44C9F5-D7A5-98E0-6D3A-DE230DB079EA}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Jvn.Tests", "StellaOps.Concelier.Connector.Jvn.Tests", "{12264C0C-59E0-525B-E768-21FBFC64A88A}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Kev.Tests", "StellaOps.Concelier.Connector.Kev.Tests", "{91E56ECC-2E55-EB7C-5EF8-35F3D863F852}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Kisa.Tests", "StellaOps.Concelier.Connector.Kisa.Tests", "{B935E6A1-B4BF-45A6-AB82-380919280895}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Nvd.Tests", "StellaOps.Concelier.Connector.Nvd.Tests", "{9F20D98B-D90B-94A7-B0C1-02870B19ADE8}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Osv.Tests", "StellaOps.Concelier.Connector.Osv.Tests", "{370E2831-7DAD-EE43-F028-57EC53B6EB8B}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Ru.Bdu.Tests", "StellaOps.Concelier.Connector.Ru.Bdu.Tests", "{968F9A3D-E5D6-913E-BE20-4B0FED9A6C61}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Ru.Nkcki.Tests", "StellaOps.Concelier.Connector.Ru.Nkcki.Tests", "{AF7C4115-8470-3B6F-1620-63A15F26FACA}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.StellaOpsMirror.Tests", "StellaOps.Concelier.Connector.StellaOpsMirror.Tests", "{DA884F1A-D817-5896-250A-ED46F481E047}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Vndr.Adobe.Tests", "StellaOps.Concelier.Connector.Vndr.Adobe.Tests", "{C4631619-5EFE-EBA8-7A7A-F2DFEAA55F01}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Vndr.Apple.Tests", "StellaOps.Concelier.Connector.Vndr.Apple.Tests", "{562E8B89-43E5-5F68-AB31-9101F24A755D}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Vndr.Chromium.Tests", "StellaOps.Concelier.Connector.Vndr.Chromium.Tests", "{98CC2F50-4914-89F3-C890-79A61082EBAB}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Vndr.Cisco.Tests", "StellaOps.Concelier.Connector.Vndr.Cisco.Tests", "{389684EB-484A-F8EB-2EAA-58EBD76CB669}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Vndr.Msrc.Tests", "StellaOps.Concelier.Connector.Vndr.Msrc.Tests", "{900F7E29-0CC0-F876-2483-9953ADF4FEC5}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Vndr.Oracle.Tests", "StellaOps.Concelier.Connector.Vndr.Oracle.Tests", "{44CE3898-2033-5C64-3CDF-1B2F73891A1F}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Connector.Vndr.Vmware.Tests", "StellaOps.Concelier.Connector.Vndr.Vmware.Tests", "{4A644B92-3E47-0C5E-F2F9-09412DE177F3}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Core.Tests", "StellaOps.Concelier.Core.Tests", "{C3A65562-EA95-44BC-4D3A-DB9E8150F04E}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Exporter.Json.Tests", "StellaOps.Concelier.Exporter.Json.Tests", "{5F74CD86-197C-AA06-FE1E-E10381C20D9A}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Exporter.TrivyDb.Tests", "StellaOps.Concelier.Exporter.TrivyDb.Tests", "{39FAA799-6DEB-60C6-D507-5A89790BEE9E}"
|
||||
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Federation.Tests", "StellaOps.Concelier.Federation.Tests", "{784F769A-0D61-066A-6D6F-BF643EA5AF54}"
|
||||
@@ -1751,3 +1751,4 @@ Global
|
||||
|
||||
{BE5E9A22-1590-41D0-919B-8BFA26E70C62}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{5DE92F2D-B834-DD45-A95C-44AE99A61D37}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Migration: 002_add_enriched_sbom_store
|
||||
-- Category: startup
|
||||
-- Description: Store parsed SBOM documents for downstream queries.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS concelier.sbom_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
serial_number TEXT NOT NULL,
|
||||
artifact_digest TEXT,
|
||||
format TEXT NOT NULL CHECK (format IN ('cyclonedx', 'spdx')),
|
||||
spec_version TEXT NOT NULL,
|
||||
component_count INT NOT NULL DEFAULT 0,
|
||||
service_count INT NOT NULL DEFAULT 0,
|
||||
vulnerability_count INT NOT NULL DEFAULT 0,
|
||||
has_crypto BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
has_services BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
has_vulnerabilities BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sbom_json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_concelier_sbom_serial UNIQUE (serial_number),
|
||||
CONSTRAINT uq_concelier_sbom_artifact UNIQUE (artifact_digest)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_concelier_sbom_artifact_digest
|
||||
ON concelier.sbom_documents(artifact_digest)
|
||||
WHERE artifact_digest IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_concelier_sbom_has_crypto
|
||||
ON concelier.sbom_documents(has_crypto)
|
||||
WHERE has_crypto = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_concelier_sbom_has_services
|
||||
ON concelier.sbom_documents(has_services)
|
||||
WHERE has_services = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_concelier_sbom_has_vulns
|
||||
ON concelier.sbom_documents(has_vulnerabilities)
|
||||
WHERE has_vulnerabilities = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_concelier_sbom_updated
|
||||
ON concelier.sbom_documents(updated_at DESC);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Concelier: Add SBOM license index helpers
|
||||
-- Adds license IDs/expressions for fast license queries.
|
||||
|
||||
ALTER TABLE concelier.sbom_documents
|
||||
ADD COLUMN IF NOT EXISTS license_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS license_expressions TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sbom_documents_license_ids
|
||||
ON concelier.sbom_documents USING GIN (license_ids);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Concelier.SbomIntegration;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Advisories;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
@@ -57,6 +58,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IAdvisoryLinksetLookup>(sp => sp.GetRequiredService<IAdvisoryLinksetStore>());
|
||||
services.AddScoped<StorageContracts.IDocumentStore, PostgresDocumentStore>();
|
||||
services.AddScoped<StorageContracts.IDtoStore, PostgresDtoStore>();
|
||||
services.AddScoped<ISbomRepository, SbomRepository>();
|
||||
services.AddScoped<ExportingContracts.IExportStateStore, PostgresExportStateStore>();
|
||||
services.AddScoped<PsirtContracts.IPsirtFlagStore, PostgresPsirtFlagStore>();
|
||||
services.AddScoped<JpFlagsContracts.IJpFlagStore, PostgresJpFlagStore>();
|
||||
@@ -104,6 +106,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IAdvisoryLinksetLookup>(sp => sp.GetRequiredService<IAdvisoryLinksetStore>());
|
||||
services.AddScoped<StorageContracts.IDocumentStore, PostgresDocumentStore>();
|
||||
services.AddScoped<StorageContracts.IDtoStore, PostgresDtoStore>();
|
||||
services.AddScoped<ISbomRepository, SbomRepository>();
|
||||
services.AddScoped<ExportingContracts.IExportStateStore, PostgresExportStateStore>();
|
||||
services.AddScoped<PsirtContracts.IPsirtFlagStore, PostgresPsirtFlagStore>();
|
||||
services.AddScoped<JpFlagsContracts.IJpFlagStore, PostgresJpFlagStore>();
|
||||
|
||||
@@ -9,3 +9,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0230-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0230-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| CICD-VAL-SMOKE-001 | DONE | Smoke validation: restore reference summaries from raw payload. |
|
||||
| TASK-015-011 | DONE | Added enriched SBOM storage table + Postgres repository. |
|
||||
| TASK-015-007d | DONE | Added license indexes and repository queries for license inventory. |
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISbomRepository.cs
|
||||
// Sprint: SPRINT_20260119_015_Concelier_sbom_full_extraction
|
||||
// Task: TASK-015-011 - Enriched SBOM repository interface
|
||||
// Description: Storage contract for ParsedSbom persistence and lookup
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for storing and querying enriched ParsedSbom data.
|
||||
/// </summary>
|
||||
public interface ISbomRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a parsed SBOM by its serial number.
|
||||
/// </summary>
|
||||
Task<ParsedSbom?> GetBySerialNumberAsync(
|
||||
string serialNumber,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a parsed SBOM by the artifact digest.
|
||||
/// </summary>
|
||||
Task<ParsedSbom?> GetByArtifactDigestAsync(
|
||||
string digest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates a parsed SBOM.
|
||||
/// </summary>
|
||||
Task StoreAsync(ParsedSbom sbom, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets services extracted for an artifact.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ParsedService>> GetServicesForArtifactAsync(
|
||||
string artifactId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets components that include crypto properties for an artifact.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ParsedComponent>> GetComponentsWithCryptoAsync(
|
||||
string artifactId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets embedded vulnerabilities for an artifact.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ParsedVulnerability>> GetEmbeddedVulnerabilitiesAsync(
|
||||
string artifactId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all licenses referenced by an artifact.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ParsedLicense>> GetLicensesForArtifactAsync(
|
||||
string artifactId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets components that reference the given SPDX license ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ParsedComponent>> GetComponentsByLicenseAsync(
|
||||
string spdxId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets components without declared license data for an artifact.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ParsedComponent>> GetComponentsWithoutLicenseAsync(
|
||||
string artifactId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets components by license category for an artifact.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ParsedComponent>> GetComponentsByLicenseCategoryAsync(
|
||||
string artifactId,
|
||||
LicenseCategory category,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns license inventory summary information for an artifact.
|
||||
/// </summary>
|
||||
Task<LicenseInventorySummary> GetLicenseInventoryAsync(
|
||||
string artifactId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// License inventory summary for an artifact.
|
||||
/// </summary>
|
||||
public sealed record LicenseInventorySummary
|
||||
{
|
||||
public int TotalComponents { get; init; }
|
||||
public int ComponentsWithLicense { get; init; }
|
||||
public int ComponentsWithoutLicense { get; init; }
|
||||
public ImmutableDictionary<string, int> LicenseDistribution { get; init; } =
|
||||
ImmutableDictionary<string, int>.Empty;
|
||||
public ImmutableArray<string> UniqueLicenses { get; init; } = [];
|
||||
public ImmutableArray<string> Expressions { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ILicenseExpressionValidator.cs
|
||||
// Sprint: SPRINT_20260119_015_Concelier_sbom_full_extraction
|
||||
// Task: TASK-015-007c - SPDX license expression validation
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Licensing;
|
||||
|
||||
public interface ILicenseExpressionValidator
|
||||
{
|
||||
LicenseValidationResult Validate(ParsedLicenseExpression expression);
|
||||
LicenseValidationResult ValidateString(string spdxExpression);
|
||||
}
|
||||
|
||||
public sealed record LicenseValidationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public ImmutableArray<string> Errors { get; init; } = [];
|
||||
public ImmutableArray<string> Warnings { get; init; } = [];
|
||||
public ImmutableArray<string> ReferencedLicenses { get; init; } = [];
|
||||
public ImmutableArray<string> ReferencedExceptions { get; init; } = [];
|
||||
public ImmutableArray<string> DeprecatedLicenses { get; init; } = [];
|
||||
public ImmutableArray<string> UnknownLicenses { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SpdxLicenseExpressionValidator.cs
|
||||
// Sprint: SPRINT_20260119_015_Concelier_sbom_full_extraction
|
||||
// Task: TASK-015-007c - SPDX license expression validation
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Licensing;
|
||||
|
||||
public sealed class SpdxLicenseExpressionValidator : ILicenseExpressionValidator
|
||||
{
|
||||
private static readonly Lazy<SpdxLicenseCatalog> Catalog = new(LoadCatalog);
|
||||
|
||||
public LicenseValidationResult Validate(ParsedLicenseExpression expression)
|
||||
{
|
||||
if (expression is null)
|
||||
{
|
||||
return LicenseValidationResultBuilder.Invalid("License expression is null.");
|
||||
}
|
||||
|
||||
var context = new LicenseValidationContext();
|
||||
CollectExpression(expression, context);
|
||||
return LicenseValidationResultBuilder.FromContext(context);
|
||||
}
|
||||
|
||||
public LicenseValidationResult ValidateString(string spdxExpression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(spdxExpression))
|
||||
{
|
||||
return LicenseValidationResultBuilder.Invalid("License expression is empty.");
|
||||
}
|
||||
|
||||
var parser = new LicenseExpressionParser(spdxExpression);
|
||||
if (!parser.TryParse(out var parsed, out var error))
|
||||
{
|
||||
return LicenseValidationResultBuilder.Invalid(error ?? "Invalid SPDX license expression.");
|
||||
}
|
||||
|
||||
if (parsed is null)
|
||||
{
|
||||
return LicenseValidationResultBuilder.Invalid(error ?? "Invalid SPDX license expression.");
|
||||
}
|
||||
|
||||
return Validate(parsed);
|
||||
}
|
||||
|
||||
private static void CollectExpression(
|
||||
ParsedLicenseExpression expression,
|
||||
LicenseValidationContext context)
|
||||
{
|
||||
switch (expression)
|
||||
{
|
||||
case SimpleLicense simple:
|
||||
ValidateLicenseId(simple.Id, context);
|
||||
break;
|
||||
case OrLater orLater:
|
||||
ValidateLicenseId(orLater.LicenseId, context);
|
||||
break;
|
||||
case WithException withException:
|
||||
CollectExpression(withException.License, context);
|
||||
ValidateException(withException.Exception, context);
|
||||
break;
|
||||
case ConjunctiveSet conjunctive:
|
||||
foreach (var member in conjunctive.Members)
|
||||
{
|
||||
CollectExpression(member, context);
|
||||
}
|
||||
break;
|
||||
case DisjunctiveSet disjunctive:
|
||||
foreach (var member in disjunctive.Members)
|
||||
{
|
||||
CollectExpression(member, context);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateLicenseId(string licenseId, LicenseValidationContext context)
|
||||
{
|
||||
var normalized = NormalizeToken(licenseId);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
context.Errors.Add("License identifier is empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
context.ReferencedLicenses.Add(normalized);
|
||||
|
||||
if (IsSpecialLicense(normalized))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var catalog = Catalog.Value;
|
||||
if (IsLicenseRef(normalized))
|
||||
{
|
||||
context.UnknownLicenses.Add(normalized);
|
||||
context.Warnings.Add($"LicenseRef identifier is allowed but not listed: {normalized}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!catalog.LicenseIds.Contains(normalized))
|
||||
{
|
||||
context.UnknownLicenses.Add(normalized);
|
||||
context.Errors.Add($"Unknown SPDX license identifier: {normalized}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (catalog.DeprecatedLicenseIds.Contains(normalized))
|
||||
{
|
||||
context.DeprecatedLicenses.Add(normalized);
|
||||
context.Warnings.Add($"Deprecated SPDX license identifier: {normalized}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateException(string exceptionId, LicenseValidationContext context)
|
||||
{
|
||||
var normalized = NormalizeToken(exceptionId);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
context.Errors.Add("License exception identifier is empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
context.ReferencedExceptions.Add(normalized);
|
||||
|
||||
var catalog = Catalog.Value;
|
||||
if (!catalog.ExceptionIds.Contains(normalized))
|
||||
{
|
||||
context.Errors.Add($"Unknown SPDX license exception: {normalized}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeToken(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
|
||||
|
||||
private static bool IsSpecialLicense(string licenseId)
|
||||
=> string.Equals(licenseId, "NONE", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(licenseId, "NOASSERTION", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsLicenseRef(string licenseId)
|
||||
=> licenseId.StartsWith("LicenseRef-", StringComparison.Ordinal)
|
||||
|| licenseId.StartsWith("DocumentRef-", StringComparison.Ordinal);
|
||||
|
||||
private static SpdxLicenseCatalog LoadCatalog()
|
||||
{
|
||||
var assembly = typeof(SpdxLicenseExpressionValidator).Assembly;
|
||||
var licenseResource = "StellaOps.Concelier.SbomIntegration.Resources.spdx-license-list-3.21.json";
|
||||
var exceptionResource = "StellaOps.Concelier.SbomIntegration.Resources.spdx-license-exceptions-3.21.json";
|
||||
|
||||
var licenseCatalog = LoadLicenses(assembly, licenseResource);
|
||||
var exceptionIds = LoadExceptions(assembly, exceptionResource);
|
||||
|
||||
return licenseCatalog with
|
||||
{
|
||||
ExceptionIds = exceptionIds
|
||||
};
|
||||
}
|
||||
|
||||
private static SpdxLicenseCatalog LoadLicenses(Assembly assembly, string resourceName)
|
||||
{
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Missing embedded resource: {resourceName}");
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
|
||||
var version = GetStringProperty(document.RootElement, "licenseListVersion") ?? "unknown";
|
||||
if (!document.RootElement.TryGetProperty("licenses", out var licenses) ||
|
||||
licenses.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return new SpdxLicenseCatalog
|
||||
{
|
||||
Version = version,
|
||||
LicenseIds = ImmutableHashSet<string>.Empty,
|
||||
DeprecatedLicenseIds = ImmutableHashSet<string>.Empty,
|
||||
ExceptionIds = ImmutableHashSet<string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
var licenseIds = ImmutableHashSet.CreateBuilder<string>(StringComparer.Ordinal);
|
||||
var deprecated = ImmutableHashSet.CreateBuilder<string>(StringComparer.Ordinal);
|
||||
foreach (var entry in licenses.EnumerateArray())
|
||||
{
|
||||
var id = GetStringProperty(entry, "licenseId");
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
licenseIds.Add(id);
|
||||
if (GetBooleanProperty(entry, "isDeprecatedLicenseId"))
|
||||
{
|
||||
deprecated.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return new SpdxLicenseCatalog
|
||||
{
|
||||
Version = version,
|
||||
LicenseIds = licenseIds.ToImmutable(),
|
||||
DeprecatedLicenseIds = deprecated.ToImmutable(),
|
||||
ExceptionIds = ImmutableHashSet<string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableHashSet<string> LoadExceptions(Assembly assembly, string resourceName)
|
||||
{
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Missing embedded resource: {resourceName}");
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
|
||||
if (!document.RootElement.TryGetProperty("exceptions", out var exceptions) ||
|
||||
exceptions.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return ImmutableHashSet<string>.Empty;
|
||||
}
|
||||
|
||||
var exceptionIds = ImmutableHashSet.CreateBuilder<string>(StringComparer.Ordinal);
|
||||
foreach (var entry in exceptions.EnumerateArray())
|
||||
{
|
||||
var id = GetStringProperty(entry, "licenseExceptionId");
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
exceptionIds.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return exceptionIds.ToImmutable();
|
||||
}
|
||||
|
||||
private static string? GetStringProperty(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var prop) &&
|
||||
prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return prop.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool GetBooleanProperty(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var prop))
|
||||
{
|
||||
if (prop.ValueKind == JsonValueKind.True)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (prop.ValueKind == JsonValueKind.False)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed record SpdxLicenseCatalog
|
||||
{
|
||||
public required string Version { get; init; }
|
||||
public required ImmutableHashSet<string> LicenseIds { get; init; }
|
||||
public required ImmutableHashSet<string> DeprecatedLicenseIds { get; init; }
|
||||
public required ImmutableHashSet<string> ExceptionIds { get; init; }
|
||||
}
|
||||
|
||||
private sealed class LicenseValidationContext
|
||||
{
|
||||
public HashSet<string> ReferencedLicenses { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> ReferencedExceptions { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> DeprecatedLicenses { get; } = new(StringComparer.Ordinal);
|
||||
public HashSet<string> UnknownLicenses { get; } = new(StringComparer.Ordinal);
|
||||
public List<string> Errors { get; } = [];
|
||||
public List<string> Warnings { get; } = [];
|
||||
}
|
||||
|
||||
private static class LicenseValidationResultBuilder
|
||||
{
|
||||
public static LicenseValidationResult Invalid(string error)
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = [error]
|
||||
};
|
||||
}
|
||||
|
||||
public static LicenseValidationResult FromContext(LicenseValidationContext context)
|
||||
{
|
||||
var errors = ToSortedImmutable(context.Errors);
|
||||
var warnings = ToSortedImmutable(context.Warnings);
|
||||
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = errors.Length == 0,
|
||||
Errors = errors,
|
||||
Warnings = warnings,
|
||||
ReferencedLicenses = ToSortedImmutable(context.ReferencedLicenses),
|
||||
ReferencedExceptions = ToSortedImmutable(context.ReferencedExceptions),
|
||||
DeprecatedLicenses = ToSortedImmutable(context.DeprecatedLicenses),
|
||||
UnknownLicenses = ToSortedImmutable(context.UnknownLicenses)
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ToSortedImmutable(IEnumerable<string> values)
|
||||
{
|
||||
return values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LicenseExpressionParser
|
||||
{
|
||||
private readonly IReadOnlyList<Token> _tokens;
|
||||
private int _index;
|
||||
|
||||
public LicenseExpressionParser(string expression)
|
||||
{
|
||||
_tokens = Tokenize(expression);
|
||||
}
|
||||
|
||||
public bool TryParse(out ParsedLicenseExpression? expression, out string? error)
|
||||
{
|
||||
error = null;
|
||||
expression = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (_tokens.Count == 0)
|
||||
{
|
||||
error = "License expression is empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
expression = ParseOr();
|
||||
if (!IsAtEnd())
|
||||
{
|
||||
error = "Unexpected trailing tokens in license expression.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private ParsedLicenseExpression ParseOr()
|
||||
{
|
||||
var members = new List<ParsedLicenseExpression> { ParseAnd() };
|
||||
while (Match(TokenKind.Or))
|
||||
{
|
||||
members.Add(ParseAnd());
|
||||
}
|
||||
|
||||
return members.Count == 1
|
||||
? members[0]
|
||||
: new DisjunctiveSet(members.ToImmutableArray());
|
||||
}
|
||||
|
||||
private ParsedLicenseExpression ParseAnd()
|
||||
{
|
||||
var members = new List<ParsedLicenseExpression> { ParseWith() };
|
||||
while (Match(TokenKind.And))
|
||||
{
|
||||
members.Add(ParseWith());
|
||||
}
|
||||
|
||||
return members.Count == 1
|
||||
? members[0]
|
||||
: new ConjunctiveSet(members.ToImmutableArray());
|
||||
}
|
||||
|
||||
private ParsedLicenseExpression ParseWith()
|
||||
{
|
||||
var primary = ParsePrimary();
|
||||
if (!Match(TokenKind.With))
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var exception = Expect(TokenKind.Identifier);
|
||||
return new WithException(primary, exception.Value);
|
||||
}
|
||||
|
||||
private ParsedLicenseExpression ParsePrimary()
|
||||
{
|
||||
if (Match(TokenKind.LeftParen))
|
||||
{
|
||||
var inner = ParseOr();
|
||||
Expect(TokenKind.RightParen);
|
||||
return inner;
|
||||
}
|
||||
|
||||
var token = Expect(TokenKind.Identifier);
|
||||
return BuildLicense(token.Value);
|
||||
}
|
||||
|
||||
private static ParsedLicenseExpression BuildLicense(string value)
|
||||
{
|
||||
if (value.EndsWith("+", StringComparison.Ordinal) && value.Length > 1)
|
||||
{
|
||||
return new OrLater(value[..^1]);
|
||||
}
|
||||
|
||||
return new SimpleLicense(value);
|
||||
}
|
||||
|
||||
private bool Match(TokenKind kind)
|
||||
{
|
||||
if (IsAtEnd() || _tokens[_index].Kind != kind)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_index++;
|
||||
return true;
|
||||
}
|
||||
|
||||
private Token Expect(TokenKind kind)
|
||||
{
|
||||
if (IsAtEnd() || _tokens[_index].Kind != kind)
|
||||
{
|
||||
throw new FormatException("Invalid SPDX license expression.");
|
||||
}
|
||||
|
||||
return _tokens[_index++];
|
||||
}
|
||||
|
||||
private bool IsAtEnd() => _index >= _tokens.Count;
|
||||
|
||||
private static IReadOnlyList<Token> Tokenize(string expression)
|
||||
{
|
||||
var tokens = new List<Token>();
|
||||
var span = expression.AsSpan();
|
||||
var index = 0;
|
||||
while (index < span.Length)
|
||||
{
|
||||
var current = span[index];
|
||||
if (char.IsWhiteSpace(current))
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current == '(')
|
||||
{
|
||||
tokens.Add(new Token(TokenKind.LeftParen, "("));
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current == ')')
|
||||
{
|
||||
tokens.Add(new Token(TokenKind.RightParen, ")"));
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var start = index;
|
||||
while (index < span.Length &&
|
||||
!char.IsWhiteSpace(span[index]) &&
|
||||
span[index] != '(' &&
|
||||
span[index] != ')')
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
var value = span[start..index].ToString();
|
||||
tokens.Add(ToToken(value));
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static Token ToToken(string value)
|
||||
{
|
||||
if (string.Equals(value, "AND", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new Token(TokenKind.And, value);
|
||||
}
|
||||
|
||||
if (string.Equals(value, "OR", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new Token(TokenKind.Or, value);
|
||||
}
|
||||
|
||||
if (string.Equals(value, "WITH", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new Token(TokenKind.With, value);
|
||||
}
|
||||
|
||||
return new Token(TokenKind.Identifier, value);
|
||||
}
|
||||
|
||||
private readonly record struct Token(TokenKind Kind, string Value);
|
||||
|
||||
private enum TokenKind
|
||||
{
|
||||
Identifier,
|
||||
And,
|
||||
Or,
|
||||
With,
|
||||
LeftParen,
|
||||
RightParen
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,14 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Matching;
|
||||
|
||||
@@ -22,15 +25,27 @@ public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
|
||||
private readonly ICanonicalAdvisoryService _canonicalService;
|
||||
private readonly ILogger<SbomAdvisoryMatcher> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IVexConsumer? _vexConsumer;
|
||||
private readonly ISbomRepository? _sbomRepository;
|
||||
private readonly IVexConsumptionPolicyLoader? _policyLoader;
|
||||
private readonly VexConsumptionOptions _vexOptions;
|
||||
|
||||
public SbomAdvisoryMatcher(
|
||||
ICanonicalAdvisoryService canonicalService,
|
||||
ILogger<SbomAdvisoryMatcher> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
IVexConsumer? vexConsumer = null,
|
||||
ISbomRepository? sbomRepository = null,
|
||||
IVexConsumptionPolicyLoader? policyLoader = null,
|
||||
IOptions<VexConsumptionOptions>? vexOptions = null)
|
||||
{
|
||||
_canonicalService = canonicalService ?? throw new ArgumentNullException(nameof(canonicalService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_vexConsumer = vexConsumer;
|
||||
_sbomRepository = sbomRepository;
|
||||
_policyLoader = policyLoader;
|
||||
_vexOptions = vexOptions?.Value ?? new VexConsumptionOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -50,6 +65,9 @@ public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
|
||||
return [];
|
||||
}
|
||||
|
||||
var vexContext = await BuildVexContextAsync(sbomDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Matching {PurlCount} PURLs against canonical advisories", purlList.Count);
|
||||
|
||||
var matches = new ConcurrentBag<SbomAdvisoryMatch>();
|
||||
@@ -69,6 +87,7 @@ public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
|
||||
purl,
|
||||
reachabilityMap,
|
||||
deploymentMap,
|
||||
vexContext,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var match in purlMatches)
|
||||
@@ -155,6 +174,7 @@ public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
|
||||
string purl,
|
||||
IReadOnlyDictionary<string, bool>? reachabilityMap,
|
||||
IReadOnlyDictionary<string, bool>? deploymentMap,
|
||||
VexContext? vexContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -172,18 +192,33 @@ public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
|
||||
var matchMethod = DetermineMatchMethod(purl);
|
||||
var matchedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
return advisories.Select(advisory => new SbomAdvisoryMatch
|
||||
var results = new List<SbomAdvisoryMatch>();
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
Id = ComputeDeterministicMatchId(sbomDigest, purl, advisory.Id),
|
||||
SbomId = sbomId,
|
||||
SbomDigest = sbomDigest,
|
||||
CanonicalId = advisory.Id,
|
||||
Purl = purl,
|
||||
Method = matchMethod,
|
||||
IsReachable = isReachable,
|
||||
IsDeployed = isDeployed,
|
||||
MatchedAt = matchedAt
|
||||
}).ToList();
|
||||
if (ShouldFilterByVex(advisory, purl, vexContext))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Filtered advisory {CanonicalId} for PURL {Purl} due to VEX status",
|
||||
advisory.Id,
|
||||
purl);
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add(new SbomAdvisoryMatch
|
||||
{
|
||||
Id = ComputeDeterministicMatchId(sbomDigest, purl, advisory.Id),
|
||||
SbomId = sbomId,
|
||||
SbomDigest = sbomDigest,
|
||||
CanonicalId = advisory.Id,
|
||||
Purl = purl,
|
||||
Method = matchMethod,
|
||||
IsReachable = isReachable,
|
||||
IsDeployed = isDeployed,
|
||||
MatchedAt = matchedAt
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -293,4 +328,156 @@ public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(input))[..16];
|
||||
return new Guid(hashBytes);
|
||||
}
|
||||
|
||||
private async Task<VexContext?> BuildVexContextAsync(string sbomDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_vexConsumer is null || _sbomRepository is null || _policyLoader is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_vexOptions.Enabled || _vexOptions.IgnoreVex)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sbomDigest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sbom = await _sbomRepository.GetByArtifactDigestAsync(sbomDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (sbom is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var policy = await _policyLoader.LoadAsync(_vexOptions.PolicyPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
policy = ApplyOverrides(policy);
|
||||
|
||||
VexConsumptionResult result;
|
||||
if (_vexConsumer is VexConsumer consumer)
|
||||
{
|
||||
result = await consumer.ConsumeFromSbomAsync(sbom, policy, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await _vexConsumer.ConsumeAsync(sbom.Vulnerabilities, policy, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var lookup = result.Statements
|
||||
.GroupBy(statement => statement.VulnerabilityId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => SelectPreferredStatement(group.ToList()),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new VexContext(policy, lookup);
|
||||
}
|
||||
|
||||
private static ConsumedVexStatement SelectPreferredStatement(IReadOnlyList<ConsumedVexStatement> statements)
|
||||
{
|
||||
return statements
|
||||
.OrderByDescending(statement => statement.TrustLevel.ToRank())
|
||||
.ThenByDescending(statement => statement.Timestamp ?? DateTimeOffset.MinValue)
|
||||
.First();
|
||||
}
|
||||
|
||||
private VexConsumptionPolicy ApplyOverrides(VexConsumptionPolicy policy)
|
||||
{
|
||||
if (_vexOptions.TrustEmbeddedVex.HasValue)
|
||||
{
|
||||
policy = policy with { TrustEmbeddedVex = _vexOptions.TrustEmbeddedVex.Value };
|
||||
}
|
||||
|
||||
if (_vexOptions.MinimumTrustLevel.HasValue)
|
||||
{
|
||||
policy = policy with { MinimumTrustLevel = _vexOptions.MinimumTrustLevel.Value };
|
||||
}
|
||||
|
||||
if (_vexOptions.FilterNotAffected.HasValue)
|
||||
{
|
||||
policy = policy with { FilterNotAffected = _vexOptions.FilterNotAffected.Value };
|
||||
}
|
||||
|
||||
if (_vexOptions.ExternalVexSources is { Length: > 0 })
|
||||
{
|
||||
policy = policy with
|
||||
{
|
||||
MergePolicy = policy.MergePolicy with
|
||||
{
|
||||
ExternalSources = BuildExternalSources(_vexOptions.ExternalVexSources)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
private static ImmutableArray<VexExternalSource> BuildExternalSources(string[] sources)
|
||||
{
|
||||
return sources
|
||||
.Where(source => !string.IsNullOrWhiteSpace(source))
|
||||
.Select(source => new VexExternalSource
|
||||
{
|
||||
Type = "external",
|
||||
Url = source.Trim()
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static bool ShouldFilterByVex(
|
||||
CanonicalAdvisory advisory,
|
||||
string purl,
|
||||
VexContext? vexContext)
|
||||
{
|
||||
if (vexContext is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!vexContext.Statements.TryGetValue(advisory.Cve, out var statement))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!AppliesToComponent(statement, purl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return statement.Status == VexStatus.NotAffected && vexContext.Policy.FilterNotAffected;
|
||||
}
|
||||
|
||||
private static bool AppliesToComponent(ConsumedVexStatement statement, string purl)
|
||||
{
|
||||
if (statement.AffectedComponents.IsDefaultOrEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = NormalizePurl(purl);
|
||||
foreach (var component in statement.AffectedComponents)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(component))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(NormalizePurl(component), normalized, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed record VexContext(
|
||||
VexConsumptionPolicy Policy,
|
||||
IReadOnlyDictionary<string, ConsumedVexStatement> Statements);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed record ParsedSbom
|
||||
public ParsedDeclarations? Declarations { get; init; }
|
||||
public ParsedDefinitions? Definitions { get; init; }
|
||||
public ImmutableArray<ParsedAnnotation> Annotations { get; init; } = [];
|
||||
public ParsedSignature? Signature { get; init; }
|
||||
public required ParsedSbomMetadata Metadata { get; init; }
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ public sealed record ParsedSbomMetadata
|
||||
public string? Supplier { get; init; }
|
||||
public string? Manufacturer { get; init; }
|
||||
public ImmutableArray<string> Profiles { get; init; } = [];
|
||||
public ImmutableArray<string> SbomTypes { get; init; } = [];
|
||||
public ImmutableArray<ParsedNamespaceMapEntry> NamespaceMap { get; init; } = [];
|
||||
public ImmutableArray<string> Imports { get; init; } = [];
|
||||
public string? RootComponentRef { get; init; }
|
||||
@@ -76,12 +78,38 @@ public sealed record ParsedComponent
|
||||
public ParsedPedigree? Pedigree { get; init; }
|
||||
public ParsedCryptoProperties? CryptoProperties { get; init; }
|
||||
public ParsedModelCard? ModelCard { get; init; }
|
||||
public ParsedSwid? Swid { get; init; }
|
||||
public ParsedDatasetMetadata? DatasetMetadata { get; init; }
|
||||
public ParsedOrganization? Supplier { get; init; }
|
||||
public ParsedOrganization? Manufacturer { get; init; }
|
||||
public ComponentScope Scope { get; init; } = ComponentScope.Required;
|
||||
public bool Modified { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ParsedSwid
|
||||
{
|
||||
public string? TagId { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public int? TagVersion { get; init; }
|
||||
public bool? Patch { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ParsedDatasetMetadata
|
||||
{
|
||||
public string? DatasetType { get; init; }
|
||||
public string? DataCollectionProcess { get; init; }
|
||||
public string? DataPreprocessing { get; init; }
|
||||
public string? DatasetSize { get; init; }
|
||||
public string? IntendedUse { get; init; }
|
||||
public string? KnownBias { get; init; }
|
||||
public ImmutableArray<string> SensitivePersonalInformation { get; init; } = [];
|
||||
public string? Sensor { get; init; }
|
||||
public string? Availability { get; init; }
|
||||
public string? ConfidentialityLevel { get; init; }
|
||||
public bool? HasSensitivePersonalInformation { get; init; }
|
||||
}
|
||||
|
||||
public enum ComponentScope
|
||||
{
|
||||
Required,
|
||||
@@ -449,6 +477,17 @@ public sealed record ParsedModelParameters
|
||||
public string? EnergyConsumption { get; init; }
|
||||
public ImmutableDictionary<string, string> Hyperparameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
public string? InformationAboutApplication { get; init; }
|
||||
public string? InformationAboutTraining { get; init; }
|
||||
public string? Limitation { get; init; }
|
||||
public ImmutableArray<string> Metrics { get; init; } = [];
|
||||
public ImmutableArray<string> MetricDecisionThresholds { get; init; } = [];
|
||||
public string? ModelDataPreprocessing { get; init; }
|
||||
public string? ModelExplainability { get; init; }
|
||||
public string? SafetyRiskAssessment { get; init; }
|
||||
public ImmutableArray<string> SensitivePersonalInformation { get; init; } = [];
|
||||
public ImmutableArray<string> StandardCompliance { get; init; } = [];
|
||||
public bool? UseSensitivePersonalInformation { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ParsedDatasetRef
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"licenseListVersion": "3.21",
|
||||
"exceptions": [
|
||||
{
|
||||
"reference": "./389-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./389-exception.html",
|
||||
"referenceNumber": 48,
|
||||
"name": "389 Directory Server Exception",
|
||||
"licenseExceptionId": "389-exception",
|
||||
"seeAlso": [
|
||||
"http://directory.fedoraproject.org/wiki/GPL_Exception_License_Text",
|
||||
"https://web.archive.org/web/20080828121337/http://directory.fedoraproject.org/wiki/GPL_Exception_License_Text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Asterisk-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Asterisk-exception.html",
|
||||
"referenceNumber": 33,
|
||||
"name": "Asterisk exception",
|
||||
"licenseExceptionId": "Asterisk-exception",
|
||||
"seeAlso": [
|
||||
"https://github.com/asterisk/libpri/blob/7f91151e6bd10957c746c031c1f4a030e8146e9a/pri.c#L22",
|
||||
"https://github.com/asterisk/libss7/blob/03e81bcd0d28ff25d4c77c78351ddadc82ff5c3f/ss7.c#L24"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Autoconf-exception-2.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Autoconf-exception-2.0.html",
|
||||
"referenceNumber": 42,
|
||||
"name": "Autoconf exception 2.0",
|
||||
"licenseExceptionId": "Autoconf-exception-2.0",
|
||||
"seeAlso": [
|
||||
"http://ac-archive.sourceforge.net/doc/copyright.html",
|
||||
"http://ftp.gnu.org/gnu/autoconf/autoconf-2.59.tar.gz"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Autoconf-exception-3.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Autoconf-exception-3.0.html",
|
||||
"referenceNumber": 41,
|
||||
"name": "Autoconf exception 3.0",
|
||||
"licenseExceptionId": "Autoconf-exception-3.0",
|
||||
"seeAlso": [
|
||||
"http://www.gnu.org/licenses/autoconf-exception-3.0.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Autoconf-exception-generic.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Autoconf-exception-generic.html",
|
||||
"referenceNumber": 4,
|
||||
"name": "Autoconf generic exception",
|
||||
"licenseExceptionId": "Autoconf-exception-generic",
|
||||
"seeAlso": [
|
||||
"https://launchpad.net/ubuntu/precise/+source/xmltooling/+copyright",
|
||||
"https://tracker.debian.org/media/packages/s/sipwitch/copyright-1.9.15-3",
|
||||
"https://opensource.apple.com/source/launchd/launchd-258.1/launchd/compile.auto.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Autoconf-exception-macro.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Autoconf-exception-macro.html",
|
||||
"referenceNumber": 19,
|
||||
"name": "Autoconf macro exception",
|
||||
"licenseExceptionId": "Autoconf-exception-macro",
|
||||
"seeAlso": [
|
||||
"https://github.com/freedesktop/xorg-macros/blob/39f07f7db58ebbf3dcb64a2bf9098ed5cf3d1223/xorg-macros.m4.in",
|
||||
"https://www.gnu.org/software/autoconf-archive/ax_pthread.html",
|
||||
"https://launchpad.net/ubuntu/precise/+source/xmltooling/+copyright"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Bison-exception-2.2.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Bison-exception-2.2.html",
|
||||
"referenceNumber": 11,
|
||||
"name": "Bison exception 2.2",
|
||||
"licenseExceptionId": "Bison-exception-2.2",
|
||||
"seeAlso": [
|
||||
"http://git.savannah.gnu.org/cgit/bison.git/tree/data/yacc.c?id\u003d193d7c7054ba7197b0789e14965b739162319b5e#n141"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Bootloader-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Bootloader-exception.html",
|
||||
"referenceNumber": 50,
|
||||
"name": "Bootloader Distribution Exception",
|
||||
"licenseExceptionId": "Bootloader-exception",
|
||||
"seeAlso": [
|
||||
"https://github.com/pyinstaller/pyinstaller/blob/develop/COPYING.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Classpath-exception-2.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Classpath-exception-2.0.html",
|
||||
"referenceNumber": 36,
|
||||
"name": "Classpath exception 2.0",
|
||||
"licenseExceptionId": "Classpath-exception-2.0",
|
||||
"seeAlso": [
|
||||
"http://www.gnu.org/software/classpath/license.html",
|
||||
"https://fedoraproject.org/wiki/Licensing/GPL_Classpath_Exception"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./CLISP-exception-2.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./CLISP-exception-2.0.html",
|
||||
"referenceNumber": 9,
|
||||
"name": "CLISP exception 2.0",
|
||||
"licenseExceptionId": "CLISP-exception-2.0",
|
||||
"seeAlso": [
|
||||
"http://sourceforge.net/p/clisp/clisp/ci/default/tree/COPYRIGHT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./cryptsetup-OpenSSL-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./cryptsetup-OpenSSL-exception.html",
|
||||
"referenceNumber": 39,
|
||||
"name": "cryptsetup OpenSSL exception",
|
||||
"licenseExceptionId": "cryptsetup-OpenSSL-exception",
|
||||
"seeAlso": [
|
||||
"https://gitlab.com/cryptsetup/cryptsetup/-/blob/main/COPYING",
|
||||
"https://gitlab.nic.cz/datovka/datovka/-/blob/develop/COPYING",
|
||||
"https://github.com/nbs-system/naxsi/blob/951123ad456bdf5ac94e8d8819342fe3d49bc002/naxsi_src/naxsi_raw.c",
|
||||
"http://web.mit.edu/jgross/arch/amd64_deb60/bin/mosh"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./DigiRule-FOSS-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./DigiRule-FOSS-exception.html",
|
||||
"referenceNumber": 20,
|
||||
"name": "DigiRule FOSS License Exception",
|
||||
"licenseExceptionId": "DigiRule-FOSS-exception",
|
||||
"seeAlso": [
|
||||
"http://www.digirulesolutions.com/drupal/foss"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./eCos-exception-2.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./eCos-exception-2.0.html",
|
||||
"referenceNumber": 38,
|
||||
"name": "eCos exception 2.0",
|
||||
"licenseExceptionId": "eCos-exception-2.0",
|
||||
"seeAlso": [
|
||||
"http://ecos.sourceware.org/license-overview.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Fawkes-Runtime-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Fawkes-Runtime-exception.html",
|
||||
"referenceNumber": 8,
|
||||
"name": "Fawkes Runtime Exception",
|
||||
"licenseExceptionId": "Fawkes-Runtime-exception",
|
||||
"seeAlso": [
|
||||
"http://www.fawkesrobotics.org/about/license/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./FLTK-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./FLTK-exception.html",
|
||||
"referenceNumber": 18,
|
||||
"name": "FLTK exception",
|
||||
"licenseExceptionId": "FLTK-exception",
|
||||
"seeAlso": [
|
||||
"http://www.fltk.org/COPYING.php"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Font-exception-2.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Font-exception-2.0.html",
|
||||
"referenceNumber": 7,
|
||||
"name": "Font exception 2.0",
|
||||
"licenseExceptionId": "Font-exception-2.0",
|
||||
"seeAlso": [
|
||||
"http://www.gnu.org/licenses/gpl-faq.html#FontException"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./freertos-exception-2.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./freertos-exception-2.0.html",
|
||||
"referenceNumber": 47,
|
||||
"name": "FreeRTOS Exception 2.0",
|
||||
"licenseExceptionId": "freertos-exception-2.0",
|
||||
"seeAlso": [
|
||||
"https://web.archive.org/web/20060809182744/http://www.freertos.org/a00114.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./GCC-exception-2.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./GCC-exception-2.0.html",
|
||||
"referenceNumber": 54,
|
||||
"name": "GCC Runtime Library exception 2.0",
|
||||
"licenseExceptionId": "GCC-exception-2.0",
|
||||
"seeAlso": [
|
||||
"https://gcc.gnu.org/git/?p\u003dgcc.git;a\u003dblob;f\u003dgcc/libgcc1.c;h\u003d762f5143fc6eed57b6797c82710f3538aa52b40b;hb\u003dcb143a3ce4fb417c68f5fa2691a1b1b1053dfba9#l10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./GCC-exception-3.1.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./GCC-exception-3.1.html",
|
||||
"referenceNumber": 27,
|
||||
"name": "GCC Runtime Library exception 3.1",
|
||||
"licenseExceptionId": "GCC-exception-3.1",
|
||||
"seeAlso": [
|
||||
"http://www.gnu.org/licenses/gcc-exception-3.1.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./GNAT-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./GNAT-exception.html",
|
||||
"referenceNumber": 13,
|
||||
"name": "GNAT exception",
|
||||
"licenseExceptionId": "GNAT-exception",
|
||||
"seeAlso": [
|
||||
"https://github.com/AdaCore/florist/blob/master/libsrc/posix-configurable_file_limits.adb"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./gnu-javamail-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./gnu-javamail-exception.html",
|
||||
"referenceNumber": 34,
|
||||
"name": "GNU JavaMail exception",
|
||||
"licenseExceptionId": "gnu-javamail-exception",
|
||||
"seeAlso": [
|
||||
"http://www.gnu.org/software/classpathx/javamail/javamail.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./GPL-3.0-interface-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./GPL-3.0-interface-exception.html",
|
||||
"referenceNumber": 21,
|
||||
"name": "GPL-3.0 Interface Exception",
|
||||
"licenseExceptionId": "GPL-3.0-interface-exception",
|
||||
"seeAlso": [
|
||||
"https://www.gnu.org/licenses/gpl-faq.en.html#LinkingOverControlledInterface"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./GPL-3.0-linking-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./GPL-3.0-linking-exception.html",
|
||||
"referenceNumber": 1,
|
||||
"name": "GPL-3.0 Linking Exception",
|
||||
"licenseExceptionId": "GPL-3.0-linking-exception",
|
||||
"seeAlso": [
|
||||
"https://www.gnu.org/licenses/gpl-faq.en.html#GPLIncompatibleLibs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./GPL-3.0-linking-source-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./GPL-3.0-linking-source-exception.html",
|
||||
"referenceNumber": 37,
|
||||
"name": "GPL-3.0 Linking Exception (with Corresponding Source)",
|
||||
"licenseExceptionId": "GPL-3.0-linking-source-exception",
|
||||
"seeAlso": [
|
||||
"https://www.gnu.org/licenses/gpl-faq.en.html#GPLIncompatibleLibs",
|
||||
"https://github.com/mirror/wget/blob/master/src/http.c#L20"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./GPL-CC-1.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./GPL-CC-1.0.html",
|
||||
"referenceNumber": 52,
|
||||
"name": "GPL Cooperation Commitment 1.0",
|
||||
"licenseExceptionId": "GPL-CC-1.0",
|
||||
"seeAlso": [
|
||||
"https://github.com/gplcc/gplcc/blob/master/Project/COMMITMENT",
|
||||
"https://gplcc.github.io/gplcc/Project/README-PROJECT.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./GStreamer-exception-2005.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./GStreamer-exception-2005.html",
|
||||
"referenceNumber": 35,
|
||||
"name": "GStreamer Exception (2005)",
|
||||
"licenseExceptionId": "GStreamer-exception-2005",
|
||||
"seeAlso": [
|
||||
"https://gstreamer.freedesktop.org/documentation/frequently-asked-questions/licensing.html?gi-language\u003dc#licensing-of-applications-using-gstreamer"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./GStreamer-exception-2008.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./GStreamer-exception-2008.html",
|
||||
"referenceNumber": 30,
|
||||
"name": "GStreamer Exception (2008)",
|
||||
"licenseExceptionId": "GStreamer-exception-2008",
|
||||
"seeAlso": [
|
||||
"https://gstreamer.freedesktop.org/documentation/frequently-asked-questions/licensing.html?gi-language\u003dc#licensing-of-applications-using-gstreamer"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./i2p-gpl-java-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./i2p-gpl-java-exception.html",
|
||||
"referenceNumber": 40,
|
||||
"name": "i2p GPL+Java Exception",
|
||||
"licenseExceptionId": "i2p-gpl-java-exception",
|
||||
"seeAlso": [
|
||||
"http://geti2p.net/en/get-involved/develop/licenses#java_exception"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./KiCad-libraries-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./KiCad-libraries-exception.html",
|
||||
"referenceNumber": 28,
|
||||
"name": "KiCad Libraries Exception",
|
||||
"licenseExceptionId": "KiCad-libraries-exception",
|
||||
"seeAlso": [
|
||||
"https://www.kicad.org/libraries/license/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./LGPL-3.0-linking-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./LGPL-3.0-linking-exception.html",
|
||||
"referenceNumber": 2,
|
||||
"name": "LGPL-3.0 Linking Exception",
|
||||
"licenseExceptionId": "LGPL-3.0-linking-exception",
|
||||
"seeAlso": [
|
||||
"https://raw.githubusercontent.com/go-xmlpath/xmlpath/v2/LICENSE",
|
||||
"https://github.com/goamz/goamz/blob/master/LICENSE",
|
||||
"https://github.com/juju/errors/blob/master/LICENSE"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./libpri-OpenH323-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./libpri-OpenH323-exception.html",
|
||||
"referenceNumber": 32,
|
||||
"name": "libpri OpenH323 exception",
|
||||
"licenseExceptionId": "libpri-OpenH323-exception",
|
||||
"seeAlso": [
|
||||
"https://github.com/asterisk/libpri/blob/1.6.0/README#L19-L22"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Libtool-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Libtool-exception.html",
|
||||
"referenceNumber": 17,
|
||||
"name": "Libtool Exception",
|
||||
"licenseExceptionId": "Libtool-exception",
|
||||
"seeAlso": [
|
||||
"http://git.savannah.gnu.org/cgit/libtool.git/tree/m4/libtool.m4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Linux-syscall-note.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Linux-syscall-note.html",
|
||||
"referenceNumber": 49,
|
||||
"name": "Linux Syscall Note",
|
||||
"licenseExceptionId": "Linux-syscall-note",
|
||||
"seeAlso": [
|
||||
"https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/COPYING"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./LLGPL.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./LLGPL.html",
|
||||
"referenceNumber": 3,
|
||||
"name": "LLGPL Preamble",
|
||||
"licenseExceptionId": "LLGPL",
|
||||
"seeAlso": [
|
||||
"http://opensource.franz.com/preamble.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./LLVM-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./LLVM-exception.html",
|
||||
"referenceNumber": 14,
|
||||
"name": "LLVM Exception",
|
||||
"licenseExceptionId": "LLVM-exception",
|
||||
"seeAlso": [
|
||||
"http://llvm.org/foundation/relicensing/LICENSE.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./LZMA-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./LZMA-exception.html",
|
||||
"referenceNumber": 55,
|
||||
"name": "LZMA exception",
|
||||
"licenseExceptionId": "LZMA-exception",
|
||||
"seeAlso": [
|
||||
"http://nsis.sourceforge.net/Docs/AppendixI.html#I.6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./mif-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./mif-exception.html",
|
||||
"referenceNumber": 53,
|
||||
"name": "Macros and Inline Functions Exception",
|
||||
"licenseExceptionId": "mif-exception",
|
||||
"seeAlso": [
|
||||
"http://www.scs.stanford.edu/histar/src/lib/cppsup/exception",
|
||||
"http://dev.bertos.org/doxygen/",
|
||||
"https://www.threadingbuildingblocks.org/licensing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Nokia-Qt-exception-1.1.json",
|
||||
"isDeprecatedLicenseId": true,
|
||||
"detailsUrl": "./Nokia-Qt-exception-1.1.html",
|
||||
"referenceNumber": 31,
|
||||
"name": "Nokia Qt LGPL exception 1.1",
|
||||
"licenseExceptionId": "Nokia-Qt-exception-1.1",
|
||||
"seeAlso": [
|
||||
"https://www.keepassx.org/dev/projects/keepassx/repository/revisions/b8dfb9cc4d5133e0f09cd7533d15a4f1c19a40f2/entry/LICENSE.NOKIA-LGPL-EXCEPTION"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./OCaml-LGPL-linking-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./OCaml-LGPL-linking-exception.html",
|
||||
"referenceNumber": 29,
|
||||
"name": "OCaml LGPL Linking Exception",
|
||||
"licenseExceptionId": "OCaml-LGPL-linking-exception",
|
||||
"seeAlso": [
|
||||
"https://caml.inria.fr/ocaml/license.en.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./OCCT-exception-1.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./OCCT-exception-1.0.html",
|
||||
"referenceNumber": 15,
|
||||
"name": "Open CASCADE Exception 1.0",
|
||||
"licenseExceptionId": "OCCT-exception-1.0",
|
||||
"seeAlso": [
|
||||
"http://www.opencascade.com/content/licensing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./OpenJDK-assembly-exception-1.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./OpenJDK-assembly-exception-1.0.html",
|
||||
"referenceNumber": 24,
|
||||
"name": "OpenJDK Assembly exception 1.0",
|
||||
"licenseExceptionId": "OpenJDK-assembly-exception-1.0",
|
||||
"seeAlso": [
|
||||
"http://openjdk.java.net/legal/assembly-exception.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./openvpn-openssl-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./openvpn-openssl-exception.html",
|
||||
"referenceNumber": 43,
|
||||
"name": "OpenVPN OpenSSL Exception",
|
||||
"licenseExceptionId": "openvpn-openssl-exception",
|
||||
"seeAlso": [
|
||||
"http://openvpn.net/index.php/license.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./PS-or-PDF-font-exception-20170817.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./PS-or-PDF-font-exception-20170817.html",
|
||||
"referenceNumber": 45,
|
||||
"name": "PS/PDF font exception (2017-08-17)",
|
||||
"licenseExceptionId": "PS-or-PDF-font-exception-20170817",
|
||||
"seeAlso": [
|
||||
"https://github.com/ArtifexSoftware/urw-base35-fonts/blob/65962e27febc3883a17e651cdb23e783668c996f/LICENSE"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./QPL-1.0-INRIA-2004-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./QPL-1.0-INRIA-2004-exception.html",
|
||||
"referenceNumber": 44,
|
||||
"name": "INRIA QPL 1.0 2004 variant exception",
|
||||
"licenseExceptionId": "QPL-1.0-INRIA-2004-exception",
|
||||
"seeAlso": [
|
||||
"https://git.frama-c.com/pub/frama-c/-/blob/master/licenses/Q_MODIFIED_LICENSE",
|
||||
"https://github.com/maranget/hevea/blob/master/LICENSE"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Qt-GPL-exception-1.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Qt-GPL-exception-1.0.html",
|
||||
"referenceNumber": 10,
|
||||
"name": "Qt GPL exception 1.0",
|
||||
"licenseExceptionId": "Qt-GPL-exception-1.0",
|
||||
"seeAlso": [
|
||||
"http://code.qt.io/cgit/qt/qtbase.git/tree/LICENSE.GPL3-EXCEPT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Qt-LGPL-exception-1.1.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Qt-LGPL-exception-1.1.html",
|
||||
"referenceNumber": 16,
|
||||
"name": "Qt LGPL exception 1.1",
|
||||
"licenseExceptionId": "Qt-LGPL-exception-1.1",
|
||||
"seeAlso": [
|
||||
"http://code.qt.io/cgit/qt/qtbase.git/tree/LGPL_EXCEPTION.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Qwt-exception-1.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Qwt-exception-1.0.html",
|
||||
"referenceNumber": 51,
|
||||
"name": "Qwt exception 1.0",
|
||||
"licenseExceptionId": "Qwt-exception-1.0",
|
||||
"seeAlso": [
|
||||
"http://qwt.sourceforge.net/qwtlicense.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./SHL-2.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./SHL-2.0.html",
|
||||
"referenceNumber": 26,
|
||||
"name": "Solderpad Hardware License v2.0",
|
||||
"licenseExceptionId": "SHL-2.0",
|
||||
"seeAlso": [
|
||||
"https://solderpad.org/licenses/SHL-2.0/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./SHL-2.1.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./SHL-2.1.html",
|
||||
"referenceNumber": 23,
|
||||
"name": "Solderpad Hardware License v2.1",
|
||||
"licenseExceptionId": "SHL-2.1",
|
||||
"seeAlso": [
|
||||
"https://solderpad.org/licenses/SHL-2.1/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./SWI-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./SWI-exception.html",
|
||||
"referenceNumber": 22,
|
||||
"name": "SWI exception",
|
||||
"licenseExceptionId": "SWI-exception",
|
||||
"seeAlso": [
|
||||
"https://github.com/SWI-Prolog/packages-clpqr/blob/bfa80b9270274f0800120d5b8e6fef42ac2dc6a5/clpqr/class.pl"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Swift-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Swift-exception.html",
|
||||
"referenceNumber": 46,
|
||||
"name": "Swift Exception",
|
||||
"licenseExceptionId": "Swift-exception",
|
||||
"seeAlso": [
|
||||
"https://swift.org/LICENSE.txt",
|
||||
"https://github.com/apple/swift-package-manager/blob/7ab2275f447a5eb37497ed63a9340f8a6d1e488b/LICENSE.txt#L205"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./u-boot-exception-2.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./u-boot-exception-2.0.html",
|
||||
"referenceNumber": 5,
|
||||
"name": "U-Boot exception 2.0",
|
||||
"licenseExceptionId": "u-boot-exception-2.0",
|
||||
"seeAlso": [
|
||||
"http://git.denx.de/?p\u003du-boot.git;a\u003dblob;f\u003dLicenses/Exceptions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./Universal-FOSS-exception-1.0.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./Universal-FOSS-exception-1.0.html",
|
||||
"referenceNumber": 12,
|
||||
"name": "Universal FOSS Exception, Version 1.0",
|
||||
"licenseExceptionId": "Universal-FOSS-exception-1.0",
|
||||
"seeAlso": [
|
||||
"https://oss.oracle.com/licenses/universal-foss-exception/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./vsftpd-openssl-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./vsftpd-openssl-exception.html",
|
||||
"referenceNumber": 56,
|
||||
"name": "vsftpd OpenSSL exception",
|
||||
"licenseExceptionId": "vsftpd-openssl-exception",
|
||||
"seeAlso": [
|
||||
"https://git.stg.centos.org/source-git/vsftpd/blob/f727873674d9c9cd7afcae6677aa782eb54c8362/f/LICENSE",
|
||||
"https://launchpad.net/debian/squeeze/+source/vsftpd/+copyright",
|
||||
"https://github.com/richardcochran/vsftpd/blob/master/COPYING"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./WxWindows-exception-3.1.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./WxWindows-exception-3.1.html",
|
||||
"referenceNumber": 25,
|
||||
"name": "WxWindows Library Exception 3.1",
|
||||
"licenseExceptionId": "WxWindows-exception-3.1",
|
||||
"seeAlso": [
|
||||
"http://www.opensource.org/licenses/WXwindows"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reference": "./x11vnc-openssl-exception.json",
|
||||
"isDeprecatedLicenseId": false,
|
||||
"detailsUrl": "./x11vnc-openssl-exception.html",
|
||||
"referenceNumber": 6,
|
||||
"name": "x11vnc OpenSSL Exception",
|
||||
"licenseExceptionId": "x11vnc-openssl-exception",
|
||||
"seeAlso": [
|
||||
"https://github.com/LibVNC/x11vnc/blob/master/src/8to24.c#L22"
|
||||
]
|
||||
}
|
||||
],
|
||||
"releaseDate": "2023-06-18"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,14 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration;
|
||||
|
||||
@@ -22,15 +25,27 @@ public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
|
||||
private readonly ICanonicalAdvisoryService _canonicalService;
|
||||
private readonly ILogger<SbomAdvisoryMatcher> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IVexConsumer? _vexConsumer;
|
||||
private readonly ISbomRepository? _sbomRepository;
|
||||
private readonly IVexConsumptionPolicyLoader? _policyLoader;
|
||||
private readonly VexConsumptionOptions _vexOptions;
|
||||
|
||||
public SbomAdvisoryMatcher(
|
||||
ICanonicalAdvisoryService canonicalService,
|
||||
ILogger<SbomAdvisoryMatcher> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
IVexConsumer? vexConsumer = null,
|
||||
ISbomRepository? sbomRepository = null,
|
||||
IVexConsumptionPolicyLoader? policyLoader = null,
|
||||
IOptions<VexConsumptionOptions>? vexOptions = null)
|
||||
{
|
||||
_canonicalService = canonicalService ?? throw new ArgumentNullException(nameof(canonicalService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_vexConsumer = vexConsumer;
|
||||
_sbomRepository = sbomRepository;
|
||||
_policyLoader = policyLoader;
|
||||
_vexOptions = vexOptions?.Value ?? new VexConsumptionOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -50,6 +65,9 @@ public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
|
||||
return [];
|
||||
}
|
||||
|
||||
var vexContext = await BuildVexContextAsync(sbomDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Matching {PurlCount} PURLs against canonical advisories", purlList.Count);
|
||||
|
||||
var matches = new ConcurrentBag<SbomAdvisoryMatch>();
|
||||
@@ -69,6 +87,7 @@ public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
|
||||
purl,
|
||||
reachabilityMap,
|
||||
deploymentMap,
|
||||
vexContext,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var match in purlMatches)
|
||||
@@ -155,6 +174,7 @@ public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
|
||||
string purl,
|
||||
IReadOnlyDictionary<string, bool>? reachabilityMap,
|
||||
IReadOnlyDictionary<string, bool>? deploymentMap,
|
||||
VexContext? vexContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -173,18 +193,33 @@ public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
|
||||
|
||||
var matchedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
return advisories.Select(advisory => new SbomAdvisoryMatch
|
||||
var results = new List<SbomAdvisoryMatch>();
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
Id = ComputeDeterministicMatchId(sbomDigest, purl, advisory.Id),
|
||||
SbomId = sbomId,
|
||||
SbomDigest = sbomDigest,
|
||||
CanonicalId = advisory.Id,
|
||||
Purl = purl,
|
||||
Method = matchMethod,
|
||||
IsReachable = isReachable,
|
||||
IsDeployed = isDeployed,
|
||||
MatchedAt = matchedAt
|
||||
}).ToList();
|
||||
if (ShouldFilterByVex(advisory, purl, vexContext))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Filtered advisory {CanonicalId} for PURL {Purl} due to VEX status",
|
||||
advisory.Id,
|
||||
purl);
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add(new SbomAdvisoryMatch
|
||||
{
|
||||
Id = ComputeDeterministicMatchId(sbomDigest, purl, advisory.Id),
|
||||
SbomId = sbomId,
|
||||
SbomDigest = sbomDigest,
|
||||
CanonicalId = advisory.Id,
|
||||
Purl = purl,
|
||||
Method = matchMethod,
|
||||
IsReachable = isReachable,
|
||||
IsDeployed = isDeployed,
|
||||
MatchedAt = matchedAt
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -294,4 +329,156 @@ public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(input))[..16];
|
||||
return new Guid(hashBytes);
|
||||
}
|
||||
|
||||
private async Task<VexContext?> BuildVexContextAsync(string sbomDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_vexConsumer is null || _sbomRepository is null || _policyLoader is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_vexOptions.Enabled || _vexOptions.IgnoreVex)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sbomDigest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sbom = await _sbomRepository.GetByArtifactDigestAsync(sbomDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (sbom is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var policy = await _policyLoader.LoadAsync(_vexOptions.PolicyPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
policy = ApplyOverrides(policy);
|
||||
|
||||
VexConsumptionResult result;
|
||||
if (_vexConsumer is VexConsumer consumer)
|
||||
{
|
||||
result = await consumer.ConsumeFromSbomAsync(sbom, policy, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await _vexConsumer.ConsumeAsync(sbom.Vulnerabilities, policy, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var lookup = result.Statements
|
||||
.GroupBy(statement => statement.VulnerabilityId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => SelectPreferredStatement(group.ToList()),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new VexContext(policy, lookup);
|
||||
}
|
||||
|
||||
private static ConsumedVexStatement SelectPreferredStatement(IReadOnlyList<ConsumedVexStatement> statements)
|
||||
{
|
||||
return statements
|
||||
.OrderByDescending(statement => statement.TrustLevel.ToRank())
|
||||
.ThenByDescending(statement => statement.Timestamp ?? DateTimeOffset.MinValue)
|
||||
.First();
|
||||
}
|
||||
|
||||
private VexConsumptionPolicy ApplyOverrides(VexConsumptionPolicy policy)
|
||||
{
|
||||
if (_vexOptions.TrustEmbeddedVex.HasValue)
|
||||
{
|
||||
policy = policy with { TrustEmbeddedVex = _vexOptions.TrustEmbeddedVex.Value };
|
||||
}
|
||||
|
||||
if (_vexOptions.MinimumTrustLevel.HasValue)
|
||||
{
|
||||
policy = policy with { MinimumTrustLevel = _vexOptions.MinimumTrustLevel.Value };
|
||||
}
|
||||
|
||||
if (_vexOptions.FilterNotAffected.HasValue)
|
||||
{
|
||||
policy = policy with { FilterNotAffected = _vexOptions.FilterNotAffected.Value };
|
||||
}
|
||||
|
||||
if (_vexOptions.ExternalVexSources is { Length: > 0 })
|
||||
{
|
||||
policy = policy with
|
||||
{
|
||||
MergePolicy = policy.MergePolicy with
|
||||
{
|
||||
ExternalSources = BuildExternalSources(_vexOptions.ExternalVexSources)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
private static ImmutableArray<VexExternalSource> BuildExternalSources(string[] sources)
|
||||
{
|
||||
return sources
|
||||
.Where(source => !string.IsNullOrWhiteSpace(source))
|
||||
.Select(source => new VexExternalSource
|
||||
{
|
||||
Type = "external",
|
||||
Url = source.Trim()
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static bool ShouldFilterByVex(
|
||||
CanonicalAdvisory advisory,
|
||||
string purl,
|
||||
VexContext? vexContext)
|
||||
{
|
||||
if (vexContext is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!vexContext.Statements.TryGetValue(advisory.Cve, out var statement))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!AppliesToComponent(statement, purl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return statement.Status == VexStatus.NotAffected && vexContext.Policy.FilterNotAffected;
|
||||
}
|
||||
|
||||
private static bool AppliesToComponent(ConsumedVexStatement statement, string purl)
|
||||
{
|
||||
if (statement.AffectedComponents.IsDefaultOrEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = NormalizePurl(purl);
|
||||
foreach (var component in statement.AffectedComponents)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(component))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(NormalizePurl(component), normalized, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed record VexContext(
|
||||
VexConsumptionPolicy Policy,
|
||||
IReadOnlyDictionary<string, ConsumedVexStatement> Statements);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Concelier.SbomIntegration.Events;
|
||||
using StellaOps.Concelier.SbomIntegration.Index;
|
||||
using StellaOps.Concelier.SbomIntegration.Licensing;
|
||||
using StellaOps.Concelier.SbomIntegration.Matching;
|
||||
using StellaOps.Concelier.SbomIntegration.Parsing;
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration;
|
||||
|
||||
@@ -28,7 +30,17 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
// Register parser
|
||||
services.TryAddSingleton<ISbomParser, SbomParser>();
|
||||
services.TryAddSingleton<IParsedSbomParser, ParsedSbomParser>();
|
||||
services.TryAddSingleton<IParsedSbomParser, ParsedSbomParser>();
|
||||
services.TryAddSingleton<ILicenseExpressionValidator, SpdxLicenseExpressionValidator>();
|
||||
services.AddOptions<VexConsumptionOptions>();
|
||||
services.TryAddSingleton<IVexConsumptionPolicyLoader, VexConsumptionPolicyLoader>();
|
||||
services.TryAddSingleton<IVexTrustEvaluator, VexTrustEvaluator>();
|
||||
services.TryAddSingleton<IVexConflictResolver, VexConflictResolver>();
|
||||
services.TryAddSingleton<IVexMerger, VexMerger>();
|
||||
services.TryAddSingleton<IVexStatementExtractor, CycloneDxVexExtractor>();
|
||||
services.TryAddSingleton<IVexStatementExtractor, SpdxVexExtractor>();
|
||||
services.TryAddSingleton<IVexConsumer, VexConsumer>();
|
||||
services.TryAddSingleton<IVexConsumptionReporter, VexConsumptionReporter>();
|
||||
|
||||
// Register PURL index (requires Valkey connection)
|
||||
services.TryAddSingleton<IPurlCanonicalIndex, ValkeyPurlCanonicalIndex>();
|
||||
@@ -53,7 +65,17 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
// Register parser
|
||||
services.TryAddSingleton<ISbomParser, SbomParser>();
|
||||
services.TryAddSingleton<IParsedSbomParser, ParsedSbomParser>();
|
||||
services.TryAddSingleton<IParsedSbomParser, ParsedSbomParser>();
|
||||
services.TryAddSingleton<ILicenseExpressionValidator, SpdxLicenseExpressionValidator>();
|
||||
services.AddOptions<VexConsumptionOptions>();
|
||||
services.TryAddSingleton<IVexConsumptionPolicyLoader, VexConsumptionPolicyLoader>();
|
||||
services.TryAddSingleton<IVexTrustEvaluator, VexTrustEvaluator>();
|
||||
services.TryAddSingleton<IVexConflictResolver, VexConflictResolver>();
|
||||
services.TryAddSingleton<IVexMerger, VexMerger>();
|
||||
services.TryAddSingleton<IVexStatementExtractor, CycloneDxVexExtractor>();
|
||||
services.TryAddSingleton<IVexStatementExtractor, SpdxVexExtractor>();
|
||||
services.TryAddSingleton<IVexConsumer, VexConsumer>();
|
||||
services.TryAddSingleton<IVexConsumptionReporter, VexConsumptionReporter>();
|
||||
|
||||
// Register PURL index (requires Valkey connection)
|
||||
services.TryAddSingleton<IPurlCanonicalIndex, ValkeyPurlCanonicalIndex>();
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\spdx-license-list-3.21.json" />
|
||||
<EmbeddedResource Include="Resources\spdx-license-exceptions-3.21.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -9,15 +9,28 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0237-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0237-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0237-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| TASK-015-001 | DOING | ParsedSbom model scaffolding. |
|
||||
| TASK-015-002 | DOING | ParsedService model scaffolding. |
|
||||
| TASK-015-003 | DOING | ParsedCryptoProperties model scaffolding. |
|
||||
| TASK-015-004 | DOING | ParsedModelCard model scaffolding. |
|
||||
| TASK-015-005 | DOING | CycloneDX formulation parsing + tests added; SPDX build parsing added. |
|
||||
| TASK-015-006 | DOING | ParsedVulnerability/VEX model scaffolding. |
|
||||
| TASK-015-007 | DOING | ParsedLicense model scaffolding. |
|
||||
| TASK-015-007a | DOING | CycloneDX license extraction expansion. |
|
||||
| TASK-015-007b | DOING | SPDX licensing profile extraction expansion. |
|
||||
| TASK-015-008 | DOING | CycloneDX extraction now covers formulation; tests updated. |
|
||||
| TASK-015-009 | DOING | ParsedSbomParser SPDX 3.0.1 extraction baseline + build profile. |
|
||||
| TASK-015-010 | DOING | ParsedSbom adapter + framework reference added; Artifact.Infrastructure build errors block tests. |
|
||||
| TASK-015-001 | DONE | ParsedSbom model covers CycloneDX/SPDX concepts with immutable collections. |
|
||||
| TASK-015-002 | DONE | ParsedService + ParsedDataFlow model covers nested services and flows. |
|
||||
| TASK-015-003 | DONE | ParsedCryptoProperties model covers algorithm/certificate/protocol details. |
|
||||
| TASK-015-004 | DONE | ParsedModelCard model covers AI profile inputs, metrics, and considerations. |
|
||||
| TASK-015-005 | DONE | ParsedFormulation + ParsedBuildInfo modeled and parsed for CycloneDX/SPDX. |
|
||||
| TASK-015-006 | DONE | ParsedVulnerability/VEX parsing (CycloneDX + SPDX) complete. |
|
||||
| TASK-015-007 | DONE | ParsedLicense model with AST expressions and terms. |
|
||||
| TASK-015-007a | DONE | CycloneDX license extraction covers expressions, terms, and text. |
|
||||
| TASK-015-007b | DONE | SPDX licensing profile extraction expansion. |
|
||||
| TASK-015-007c | DONE | SPDX license expression validator + embedded license lists. |
|
||||
| TASK-015-007d | DONE | Added license query contract + inventory summary types. |
|
||||
| TASK-015-008 | DONE | CycloneDX extraction now covers compositions, annotations, declarations/definitions, signature, and swid; tests updated. |
|
||||
| TASK-015-009 | DONE | SPDX 3.0.1 extraction now covers AI/dataset/file/snippet/sbomType; tests updated. |
|
||||
| TASK-015-010 | DONE | CycloneDxExtractor now exposes ParsedSbom extraction while preserving legacy metadata API. |
|
||||
| TASK-015-011 | DONE | ISbomRepository contract added for enriched SBOM storage. |
|
||||
| TASK-020-001 | DONE | VEX consumption interfaces and models established. |
|
||||
| TASK-020-002 | DONE | CycloneDX VEX extractor maps vulnerabilities to statements. |
|
||||
| TASK-020-003 | DONE | SPDX VEX extractor maps Security profile assessments. |
|
||||
| TASK-020-004 | DONE | Trust evaluator enforces signer/timestamp rules. |
|
||||
| TASK-020-005 | DONE | Conflict resolver implements resolution strategies. |
|
||||
| TASK-020-006 | DONE | VEX merger supports union/intersection/priority modes. |
|
||||
| TASK-020-007 | DONE | VEX consumption policy defaults + loader added. |
|
||||
| TASK-020-008 | DONE | SbomAdvisoryMatcher filters NotAffected VEX entries. |
|
||||
| TASK-020-009 | DONE | VEX pipeline integrated with matcher and policy options. |
|
||||
| TASK-020-010 | DONE | VEX consumption reporter outputs JSON/SARIF/text. |
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
public interface IVexConsumer
|
||||
{
|
||||
Task<VexConsumptionResult> ConsumeAsync(
|
||||
IReadOnlyList<ParsedVulnerability> sbomVulnerabilities,
|
||||
VexConsumptionPolicy policy,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<MergedVulnerabilityStatus> MergeWithExternalVexAsync(
|
||||
IReadOnlyList<ParsedVulnerability> sbomVex,
|
||||
IReadOnlyList<VexStatement> externalVex,
|
||||
VexMergePolicy mergePolicy,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
public interface IVexConflictResolver
|
||||
{
|
||||
VexConflictResolution Resolve(
|
||||
string vulnerabilityId,
|
||||
IReadOnlyList<VexStatement> statements,
|
||||
VexConflictResolutionStrategy strategy);
|
||||
}
|
||||
|
||||
public sealed class VexConflictResolver : IVexConflictResolver
|
||||
{
|
||||
public VexConflictResolution Resolve(
|
||||
string vulnerabilityId,
|
||||
IReadOnlyList<VexStatement> statements,
|
||||
VexConflictResolutionStrategy strategy)
|
||||
{
|
||||
if (statements.Count == 0)
|
||||
{
|
||||
return new VexConflictResolution
|
||||
{
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
Strategy = strategy,
|
||||
Candidates = []
|
||||
};
|
||||
}
|
||||
|
||||
var orderedCandidates = statements
|
||||
.OrderBy(s => s.Status)
|
||||
.ThenBy(s => s.Source)
|
||||
.ThenBy(s => s.VulnerabilityId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
var selected = strategy switch
|
||||
{
|
||||
VexConflictResolutionStrategy.HighestTrust => SelectByTrust(statements),
|
||||
VexConflictResolutionStrategy.ProducerWins => SelectByProducer(statements),
|
||||
VexConflictResolutionStrategy.MostSpecific => SelectBySpecificity(statements),
|
||||
_ => SelectByTimestamp(statements)
|
||||
};
|
||||
|
||||
return new VexConflictResolution
|
||||
{
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
Strategy = strategy,
|
||||
Candidates = orderedCandidates,
|
||||
Selected = selected
|
||||
};
|
||||
}
|
||||
|
||||
private static VexStatement SelectByTrust(IEnumerable<VexStatement> statements)
|
||||
{
|
||||
return statements
|
||||
.OrderByDescending(statement => statement.TrustLevel.ToRank())
|
||||
.ThenByDescending(statement => statement.Timestamp ?? DateTimeOffset.MinValue)
|
||||
.First();
|
||||
}
|
||||
|
||||
private static VexStatement SelectByProducer(IEnumerable<VexStatement> statements)
|
||||
{
|
||||
var producer = statements
|
||||
.Where(statement => statement.IsProducerStatement || statement.Source == VexSource.SbomEmbedded)
|
||||
.OrderByDescending(statement => statement.TrustLevel.ToRank())
|
||||
.ThenByDescending(statement => statement.Timestamp ?? DateTimeOffset.MinValue)
|
||||
.FirstOrDefault();
|
||||
|
||||
return producer ?? SelectByTrust(statements);
|
||||
}
|
||||
|
||||
private static VexStatement SelectBySpecificity(IEnumerable<VexStatement> statements)
|
||||
{
|
||||
return statements
|
||||
.OrderBy(statement => statement.AffectedComponents.IsDefaultOrEmpty
|
||||
? int.MaxValue
|
||||
: statement.AffectedComponents.Length)
|
||||
.ThenByDescending(statement => statement.TrustLevel.ToRank())
|
||||
.ThenByDescending(statement => statement.Timestamp ?? DateTimeOffset.MinValue)
|
||||
.First();
|
||||
}
|
||||
|
||||
private static VexStatement SelectByTimestamp(IEnumerable<VexStatement> statements)
|
||||
{
|
||||
return statements
|
||||
.OrderByDescending(statement => statement.Timestamp ?? DateTimeOffset.MinValue)
|
||||
.ThenByDescending(statement => statement.TrustLevel.ToRank())
|
||||
.First();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
public sealed class VexConsumer : IVexConsumer
|
||||
{
|
||||
private readonly IVexTrustEvaluator _trustEvaluator;
|
||||
private readonly IVexMerger _merger;
|
||||
private readonly IReadOnlyList<IVexStatementExtractor> _extractors;
|
||||
|
||||
public VexConsumer(
|
||||
IVexTrustEvaluator trustEvaluator,
|
||||
IVexMerger merger,
|
||||
IEnumerable<IVexStatementExtractor> extractors)
|
||||
{
|
||||
_trustEvaluator = trustEvaluator ?? throw new ArgumentNullException(nameof(trustEvaluator));
|
||||
_merger = merger ?? throw new ArgumentNullException(nameof(merger));
|
||||
_extractors = extractors?.ToList() ?? throw new ArgumentNullException(nameof(extractors));
|
||||
}
|
||||
|
||||
public Task<VexConsumptionResult> ConsumeAsync(
|
||||
IReadOnlyList<ParsedVulnerability> sbomVulnerabilities,
|
||||
VexConsumptionPolicy policy,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbomVulnerabilities);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var statements = VexStatementMapper.Map(sbomVulnerabilities, VexSource.SbomEmbedded);
|
||||
return Task.FromResult(EvaluateStatements(statements, policy));
|
||||
}
|
||||
|
||||
public Task<MergedVulnerabilityStatus> MergeWithExternalVexAsync(
|
||||
IReadOnlyList<ParsedVulnerability> sbomVex,
|
||||
IReadOnlyList<VexStatement> externalVex,
|
||||
VexMergePolicy mergePolicy,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbomVex);
|
||||
ArgumentNullException.ThrowIfNull(externalVex);
|
||||
ArgumentNullException.ThrowIfNull(mergePolicy);
|
||||
|
||||
var embeddedStatements = VexStatementMapper.Map(sbomVex, VexSource.SbomEmbedded)
|
||||
.Select(statement => statement with
|
||||
{
|
||||
TrustLevel = _trustEvaluator.Evaluate(statement, VexConsumptionPolicyDefaults.Default).TrustLevel
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var externalStatements = externalVex
|
||||
.Select(statement => statement with
|
||||
{
|
||||
TrustLevel = _trustEvaluator.Evaluate(statement, VexConsumptionPolicyDefaults.Default).TrustLevel
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var merged = _merger.Merge(
|
||||
embeddedStatements,
|
||||
externalStatements,
|
||||
mergePolicy,
|
||||
mergePolicy.ConflictStrategy);
|
||||
|
||||
return Task.FromResult(merged);
|
||||
}
|
||||
|
||||
public Task<VexConsumptionResult> ConsumeFromSbomAsync(
|
||||
ParsedSbom sbom,
|
||||
VexConsumptionPolicy policy,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbom);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var extractor = _extractors.FirstOrDefault(x => x.CanHandle(sbom));
|
||||
var statements = extractor is null
|
||||
? VexStatementMapper.Map(sbom.Vulnerabilities, VexSource.SbomEmbedded)
|
||||
: extractor.Extract(sbom);
|
||||
|
||||
return Task.FromResult(EvaluateStatements(statements, policy));
|
||||
}
|
||||
|
||||
private VexConsumptionResult EvaluateStatements(
|
||||
IReadOnlyList<VexStatement> statements,
|
||||
VexConsumptionPolicy policy)
|
||||
{
|
||||
var warnings = new List<VexConsumptionWarning>();
|
||||
var consumed = new List<ConsumedVexStatement>();
|
||||
|
||||
foreach (var statement in statements)
|
||||
{
|
||||
var evaluation = _trustEvaluator.Evaluate(statement, policy);
|
||||
warnings.AddRange(evaluation.Warnings);
|
||||
|
||||
var trust = evaluation.TrustLevel;
|
||||
if (statement.Status == VexStatus.NotAffected)
|
||||
{
|
||||
if (policy.JustificationRequirements.RequireJustificationForNotAffected
|
||||
&& !IsAcceptedJustification(statement.Justification, policy.JustificationRequirements.AcceptedJustifications))
|
||||
{
|
||||
trust = VexTrustLevel.Untrusted;
|
||||
warnings.Add(BuildWarning(
|
||||
"vex.justification.missing",
|
||||
"NotAffected VEX statement lacks an accepted justification.",
|
||||
statement));
|
||||
}
|
||||
}
|
||||
|
||||
if (trust.ToRank() < policy.MinimumTrustLevel.ToRank())
|
||||
{
|
||||
warnings.Add(BuildWarning(
|
||||
"vex.trust.too_low",
|
||||
"VEX statement trust level is below the minimum policy threshold.",
|
||||
statement));
|
||||
continue;
|
||||
}
|
||||
|
||||
consumed.Add(new ConsumedVexStatement
|
||||
{
|
||||
VulnerabilityId = statement.VulnerabilityId,
|
||||
Status = statement.Status,
|
||||
Justification = statement.Justification,
|
||||
ActionStatement = statement.ActionStatement,
|
||||
AffectedComponents = statement.AffectedComponents,
|
||||
Timestamp = statement.Timestamp,
|
||||
Source = statement.Source,
|
||||
TrustLevel = trust
|
||||
});
|
||||
}
|
||||
|
||||
var overallTrust = consumed.Count == 0
|
||||
? VexTrustLevel.Untrusted
|
||||
: consumed.OrderBy(item => item.TrustLevel.ToRank()).First().TrustLevel;
|
||||
|
||||
return new VexConsumptionResult
|
||||
{
|
||||
Statements = consumed.ToImmutableArray(),
|
||||
Warnings = warnings.ToImmutableArray(),
|
||||
OverallTrustLevel = overallTrust
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsAcceptedJustification(
|
||||
VexJustification? justification,
|
||||
ImmutableArray<string> accepted)
|
||||
{
|
||||
if (accepted.IsDefaultOrEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (justification is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = NormalizeToken(justification.Value.ToString());
|
||||
return accepted.Any(value => NormalizeToken(value) == normalized);
|
||||
}
|
||||
|
||||
private static string NormalizeToken(string value)
|
||||
{
|
||||
var normalized = new string(value
|
||||
.Where(ch => ch != '_' && ch != '-' && !char.IsWhiteSpace(ch))
|
||||
.ToArray());
|
||||
return normalized.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static VexConsumptionWarning BuildWarning(
|
||||
string code,
|
||||
string message,
|
||||
VexStatement statement)
|
||||
{
|
||||
return new VexConsumptionWarning
|
||||
{
|
||||
Code = code,
|
||||
Message = message,
|
||||
VulnerabilityId = statement.VulnerabilityId,
|
||||
Source = statement.Source
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
public sealed record VexConsumptionResult
|
||||
{
|
||||
public ImmutableArray<ConsumedVexStatement> Statements { get; init; } = [];
|
||||
public ImmutableArray<VexConsumptionWarning> Warnings { get; init; } = [];
|
||||
public VexTrustLevel OverallTrustLevel { get; init; } = VexTrustLevel.Untrusted;
|
||||
}
|
||||
|
||||
public sealed record ConsumedVexStatement
|
||||
{
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required VexStatus Status { get; init; }
|
||||
public VexJustification? Justification { get; init; }
|
||||
public string? ActionStatement { get; init; }
|
||||
public ImmutableArray<string> AffectedComponents { get; init; } = [];
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public VexSource Source { get; init; }
|
||||
public VexTrustLevel TrustLevel { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VexStatement
|
||||
{
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required VexStatus Status { get; init; }
|
||||
public VexJustification? Justification { get; init; }
|
||||
public string? ActionStatement { get; init; }
|
||||
public ImmutableArray<string> AffectedComponents { get; init; } = [];
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public VexSource Source { get; init; }
|
||||
public VexTrustLevel TrustLevel { get; init; } = VexTrustLevel.Unverified;
|
||||
public string? Issuer { get; init; }
|
||||
public string? DocumentId { get; init; }
|
||||
public bool IsProducerStatement { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VexConsumptionWarning
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public string? VulnerabilityId { get; init; }
|
||||
public VexSource? Source { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VexAwareMatchResult
|
||||
{
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string ComponentPurl { get; init; }
|
||||
public VexStatus? VexStatus { get; init; }
|
||||
public VexJustification? Justification { get; init; }
|
||||
public VexSource? VexSource { get; init; }
|
||||
public bool FilteredByVex { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MergedVulnerabilityStatus
|
||||
{
|
||||
public ImmutableArray<VexStatement> Statements { get; init; } = [];
|
||||
public ImmutableArray<VexConflictResolution> Conflicts { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record VexConflictResolution
|
||||
{
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required VexConflictResolutionStrategy Strategy { get; init; }
|
||||
public ImmutableArray<VexStatement> Candidates { get; init; } = [];
|
||||
public VexStatement? Selected { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
public enum VexStatus
|
||||
{
|
||||
NotAffected,
|
||||
Affected,
|
||||
Fixed,
|
||||
UnderInvestigation
|
||||
}
|
||||
|
||||
public enum VexSource
|
||||
{
|
||||
SbomEmbedded,
|
||||
External,
|
||||
Merged
|
||||
}
|
||||
|
||||
public enum VexTrustLevel
|
||||
{
|
||||
Verified,
|
||||
Trusted,
|
||||
Unverified,
|
||||
Untrusted
|
||||
}
|
||||
|
||||
public enum VexConflictResolutionStrategy
|
||||
{
|
||||
MostRecent,
|
||||
HighestTrust,
|
||||
ProducerWins,
|
||||
MostSpecific
|
||||
}
|
||||
|
||||
public enum VexMergeMode
|
||||
{
|
||||
Union,
|
||||
Intersection,
|
||||
ExternalPriority,
|
||||
EmbeddedPriority
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
public sealed class VexConsumptionOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public bool IgnoreVex { get; set; }
|
||||
public string? PolicyPath { get; set; }
|
||||
public bool? TrustEmbeddedVex { get; set; }
|
||||
public VexTrustLevel? MinimumTrustLevel { get; set; }
|
||||
public bool? FilterNotAffected { get; set; }
|
||||
public string[]? ExternalVexSources { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
public sealed record VexConsumptionPolicy
|
||||
{
|
||||
public bool TrustEmbeddedVex { get; init; } = true;
|
||||
public VexTrustLevel MinimumTrustLevel { get; init; } = VexTrustLevel.Unverified;
|
||||
public bool FilterNotAffected { get; init; } = true;
|
||||
public SignatureRequirements SignatureRequirements { get; init; } = new();
|
||||
public TimestampRequirements TimestampRequirements { get; init; } = new();
|
||||
public ConflictResolutionPolicy ConflictResolution { get; init; } = new();
|
||||
public VexMergePolicy MergePolicy { get; init; } = new();
|
||||
public JustificationRequirements JustificationRequirements { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record SignatureRequirements
|
||||
{
|
||||
public bool RequireSignedVex { get; init; }
|
||||
public ImmutableArray<string> TrustedSigners { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record TimestampRequirements
|
||||
{
|
||||
public int MaxAgeHours { get; init; } = 720;
|
||||
public bool RequireTimestamp { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed record ConflictResolutionPolicy
|
||||
{
|
||||
public VexConflictResolutionStrategy Strategy { get; init; } = VexConflictResolutionStrategy.MostRecent;
|
||||
public bool LogConflicts { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed record VexMergePolicy
|
||||
{
|
||||
public VexMergeMode Mode { get; init; } = VexMergeMode.Union;
|
||||
public ImmutableArray<VexExternalSource> ExternalSources { get; init; } = [];
|
||||
public VexConflictResolutionStrategy ConflictStrategy { get; init; } = VexConflictResolutionStrategy.MostRecent;
|
||||
}
|
||||
|
||||
public sealed record VexExternalSource
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Url { get; init; }
|
||||
}
|
||||
|
||||
public sealed record JustificationRequirements
|
||||
{
|
||||
public bool RequireJustificationForNotAffected { get; init; } = true;
|
||||
public ImmutableArray<string> AcceptedJustifications { get; init; } = [];
|
||||
}
|
||||
|
||||
public static class VexConsumptionPolicyDefaults
|
||||
{
|
||||
public static VexConsumptionPolicy Default { get; } = new()
|
||||
{
|
||||
TrustEmbeddedVex = true,
|
||||
MinimumTrustLevel = VexTrustLevel.Unverified,
|
||||
FilterNotAffected = true,
|
||||
SignatureRequirements = new SignatureRequirements
|
||||
{
|
||||
RequireSignedVex = false,
|
||||
TrustedSigners = []
|
||||
},
|
||||
TimestampRequirements = new TimestampRequirements
|
||||
{
|
||||
MaxAgeHours = 720,
|
||||
RequireTimestamp = true
|
||||
},
|
||||
ConflictResolution = new ConflictResolutionPolicy
|
||||
{
|
||||
Strategy = VexConflictResolutionStrategy.MostRecent,
|
||||
LogConflicts = true
|
||||
},
|
||||
MergePolicy = new VexMergePolicy
|
||||
{
|
||||
Mode = VexMergeMode.Union,
|
||||
ExternalSources = [],
|
||||
ConflictStrategy = VexConflictResolutionStrategy.MostRecent
|
||||
},
|
||||
JustificationRequirements = new JustificationRequirements
|
||||
{
|
||||
RequireJustificationForNotAffected = true,
|
||||
AcceptedJustifications = [
|
||||
"component_not_present",
|
||||
"vulnerable_code_not_present",
|
||||
"vulnerable_code_not_in_execute_path",
|
||||
"inline_mitigations_already_exist"
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
public interface IVexConsumptionPolicyLoader
|
||||
{
|
||||
Task<VexConsumptionPolicy> LoadAsync(string? path, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed class VexConsumptionPolicyLoader : IVexConsumptionPolicyLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
|
||||
private readonly IDeserializer _yamlDeserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
public async Task<VexConsumptionPolicy> LoadAsync(string? path, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return VexConsumptionPolicyDefaults.Default;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(path).ToLowerInvariant();
|
||||
await using var stream = File.OpenRead(path);
|
||||
|
||||
return extension switch
|
||||
{
|
||||
".yaml" or ".yml" => LoadFromYaml(stream),
|
||||
_ => await LoadFromJsonAsync(stream, ct).ConfigureAwait(false)
|
||||
};
|
||||
}
|
||||
|
||||
private VexConsumptionPolicy LoadFromYaml(Stream stream)
|
||||
{
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
|
||||
var yamlObject = _yamlDeserializer.Deserialize(reader);
|
||||
if (yamlObject is null)
|
||||
{
|
||||
return VexConsumptionPolicyDefaults.Default;
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Serialize(yamlObject);
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
return ExtractPolicy(document.RootElement);
|
||||
}
|
||||
|
||||
private static async Task<VexConsumptionPolicy> LoadFromJsonAsync(Stream stream, CancellationToken ct)
|
||||
{
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct)
|
||||
.ConfigureAwait(false);
|
||||
return ExtractPolicy(document.RootElement);
|
||||
}
|
||||
|
||||
private static VexConsumptionPolicy ExtractPolicy(JsonElement root)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty("vexConsumptionPolicy", out var policyElement))
|
||||
{
|
||||
return JsonSerializer.Deserialize<VexConsumptionPolicy>(policyElement, JsonOptions)
|
||||
?? VexConsumptionPolicyDefaults.Default;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<VexConsumptionPolicy>(root, JsonOptions)
|
||||
?? VexConsumptionPolicyDefaults.Default;
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
public interface IVexConsumptionReporter
|
||||
{
|
||||
string ToJson(VexConsumptionReport report);
|
||||
string ToText(VexConsumptionReport report);
|
||||
string ToSarif(VexConsumptionReport report);
|
||||
}
|
||||
|
||||
public sealed record VexConsumptionReport
|
||||
{
|
||||
public ImmutableArray<ConsumedVexStatement> Statements { get; init; } = [];
|
||||
public ImmutableArray<VexConsumptionWarning> Warnings { get; init; } = [];
|
||||
public ImmutableArray<VexConflictResolution> Conflicts { get; init; } = [];
|
||||
public ImmutableArray<VexAwareMatchResult> Matches { get; init; } = [];
|
||||
public VexTrustLevel OverallTrustLevel { get; init; } = VexTrustLevel.Untrusted;
|
||||
}
|
||||
|
||||
public sealed class VexConsumptionReporter : IVexConsumptionReporter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public string ToJson(VexConsumptionReport report)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
var payload = BuildReportPayload(report);
|
||||
return JsonSerializer.Serialize(payload, JsonOptions);
|
||||
}
|
||||
|
||||
public string ToText(VexConsumptionReport report)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("VEX Consumption Report");
|
||||
builder.AppendLine($"Statements: {report.Statements.Length}");
|
||||
builder.AppendLine($"Warnings: {report.Warnings.Length}");
|
||||
builder.AppendLine($"Conflicts: {report.Conflicts.Length}");
|
||||
builder.AppendLine($"Overall trust: {report.OverallTrustLevel}");
|
||||
builder.AppendLine();
|
||||
|
||||
foreach (var statement in report.Statements
|
||||
.OrderBy(s => s.VulnerabilityId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.AppendLine($"- {statement.VulnerabilityId}: {statement.Status} ({statement.TrustLevel})");
|
||||
if (!statement.AffectedComponents.IsDefaultOrEmpty)
|
||||
{
|
||||
builder.AppendLine($" Components: {string.Join(", ", statement.AffectedComponents)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(statement.ActionStatement))
|
||||
{
|
||||
builder.AppendLine($" Action: {statement.ActionStatement}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!report.Warnings.IsDefaultOrEmpty)
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Warnings:");
|
||||
foreach (var warning in report.Warnings)
|
||||
{
|
||||
builder.AppendLine($"- {warning.Code}: {warning.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public string ToSarif(VexConsumptionReport report)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
|
||||
var results = report.Statements
|
||||
.Where(statement => statement.Status != VexStatus.NotAffected)
|
||||
.OrderBy(statement => statement.VulnerabilityId, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(statement => new
|
||||
{
|
||||
ruleId = $"vex-{statement.Status.ToString().ToLowerInvariant()}",
|
||||
level = statement.Status == VexStatus.Affected ? "error" : "warning",
|
||||
message = new
|
||||
{
|
||||
text = $"{statement.VulnerabilityId} status: {statement.Status}"
|
||||
},
|
||||
properties = new
|
||||
{
|
||||
trustLevel = statement.TrustLevel.ToString(),
|
||||
source = statement.Source.ToString(),
|
||||
justification = statement.Justification?.ToString(),
|
||||
action = statement.ActionStatement
|
||||
}
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var sarif = new Dictionary<string, object?>
|
||||
{
|
||||
["version"] = "2.1.0",
|
||||
["$schema"] = "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json",
|
||||
["runs"] = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tool = new
|
||||
{
|
||||
driver = new
|
||||
{
|
||||
name = "StellaOps.VexConsumption",
|
||||
informationUri = "https://stella-ops.org",
|
||||
version = "1.0"
|
||||
}
|
||||
},
|
||||
results
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(sarif, JsonOptions);
|
||||
}
|
||||
|
||||
private static object BuildReportPayload(VexConsumptionReport report)
|
||||
{
|
||||
var trustBreakdown = report.Statements
|
||||
.GroupBy(statement => statement.TrustLevel)
|
||||
.OrderBy(group => group.Key.ToRank())
|
||||
.ToDictionary(group => group.Key.ToString(), group => group.Count());
|
||||
|
||||
return new
|
||||
{
|
||||
report.OverallTrustLevel,
|
||||
trustBreakdown,
|
||||
statements = report.Statements
|
||||
.OrderBy(statement => statement.VulnerabilityId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(statement => statement.Status)
|
||||
.Select(statement => new
|
||||
{
|
||||
statement.VulnerabilityId,
|
||||
statement.Status,
|
||||
statement.Justification,
|
||||
statement.ActionStatement,
|
||||
statement.AffectedComponents,
|
||||
statement.Timestamp,
|
||||
statement.Source,
|
||||
statement.TrustLevel
|
||||
}),
|
||||
matches = report.Matches
|
||||
.OrderBy(match => match.VulnerabilityId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(match => match.ComponentPurl, StringComparer.OrdinalIgnoreCase),
|
||||
conflicts = report.Conflicts
|
||||
.OrderBy(conflict => conflict.VulnerabilityId, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(conflict => new
|
||||
{
|
||||
conflict.VulnerabilityId,
|
||||
conflict.Strategy,
|
||||
Selected = conflict.Selected?.Status,
|
||||
conflict.Notes
|
||||
}),
|
||||
warnings = report.Warnings
|
||||
.OrderBy(warning => warning.Code, StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
public interface IVexStatementExtractor
|
||||
{
|
||||
bool CanHandle(ParsedSbom sbom);
|
||||
ImmutableArray<VexStatement> Extract(ParsedSbom sbom);
|
||||
}
|
||||
|
||||
public sealed class CycloneDxVexExtractor : IVexStatementExtractor
|
||||
{
|
||||
public bool CanHandle(ParsedSbom sbom)
|
||||
{
|
||||
return sbom.Format.Equals("CycloneDX", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public ImmutableArray<VexStatement> Extract(ParsedSbom sbom)
|
||||
{
|
||||
var map = BuildBomRefLookup(sbom.Components);
|
||||
return VexStatementMapper.Map(sbom.Vulnerabilities, VexSource.SbomEmbedded, map);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string?> BuildBomRefLookup(
|
||||
ImmutableArray<ParsedComponent> components)
|
||||
{
|
||||
var map = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var component in components)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(component.BomRef))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
map.TryAdd(component.BomRef.Trim(), component.Purl);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SpdxVexExtractor : IVexStatementExtractor
|
||||
{
|
||||
public bool CanHandle(ParsedSbom sbom)
|
||||
{
|
||||
return sbom.Format.Equals("SPDX", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public ImmutableArray<VexStatement> Extract(ParsedSbom sbom)
|
||||
{
|
||||
var map = BuildBomRefLookup(sbom.Components);
|
||||
return VexStatementMapper.Map(sbom.Vulnerabilities, VexSource.SbomEmbedded, map);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string?> BuildBomRefLookup(
|
||||
ImmutableArray<ParsedComponent> components)
|
||||
{
|
||||
var map = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var component in components)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(component.BomRef))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
map.TryAdd(component.BomRef.Trim(), component.Purl);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
public interface IVexMerger
|
||||
{
|
||||
MergedVulnerabilityStatus Merge(
|
||||
IReadOnlyList<VexStatement> embeddedStatements,
|
||||
IReadOnlyList<VexStatement> externalStatements,
|
||||
VexMergePolicy mergePolicy,
|
||||
VexConflictResolutionStrategy conflictStrategy);
|
||||
}
|
||||
|
||||
public sealed class VexMerger : IVexMerger
|
||||
{
|
||||
private readonly IVexConflictResolver _conflictResolver;
|
||||
|
||||
public VexMerger(IVexConflictResolver conflictResolver)
|
||||
{
|
||||
_conflictResolver = conflictResolver ?? throw new ArgumentNullException(nameof(conflictResolver));
|
||||
}
|
||||
|
||||
public MergedVulnerabilityStatus Merge(
|
||||
IReadOnlyList<VexStatement> embeddedStatements,
|
||||
IReadOnlyList<VexStatement> externalStatements,
|
||||
VexMergePolicy mergePolicy,
|
||||
VexConflictResolutionStrategy conflictStrategy)
|
||||
{
|
||||
var resolved = new List<VexStatement>();
|
||||
var conflicts = new List<VexConflictResolution>();
|
||||
|
||||
var embeddedByVuln = GroupByVulnerability(embeddedStatements);
|
||||
var externalByVuln = GroupByVulnerability(externalStatements);
|
||||
var allKeys = embeddedByVuln.Keys.Union(externalByVuln.Keys, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var vulnerabilityId in allKeys.OrderBy(key => key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var embedded = embeddedByVuln.TryGetValue(vulnerabilityId, out var embeddedList)
|
||||
? embeddedList
|
||||
: new List<VexStatement>();
|
||||
var external = externalByVuln.TryGetValue(vulnerabilityId, out var externalList)
|
||||
? externalList
|
||||
: new List<VexStatement>();
|
||||
|
||||
var candidates = SelectCandidates(mergePolicy.Mode, embedded, external);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolution = _conflictResolver.Resolve(vulnerabilityId, candidates, conflictStrategy);
|
||||
if (HasConflicts(candidates) && resolution.Candidates.Length > 1)
|
||||
{
|
||||
conflicts.Add(resolution);
|
||||
}
|
||||
|
||||
if (resolution.Selected is not null)
|
||||
{
|
||||
resolved.Add(resolution.Selected);
|
||||
}
|
||||
}
|
||||
|
||||
return new MergedVulnerabilityStatus
|
||||
{
|
||||
Statements = resolved.ToImmutableArray(),
|
||||
Conflicts = conflicts.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<VexStatement>> GroupByVulnerability(
|
||||
IReadOnlyList<VexStatement> statements)
|
||||
{
|
||||
var map = new Dictionary<string, List<VexStatement>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var statement in statements)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(statement.VulnerabilityId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!map.TryGetValue(statement.VulnerabilityId, out var list))
|
||||
{
|
||||
list = new List<VexStatement>();
|
||||
map[statement.VulnerabilityId] = list;
|
||||
}
|
||||
|
||||
list.Add(statement);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<VexStatement> SelectCandidates(
|
||||
VexMergeMode mode,
|
||||
IReadOnlyList<VexStatement> embedded,
|
||||
IReadOnlyList<VexStatement> external)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
VexMergeMode.Intersection => embedded.Count > 0 && external.Count > 0
|
||||
? embedded.Concat(external).ToList()
|
||||
: new List<VexStatement>(),
|
||||
VexMergeMode.ExternalPriority => external.Count > 0
|
||||
? external
|
||||
: embedded,
|
||||
VexMergeMode.EmbeddedPriority => embedded.Count > 0
|
||||
? embedded
|
||||
: external,
|
||||
_ => embedded.Concat(external).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasConflicts(IReadOnlyList<VexStatement> statements)
|
||||
{
|
||||
if (statements.Count <= 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var distinct = statements
|
||||
.Select(statement => statement.Status)
|
||||
.Distinct()
|
||||
.Count();
|
||||
return distinct > 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
internal static class VexStatementMapper
|
||||
{
|
||||
public static ImmutableArray<VexStatement> Map(
|
||||
IReadOnlyList<ParsedVulnerability> vulnerabilities,
|
||||
VexSource source,
|
||||
IReadOnlyDictionary<string, string?>? bomRefToPurl = null)
|
||||
{
|
||||
if (vulnerabilities.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<VexStatement>();
|
||||
foreach (var vulnerability in vulnerabilities)
|
||||
{
|
||||
if (vulnerability.Analysis is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var status = MapStatus(vulnerability.Analysis.State);
|
||||
if (status is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var affected = BuildAffectedComponents(vulnerability.Affects, bomRefToPurl);
|
||||
var timestamp = vulnerability.Analysis.LastUpdated
|
||||
?? vulnerability.Analysis.FirstIssued
|
||||
?? vulnerability.Updated
|
||||
?? vulnerability.Published;
|
||||
|
||||
var actionStatement = BuildActionStatement(vulnerability.Analysis);
|
||||
|
||||
builder.Add(new VexStatement
|
||||
{
|
||||
VulnerabilityId = vulnerability.Id,
|
||||
Status = status.Value,
|
||||
Justification = vulnerability.Analysis.Justification,
|
||||
ActionStatement = actionStatement,
|
||||
AffectedComponents = affected,
|
||||
Timestamp = timestamp,
|
||||
Source = source
|
||||
});
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static VexStatus? MapStatus(VexState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
VexState.NotAffected => VexStatus.NotAffected,
|
||||
VexState.Fixed => VexStatus.Fixed,
|
||||
VexState.Exploitable => VexStatus.Affected,
|
||||
VexState.FalsePositive => VexStatus.NotAffected,
|
||||
VexState.InTriage => VexStatus.UnderInvestigation,
|
||||
VexState.UnderInvestigation => VexStatus.UnderInvestigation,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> BuildAffectedComponents(
|
||||
ImmutableArray<ParsedVulnAffects> affects,
|
||||
IReadOnlyDictionary<string, string?>? bomRefToPurl)
|
||||
{
|
||||
if (affects.IsDefaultOrEmpty)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var components = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in affects)
|
||||
{
|
||||
var reference = entry.Ref?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
if (bomRefToPurl is not null
|
||||
&& bomRefToPurl.TryGetValue(reference, out var purl)
|
||||
&& !string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
components.Add(purl.Trim());
|
||||
}
|
||||
else
|
||||
{
|
||||
components.Add(reference);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.Version))
|
||||
{
|
||||
components.Add(entry.Version.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return components
|
||||
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string? BuildActionStatement(ParsedVulnAnalysis analysis)
|
||||
{
|
||||
if (analysis.Response.IsDefaultOrEmpty && string.IsNullOrWhiteSpace(analysis.Detail))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var responseText = analysis.Response.IsDefaultOrEmpty
|
||||
? null
|
||||
: string.Join(", ", analysis.Response.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(responseText))
|
||||
{
|
||||
return analysis.Detail;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(analysis.Detail))
|
||||
{
|
||||
return responseText;
|
||||
}
|
||||
|
||||
return $"{responseText}. {analysis.Detail}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
public interface IVexTrustEvaluator
|
||||
{
|
||||
VexTrustEvaluation Evaluate(VexStatement statement, VexConsumptionPolicy policy);
|
||||
}
|
||||
|
||||
public sealed record VexTrustEvaluation
|
||||
{
|
||||
public required VexTrustLevel TrustLevel { get; init; }
|
||||
public ImmutableArray<VexConsumptionWarning> Warnings { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class VexTrustEvaluator : IVexTrustEvaluator
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public VexTrustEvaluator(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public VexTrustEvaluation Evaluate(VexStatement statement, VexConsumptionPolicy policy)
|
||||
{
|
||||
var warnings = new List<VexConsumptionWarning>();
|
||||
var trustLevel = DetermineBaseTrust(statement);
|
||||
|
||||
if (statement.Source == VexSource.SbomEmbedded && !policy.TrustEmbeddedVex)
|
||||
{
|
||||
trustLevel = VexTrustLevel.Untrusted;
|
||||
warnings.Add(BuildWarning(
|
||||
"vex.trust.embedded.disabled",
|
||||
"Embedded VEX is not trusted by policy.",
|
||||
statement));
|
||||
}
|
||||
|
||||
if (policy.SignatureRequirements.RequireSignedVex && string.IsNullOrWhiteSpace(statement.Issuer))
|
||||
{
|
||||
trustLevel = VexTrustLevel.Untrusted;
|
||||
warnings.Add(BuildWarning(
|
||||
"vex.signature.missing",
|
||||
"VEX statement is missing a signature or issuer.",
|
||||
statement));
|
||||
}
|
||||
|
||||
if (!policy.SignatureRequirements.TrustedSigners.IsDefaultOrEmpty
|
||||
&& !string.IsNullOrWhiteSpace(statement.Issuer))
|
||||
{
|
||||
var trusted = policy.SignatureRequirements.TrustedSigners
|
||||
.Any(signer => string.Equals(signer, statement.Issuer, StringComparison.OrdinalIgnoreCase));
|
||||
if (!trusted)
|
||||
{
|
||||
trustLevel = LowerTrust(trustLevel, VexTrustLevel.Unverified);
|
||||
warnings.Add(BuildWarning(
|
||||
"vex.signature.untrusted",
|
||||
"VEX statement issuer is not in the trusted signer list.",
|
||||
statement));
|
||||
}
|
||||
else
|
||||
{
|
||||
trustLevel = RaiseTrust(trustLevel, VexTrustLevel.Verified);
|
||||
}
|
||||
}
|
||||
|
||||
if (policy.TimestampRequirements.RequireTimestamp && statement.Timestamp is null)
|
||||
{
|
||||
trustLevel = VexTrustLevel.Untrusted;
|
||||
warnings.Add(BuildWarning(
|
||||
"vex.timestamp.missing",
|
||||
"VEX statement is missing a timestamp.",
|
||||
statement));
|
||||
}
|
||||
else if (statement.Timestamp is not null)
|
||||
{
|
||||
var maxAge = TimeSpan.FromHours(policy.TimestampRequirements.MaxAgeHours);
|
||||
var age = _timeProvider.GetUtcNow() - statement.Timestamp.Value;
|
||||
if (age > maxAge)
|
||||
{
|
||||
trustLevel = VexTrustLevel.Untrusted;
|
||||
warnings.Add(BuildWarning(
|
||||
"vex.timestamp.stale",
|
||||
$"VEX statement timestamp exceeds policy max age ({policy.TimestampRequirements.MaxAgeHours}h).",
|
||||
statement));
|
||||
}
|
||||
}
|
||||
|
||||
return new VexTrustEvaluation
|
||||
{
|
||||
TrustLevel = trustLevel,
|
||||
Warnings = warnings.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static VexTrustLevel DetermineBaseTrust(VexStatement statement)
|
||||
{
|
||||
if (statement.IsProducerStatement || statement.Source == VexSource.SbomEmbedded)
|
||||
{
|
||||
return VexTrustLevel.Trusted;
|
||||
}
|
||||
|
||||
return VexTrustLevel.Unverified;
|
||||
}
|
||||
|
||||
private static VexTrustLevel RaiseTrust(VexTrustLevel current, VexTrustLevel target)
|
||||
{
|
||||
return current.ToRank() >= target.ToRank() ? current : target;
|
||||
}
|
||||
|
||||
private static VexTrustLevel LowerTrust(VexTrustLevel current, VexTrustLevel target)
|
||||
{
|
||||
return current.ToRank() <= target.ToRank() ? current : target;
|
||||
}
|
||||
|
||||
private static VexConsumptionWarning BuildWarning(
|
||||
string code,
|
||||
string message,
|
||||
VexStatement statement)
|
||||
{
|
||||
return new VexConsumptionWarning
|
||||
{
|
||||
Code = code,
|
||||
Message = message,
|
||||
VulnerabilityId = statement.VulnerabilityId,
|
||||
Source = statement.Source
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Concelier.SbomIntegration.Vex;
|
||||
|
||||
internal static class VexTrustLevelExtensions
|
||||
{
|
||||
public static int ToRank(this VexTrustLevel level)
|
||||
{
|
||||
return level switch
|
||||
{
|
||||
VexTrustLevel.Verified => 3,
|
||||
VexTrustLevel.Trusted => 2,
|
||||
VexTrustLevel.Unverified => 1,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,18 @@
|
||||
"versionRanges": [],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [],
|
||||
"provenance": []
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "affected",
|
||||
"value": "ExampleCo Router X",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vendor",
|
||||
@@ -93,7 +104,18 @@
|
||||
"versionRanges": [],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [],
|
||||
"provenance": []
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "affected",
|
||||
"value": "ExampleCo Router Y",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
@@ -152,11 +174,11 @@
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"source": "acsc",
|
||||
"kind": "document",
|
||||
"value": "https://origin.example/feeds/multi/rss",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "multi",
|
||||
@@ -166,11 +188,11 @@
|
||||
{
|
||||
"kind": "reference",
|
||||
"provenance": {
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"source": "acsc",
|
||||
"kind": "document",
|
||||
"value": "https://origin.example/feeds/multi/rss",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Persistence.Postgres;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Persistence.Tests;
|
||||
|
||||
[Collection(ConcelierPostgresCollection.Name)]
|
||||
public sealed class SbomRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow =
|
||||
new(2026, 1, 20, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly ConcelierPostgresFixture _fixture;
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly SbomRepository _repository;
|
||||
|
||||
public SbomRepositoryTests(ConcelierPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
_dataSource = new ConcelierDataSource(
|
||||
Options.Create(options),
|
||||
NullLogger<ConcelierDataSource>.Instance);
|
||||
|
||||
_repository = new SbomRepository(
|
||||
_dataSource,
|
||||
NullLogger<SbomRepository>.Instance,
|
||||
new FixedTimeProvider(FixedNow),
|
||||
new SequentialGuidProvider());
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
|
||||
|
||||
public async ValueTask DisposeAsync() => await _dataSource.DisposeAsync();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StoreAsync_RoundTripsBySerialAndDigest()
|
||||
{
|
||||
var sbom = CreateSbom(serialNumber: "urn:sha256:deadbeef");
|
||||
|
||||
await _repository.StoreAsync(sbom);
|
||||
|
||||
var bySerial = await _repository.GetBySerialNumberAsync(sbom.SerialNumber);
|
||||
var byDigest = await _repository.GetByArtifactDigestAsync("sha256:deadbeef");
|
||||
|
||||
bySerial.Should().NotBeNull();
|
||||
bySerial!.SpecVersion.Should().Be("1.7");
|
||||
bySerial.Metadata.RootComponentRef.Should().Be("root");
|
||||
|
||||
byDigest.Should().NotBeNull();
|
||||
byDigest!.SerialNumber.Should().Be(sbom.SerialNumber);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Queries_ReturnServicesCryptoAndVulnerabilities()
|
||||
{
|
||||
var sbom = CreateSbom(serialNumber: "urn:sha256:feedface");
|
||||
|
||||
await _repository.StoreAsync(sbom);
|
||||
|
||||
var services = await _repository.GetServicesForArtifactAsync(sbom.SerialNumber);
|
||||
var cryptoComponents = await _repository.GetComponentsWithCryptoAsync("sha256:feedface");
|
||||
var vulnerabilities = await _repository.GetEmbeddedVulnerabilitiesAsync("sha256:feedface");
|
||||
|
||||
services.Should().ContainSingle(service => service.Name == "api-gateway");
|
||||
var cryptoComponent = cryptoComponents.Should()
|
||||
.ContainSingle(component => component.BomRef == "root")
|
||||
.Which;
|
||||
cryptoComponent.ModelCard.Should().NotBeNull();
|
||||
cryptoComponent.ModelCard!.ModelParameters!.Task.Should().Be("classification");
|
||||
vulnerabilities.Should().ContainSingle(vuln => vuln.Id == "CVE-2026-0001");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByArtifactDigestAsync_ReturnsCompositionsAndDeclarations()
|
||||
{
|
||||
var sbom = CreateSbom(serialNumber: "urn:sha256:cccccccc");
|
||||
|
||||
await _repository.StoreAsync(sbom);
|
||||
|
||||
var byDigest = await _repository.GetByArtifactDigestAsync("sha256:cccccccc");
|
||||
|
||||
byDigest.Should().NotBeNull();
|
||||
byDigest!.Compositions.Should().ContainSingle(composition =>
|
||||
composition.Aggregate == CompositionAggregate.Complete);
|
||||
byDigest.Declarations.Should().NotBeNull();
|
||||
byDigest.Declarations!.Attestations.Should().ContainSingle(attestation =>
|
||||
attestation.Predicate == "build");
|
||||
byDigest.Declarations!.Affirmations.Should().ContainSingle(affirmation =>
|
||||
affirmation.Statement == "SBOM verified");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LicenseQueries_ReturnExpectedResults()
|
||||
{
|
||||
var sbomAlpha = CreateLicensedSbom(
|
||||
"urn:sha256:aaaaaaaa",
|
||||
"root-alpha",
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "root-alpha",
|
||||
Name = "alpha-app",
|
||||
Licenses =
|
||||
[
|
||||
new ParsedLicense
|
||||
{
|
||||
SpdxId = "MIT"
|
||||
}
|
||||
]
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "lib-gpl",
|
||||
Name = "lib-gpl",
|
||||
Licenses =
|
||||
[
|
||||
new ParsedLicense
|
||||
{
|
||||
Expression = new WithException(
|
||||
new SimpleLicense("GPL-2.0-only"),
|
||||
"Classpath-exception-2.0")
|
||||
}
|
||||
]
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "lib-empty",
|
||||
Name = "lib-empty"
|
||||
});
|
||||
|
||||
var sbomBeta = CreateLicensedSbom(
|
||||
"urn:sha256:bbbbbbbb",
|
||||
"root-beta",
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "root-beta",
|
||||
Name = "beta-app",
|
||||
Licenses =
|
||||
[
|
||||
new ParsedLicense
|
||||
{
|
||||
SpdxId = "Apache-2.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "lib-prop",
|
||||
Name = "lib-prop",
|
||||
Licenses =
|
||||
[
|
||||
new ParsedLicense
|
||||
{
|
||||
SpdxId = "LicenseRef-Proprietary"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await _repository.StoreAsync(sbomAlpha);
|
||||
await _repository.StoreAsync(sbomBeta);
|
||||
|
||||
var licenses = await _repository.GetLicensesForArtifactAsync("sha256:aaaaaaaa");
|
||||
licenses.Should().Contain(license => license.SpdxId == "MIT");
|
||||
licenses.Should().Contain(license => license.Expression is WithException);
|
||||
|
||||
var mitComponents = await _repository.GetComponentsByLicenseAsync("MIT");
|
||||
mitComponents.Should().ContainSingle(component => component.BomRef == "root-alpha");
|
||||
|
||||
var gplComponents = await _repository.GetComponentsByLicenseAsync("GPL-2.0-only");
|
||||
gplComponents.Should().ContainSingle(component => component.BomRef == "lib-gpl");
|
||||
|
||||
var noLicense = await _repository.GetComponentsWithoutLicenseAsync("sha256:aaaaaaaa");
|
||||
noLicense.Should().ContainSingle(component => component.BomRef == "lib-empty");
|
||||
|
||||
var permissive = await _repository.GetComponentsByLicenseCategoryAsync(
|
||||
"sha256:aaaaaaaa",
|
||||
LicenseCategory.Permissive);
|
||||
permissive.Should().ContainSingle(component => component.BomRef == "root-alpha");
|
||||
|
||||
var weakCopyleft = await _repository.GetComponentsByLicenseCategoryAsync(
|
||||
"sha256:aaaaaaaa",
|
||||
LicenseCategory.WeakCopyleft);
|
||||
weakCopyleft.Should().ContainSingle(component => component.BomRef == "lib-gpl");
|
||||
|
||||
var proprietary = await _repository.GetComponentsByLicenseCategoryAsync(
|
||||
"sha256:bbbbbbbb",
|
||||
LicenseCategory.Proprietary);
|
||||
proprietary.Should().ContainSingle(component => component.BomRef == "lib-prop");
|
||||
|
||||
var summary = await _repository.GetLicenseInventoryAsync("sha256:aaaaaaaa");
|
||||
summary.TotalComponents.Should().Be(3);
|
||||
summary.ComponentsWithLicense.Should().Be(2);
|
||||
summary.ComponentsWithoutLicense.Should().Be(1);
|
||||
summary.LicenseDistribution.Should().ContainKey("MIT");
|
||||
summary.LicenseDistribution.Should().ContainKey("GPL-2.0-only");
|
||||
summary.Expressions.Should().Contain("GPL-2.0-only WITH Classpath-exception-2.0");
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateSbom(string serialNumber)
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "cyclonedx",
|
||||
SpecVersion = "1.7",
|
||||
SerialNumber = serialNumber,
|
||||
Components =
|
||||
[
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "root",
|
||||
Name = "acme-app",
|
||||
CryptoProperties = new ParsedCryptoProperties
|
||||
{
|
||||
AssetType = CryptoAssetType.Algorithm
|
||||
},
|
||||
ModelCard = new ParsedModelCard
|
||||
{
|
||||
ModelParameters = new ParsedModelParameters
|
||||
{
|
||||
Task = "classification"
|
||||
}
|
||||
}
|
||||
},
|
||||
new ParsedComponent
|
||||
{
|
||||
BomRef = "lib-1",
|
||||
Name = "lib-one"
|
||||
}
|
||||
],
|
||||
Services =
|
||||
[
|
||||
new ParsedService
|
||||
{
|
||||
BomRef = "svc-api",
|
||||
Name = "api-gateway",
|
||||
Endpoints = ["https://api.example.test"]
|
||||
}
|
||||
],
|
||||
Vulnerabilities =
|
||||
[
|
||||
new ParsedVulnerability
|
||||
{
|
||||
Id = "CVE-2026-0001"
|
||||
}
|
||||
],
|
||||
Compositions =
|
||||
[
|
||||
new ParsedComposition
|
||||
{
|
||||
Aggregate = CompositionAggregate.Complete,
|
||||
Assemblies = ["root"],
|
||||
Dependencies = ["lib-1"],
|
||||
Vulnerabilities = ["CVE-2026-0001"]
|
||||
}
|
||||
],
|
||||
Declarations = new ParsedDeclarations
|
||||
{
|
||||
Attestations =
|
||||
[
|
||||
new ParsedAttestation
|
||||
{
|
||||
Subjects = ["root"],
|
||||
Predicate = "build",
|
||||
Evidence = "evidence-ref"
|
||||
}
|
||||
],
|
||||
Affirmations =
|
||||
[
|
||||
new ParsedAffirmation
|
||||
{
|
||||
Statement = "SBOM verified",
|
||||
Signatories = ["acme"]
|
||||
}
|
||||
]
|
||||
},
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Name = "acme-app",
|
||||
RootComponentRef = "root"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateLicensedSbom(
|
||||
string serialNumber,
|
||||
string rootComponentRef,
|
||||
params ParsedComponent[] components)
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "cyclonedx",
|
||||
SpecVersion = "1.7",
|
||||
SerialNumber = serialNumber,
|
||||
Components = components.ToImmutableArray(),
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
Name = "licensed-app",
|
||||
RootComponentRef = rootComponentRef
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now)
|
||||
{
|
||||
_now = now;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0231-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0231-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0231-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| TASK-015-011 | DONE | Added SbomRepository integration coverage. |
|
||||
| TASK-015-007d | DONE | Added license query coverage for SbomRepository. |
|
||||
| TASK-015-013 | DONE | Added SbomRepository integration coverage for model cards and policy fields. |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class SbomAdvisoryMatcherVexTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MatchAsync_FiltersNotAffectedVexStatements()
|
||||
{
|
||||
var sbomId = Guid.NewGuid();
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var purl = "pkg:npm/example@1.0.0";
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2026-5000", purl);
|
||||
|
||||
var canonicalService = new Mock<ICanonicalAdvisoryService>();
|
||||
canonicalService
|
||||
.Setup(service => service.GetByArtifactAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
var sbomRepository = new Mock<ISbomRepository>();
|
||||
var parsedSbom = CreateSbom();
|
||||
sbomRepository
|
||||
.Setup(repo => repo.GetByArtifactDigestAsync("sha256:abc", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(parsedSbom);
|
||||
|
||||
var vexConsumer = new Mock<IVexConsumer>();
|
||||
var vexResult = new VexConsumptionResult
|
||||
{
|
||||
Statements =
|
||||
[
|
||||
new ConsumedVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-5000",
|
||||
Status = VexStatus.NotAffected,
|
||||
Source = VexSource.SbomEmbedded,
|
||||
TrustLevel = VexTrustLevel.Trusted
|
||||
}
|
||||
]
|
||||
};
|
||||
vexConsumer
|
||||
.Setup(consumer => consumer.ConsumeAsync(parsedSbom.Vulnerabilities, It.IsAny<VexConsumptionPolicy>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(vexResult);
|
||||
|
||||
var policyLoader = new Mock<IVexConsumptionPolicyLoader>();
|
||||
policyLoader
|
||||
.Setup(loader => loader.LoadAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(VexConsumptionPolicyDefaults.Default);
|
||||
|
||||
var options = Options.Create(new VexConsumptionOptions { Enabled = true });
|
||||
var logger = new Mock<ILogger<SbomAdvisoryMatcher>>();
|
||||
|
||||
var matcher = new SbomAdvisoryMatcher(
|
||||
canonicalService.Object,
|
||||
logger.Object,
|
||||
timeProvider: null,
|
||||
vexConsumer.Object,
|
||||
sbomRepository.Object,
|
||||
policyLoader.Object,
|
||||
options);
|
||||
|
||||
var matches = await matcher.MatchAsync(sbomId, "sha256:abc", new[] { purl }, null, null);
|
||||
|
||||
Assert.Empty(matches);
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateSbom()
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "CycloneDX",
|
||||
SpecVersion = "1.7",
|
||||
SerialNumber = "urn:uuid:sbom",
|
||||
Vulnerabilities =
|
||||
[
|
||||
new ParsedVulnerability
|
||||
{
|
||||
Id = "CVE-2026-5000",
|
||||
Analysis = new ParsedVulnAnalysis
|
||||
{
|
||||
State = VexState.NotAffected,
|
||||
Justification = VexJustification.ComponentNotPresent,
|
||||
FirstIssued = DateTimeOffset.Parse("2026-01-20T00:00:00Z"),
|
||||
LastUpdated = DateTimeOffset.Parse("2026-01-20T00:00:00Z")
|
||||
}
|
||||
}
|
||||
],
|
||||
Components = [],
|
||||
Dependencies = [],
|
||||
Services = [],
|
||||
Compositions = [],
|
||||
Annotations = [],
|
||||
Metadata = new ParsedSbomMetadata()
|
||||
};
|
||||
}
|
||||
|
||||
private static CanonicalAdvisory CreateCanonicalAdvisory(Guid id, string cve, string affectsKey)
|
||||
{
|
||||
return new CanonicalAdvisory
|
||||
{
|
||||
Id = id,
|
||||
Cve = cve,
|
||||
AffectsKey = affectsKey,
|
||||
MergeHash = "hash",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-01-20T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-01-20T00:00:00Z")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,51 @@ public class SbomParserTests
|
||||
result.Purls.Should().Contain("pkg:npm/axios@1.6.0");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ParseAsync_CycloneDX_ExtractsExternalReferenceCpesAndUnresolved()
|
||||
{
|
||||
var content = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"metadata": {
|
||||
"component": {
|
||||
"name": "root",
|
||||
"version": "1.0.0",
|
||||
"purl": "pkg:npm/root@1.0.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"name": "with-cpe",
|
||||
"version": "1.2.3",
|
||||
"purl": "pkg:npm/with-cpe@1.2.3",
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "cpe",
|
||||
"url": "cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "no-purl",
|
||||
"version": "2.0.0"
|
||||
}
|
||||
],
|
||||
"dependencies": [],
|
||||
"compositions": []
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
||||
|
||||
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
||||
|
||||
result.Cpes.Should().Contain("cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*");
|
||||
result.UnresolvedComponents.Should().ContainSingle(c => c.Name == "no-purl");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SPDX Tests
|
||||
@@ -318,6 +363,102 @@ public class SbomParserTests
|
||||
result.Cpes.Should().Contain("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ParseAsync_SPDX_TracksUnresolvedPackages()
|
||||
{
|
||||
var spdxContent = """
|
||||
{
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"packages": [
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package",
|
||||
"name": "nopurl",
|
||||
"versionInfo": "1.0.0",
|
||||
"externalRefs": [
|
||||
{
|
||||
"referenceCategory": "PACKAGE-MANAGER",
|
||||
"referenceType": "purl"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(spdxContent));
|
||||
|
||||
var result = await _parser.ParseAsync(stream, SbomFormat.SPDX);
|
||||
|
||||
result.Purls.Should().BeEmpty();
|
||||
result.UnresolvedComponents.Should().ContainSingle(c => c.Name == "nopurl");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ParseAsync_SPDX3_ExtractsPurlsAndCpes()
|
||||
{
|
||||
var content = """
|
||||
{
|
||||
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "SpdxDocument",
|
||||
"spdxId": "urn:doc"
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "spdx:pkg:1",
|
||||
"name": "pkg1",
|
||||
"packageUrl": "pkg:npm/pkg1@1.0.0",
|
||||
"packageVersion": "1.0.0",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"externalIdentifierType": "cpe23Type",
|
||||
"identifier": "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "spdx:pkg:2",
|
||||
"name": "pkg2",
|
||||
"softwareVersion": "2.0.0",
|
||||
"externalIdentifier": [
|
||||
{
|
||||
"externalIdentifierType": "purl",
|
||||
"identifier": "pkg:maven/org.example/app@2.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "software_Package",
|
||||
"spdxId": "spdx:pkg:3",
|
||||
"name": "pkg3"
|
||||
},
|
||||
{
|
||||
"@type": "Profile",
|
||||
"spdxId": "spdx:profile:1"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
||||
|
||||
var result = await _parser.ParseAsync(stream, SbomFormat.SPDX);
|
||||
|
||||
result.PrimaryName.Should().Be("pkg1");
|
||||
result.PrimaryVersion.Should().Be("1.0.0");
|
||||
result.Purls.Should().Contain(new[]
|
||||
{
|
||||
"pkg:npm/pkg1@1.0.0",
|
||||
"pkg:maven/org.example/app@2.0.0"
|
||||
});
|
||||
result.Cpes.Should().Contain("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*");
|
||||
result.UnresolvedComponents.Should().ContainSingle(c => c.Name == "pkg3");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Format Detection Tests
|
||||
@@ -375,7 +516,27 @@ public class SbomParserTests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DetectFormatAsync_UnknownFormat_ReturnsNotDetected()
|
||||
public async Task DetectFormatAsync_SPDX3_DetectsFormat()
|
||||
{
|
||||
var content = """
|
||||
{
|
||||
"@context": ["https://spdx.org/rdf/3.0.1/spdx-context.jsonld"],
|
||||
"@graph": []
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
||||
|
||||
var result = await _parser.DetectFormatAsync(stream);
|
||||
|
||||
result.IsDetected.Should().BeTrue();
|
||||
result.Format.Should().Be(SbomFormat.SPDX);
|
||||
result.SpecVersion.Should().Be("3.0");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DetectFormatAsync_UnknownFormat_ReturnsNotDetected()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SpdxLicenseExpressionValidatorTests.cs
|
||||
// Sprint: SPRINT_20260119_015_Concelier_sbom_full_extraction
|
||||
// Task: TASK-015-007c - SPDX license expression validation tests
|
||||
// -----------------------------------------------------------------------------
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.SbomIntegration.Licensing;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class SpdxLicenseExpressionValidatorTests
|
||||
{
|
||||
private readonly SpdxLicenseExpressionValidator _validator = new();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ValidateString_KnownExpression_IsValid()
|
||||
{
|
||||
var result = _validator.ValidateString("MIT AND Apache-2.0");
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
result.ReferencedLicenses.Should().Contain(new[] { "MIT", "Apache-2.0" });
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ValidateString_UnknownLicense_IsInvalid()
|
||||
{
|
||||
var result = _validator.ValidateString("NoSuchLicense");
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.UnknownLicenses.Should().ContainSingle("NoSuchLicense");
|
||||
result.Errors.Should().Contain(error => error.Contains("Unknown SPDX license identifier"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ValidateString_LicenseRef_IsWarningOnly()
|
||||
{
|
||||
var result = _validator.ValidateString("LicenseRef-Internal");
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.UnknownLicenses.Should().ContainSingle("LicenseRef-Internal");
|
||||
result.Warnings.Should().Contain(warning => warning.Contains("LicenseRef identifier"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ValidateString_DeprecatedLicense_IsWarningOnly()
|
||||
{
|
||||
var result = _validator.ValidateString("AGPL-1.0");
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.DeprecatedLicenses.Should().ContainSingle("AGPL-1.0");
|
||||
result.Warnings.Should().Contain(warning => warning.Contains("Deprecated SPDX license identifier"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ValidateString_KnownException_IsValid()
|
||||
{
|
||||
var result = _validator.ValidateString("MIT WITH Classpath-exception-2.0");
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
result.ReferencedExceptions.Should().ContainSingle("Classpath-exception-2.0");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ValidateString_UnknownException_IsInvalid()
|
||||
{
|
||||
var result = _validator.ValidateString("MIT WITH Missing-exception");
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(error => error.Contains("Unknown SPDX license exception"));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -23,4 +27,4 @@
|
||||
<ProjectReference Include="..\..\..\Router/__Libraries/StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Additional source of truth: `docs/implplan/SPRINT_20260119_015_Concelier_sbom_full_extraction.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0238-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0238-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0238-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| TASK-015-012 | DONE | Added ParsedSbomParser branch coverage across CycloneDX/SPDX helpers (services, dataflows, licenses, crypto, VEX, AI profiles, references) plus round-trip JSON equivalence checks; line-rate 0.9586 and tests pass. |
|
||||
| TASK-020-011 | DONE | Added unit tests for VEX consumption, merge, and reporter behaviors. |
|
||||
| TASK-020-012 | DONE | Added integration test for CycloneDX embedded VEX parsing. |
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class VexConflictResolverTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Resolve_UsesHighestTrust()
|
||||
{
|
||||
var resolver = new VexConflictResolver();
|
||||
var statements = new[]
|
||||
{
|
||||
new VexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-2000",
|
||||
Status = VexStatus.Affected,
|
||||
Source = VexSource.External,
|
||||
TrustLevel = VexTrustLevel.Unverified,
|
||||
Timestamp = DateTimeOffset.Parse("2026-01-20T00:00:00Z")
|
||||
},
|
||||
new VexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-2000",
|
||||
Status = VexStatus.Fixed,
|
||||
Source = VexSource.External,
|
||||
TrustLevel = VexTrustLevel.Verified,
|
||||
Timestamp = DateTimeOffset.Parse("2026-01-19T00:00:00Z")
|
||||
}
|
||||
};
|
||||
|
||||
var resolution = resolver.Resolve(
|
||||
"CVE-2026-2000",
|
||||
statements,
|
||||
VexConflictResolutionStrategy.HighestTrust);
|
||||
|
||||
Assert.NotNull(resolution.Selected);
|
||||
Assert.Equal(VexTrustLevel.Verified, resolution.Selected!.TrustLevel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class VexConsumerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ConsumeAsync_ReturnsNotAffectedStatement()
|
||||
{
|
||||
var vulnerability = CreateVulnerability(
|
||||
VexState.NotAffected,
|
||||
VexJustification.ComponentNotPresent,
|
||||
DateTimeOffset.Parse("2026-01-20T00:00:00Z"));
|
||||
|
||||
var consumer = CreateConsumer();
|
||||
var result = await consumer.ConsumeAsync(
|
||||
new[] { vulnerability },
|
||||
VexConsumptionPolicyDefaults.Default,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Statements);
|
||||
Assert.Equal(VexStatus.NotAffected, result.Statements[0].Status);
|
||||
Assert.Equal(VexTrustLevel.Trusted, result.Statements[0].TrustLevel);
|
||||
Assert.Empty(result.Warnings);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ConsumeAsync_MissingJustification_FiltersStatement()
|
||||
{
|
||||
var vulnerability = CreateVulnerability(
|
||||
VexState.NotAffected,
|
||||
null,
|
||||
DateTimeOffset.Parse("2026-01-20T00:00:00Z"));
|
||||
|
||||
var consumer = CreateConsumer();
|
||||
var result = await consumer.ConsumeAsync(
|
||||
new[] { vulnerability },
|
||||
VexConsumptionPolicyDefaults.Default,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Empty(result.Statements);
|
||||
Assert.Contains(result.Warnings, warning => warning.Code == "vex.justification.missing");
|
||||
}
|
||||
|
||||
private static ParsedVulnerability CreateVulnerability(
|
||||
VexState state,
|
||||
VexJustification? justification,
|
||||
DateTimeOffset timestamp)
|
||||
{
|
||||
return new ParsedVulnerability
|
||||
{
|
||||
Id = "CVE-2026-0001",
|
||||
Analysis = new ParsedVulnAnalysis
|
||||
{
|
||||
State = state,
|
||||
Justification = justification,
|
||||
Response = [],
|
||||
Detail = "reviewed",
|
||||
FirstIssued = timestamp,
|
||||
LastUpdated = timestamp
|
||||
},
|
||||
Affects = [],
|
||||
Ratings = []
|
||||
};
|
||||
}
|
||||
|
||||
private static VexConsumer CreateConsumer()
|
||||
{
|
||||
var evaluator = new VexTrustEvaluator(new StubTimeProvider());
|
||||
var resolver = new VexConflictResolver();
|
||||
var merger = new VexMerger(resolver);
|
||||
var extractors = new IVexStatementExtractor[]
|
||||
{
|
||||
new CycloneDxVexExtractor(),
|
||||
new SpdxVexExtractor()
|
||||
};
|
||||
|
||||
return new VexConsumer(evaluator, merger, extractors);
|
||||
}
|
||||
|
||||
private sealed class StubTimeProvider : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow()
|
||||
=> DateTimeOffset.Parse("2026-01-20T01:00:00Z");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class VexConsumptionReporterTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToJson_IncludesStatements()
|
||||
{
|
||||
var reporter = new VexConsumptionReporter();
|
||||
var report = new VexConsumptionReport
|
||||
{
|
||||
OverallTrustLevel = VexTrustLevel.Trusted,
|
||||
Statements =
|
||||
[
|
||||
new ConsumedVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-4000",
|
||||
Status = VexStatus.Affected,
|
||||
Source = VexSource.SbomEmbedded,
|
||||
TrustLevel = VexTrustLevel.Trusted
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var json = reporter.ToJson(report);
|
||||
|
||||
Assert.Contains("CVE-2026-4000", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToSarif_EmitsResults()
|
||||
{
|
||||
var reporter = new VexConsumptionReporter();
|
||||
var report = new VexConsumptionReport
|
||||
{
|
||||
Statements =
|
||||
[
|
||||
new ConsumedVexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-4001",
|
||||
Status = VexStatus.Affected,
|
||||
Source = VexSource.External,
|
||||
TrustLevel = VexTrustLevel.Unverified
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var sarif = reporter.ToSarif(report);
|
||||
|
||||
Assert.Contains("vex-affected", sarif);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class VexExtractorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CycloneDxExtractor_MapsBomRefToPurl()
|
||||
{
|
||||
var sbom = CreateSbom("CycloneDX");
|
||||
var extractor = new CycloneDxVexExtractor();
|
||||
|
||||
var statements = extractor.Extract(sbom);
|
||||
|
||||
Assert.Single(statements);
|
||||
Assert.Contains("pkg:npm/example@1.0.0", statements[0].AffectedComponents);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SpdxExtractor_HandlesSpdxFormat()
|
||||
{
|
||||
var sbom = CreateSbom("SPDX");
|
||||
var extractor = new SpdxVexExtractor();
|
||||
|
||||
Assert.True(extractor.CanHandle(sbom));
|
||||
var statements = extractor.Extract(sbom);
|
||||
Assert.Single(statements);
|
||||
}
|
||||
|
||||
private static ParsedSbom CreateSbom(string format)
|
||||
{
|
||||
var component = new ParsedComponent
|
||||
{
|
||||
BomRef = "comp-1",
|
||||
Name = "example",
|
||||
Purl = "pkg:npm/example@1.0.0"
|
||||
};
|
||||
|
||||
var vulnerability = new ParsedVulnerability
|
||||
{
|
||||
Id = "CVE-2026-1000",
|
||||
Analysis = new ParsedVulnAnalysis
|
||||
{
|
||||
State = VexState.NotAffected,
|
||||
Justification = VexJustification.ComponentNotPresent,
|
||||
Response = ImmutableArray<string>.Empty,
|
||||
Detail = "not present",
|
||||
FirstIssued = DateTimeOffset.Parse("2026-01-20T00:00:00Z"),
|
||||
LastUpdated = DateTimeOffset.Parse("2026-01-20T00:00:00Z")
|
||||
},
|
||||
Affects =
|
||||
[
|
||||
new ParsedVulnAffects { Ref = "comp-1" }
|
||||
]
|
||||
};
|
||||
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = format,
|
||||
SpecVersion = "1.7",
|
||||
SerialNumber = "urn:uuid:example",
|
||||
Components = [component],
|
||||
Vulnerabilities = [vulnerability],
|
||||
Metadata = new ParsedSbomMetadata()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Parsing;
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class VexIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ConsumeFromSbomAsync_ParsesEmbeddedCycloneDxVex()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"serialNumber": "urn:uuid:00000000-0000-0000-0000-000000000001",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": "2026-01-20T00:00:00Z",
|
||||
"component": {
|
||||
"bom-ref": "comp-1",
|
||||
"type": "library",
|
||||
"name": "example",
|
||||
"version": "1.0.0",
|
||||
"purl": "pkg:npm/example@1.0.0"
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"vendor": "test",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"bom-ref": "comp-1",
|
||||
"type": "library",
|
||||
"name": "example",
|
||||
"version": "1.0.0",
|
||||
"purl": "pkg:npm/example@1.0.0"
|
||||
}
|
||||
],
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"id": "CVE-2026-9999",
|
||||
"analysis": {
|
||||
"state": "not_affected",
|
||||
"justification": "component_not_present",
|
||||
"response": ["will_not_fix"],
|
||||
"detail": "component absent",
|
||||
"lastUpdated": "2026-01-20T00:00:00Z"
|
||||
},
|
||||
"affects": [
|
||||
{ "ref": "comp-1" }
|
||||
],
|
||||
"ratings": [
|
||||
{
|
||||
"method": "CVSSv3",
|
||||
"score": 7.5,
|
||||
"severity": "high"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
var parser = new ParsedSbomParser(NullLogger<ParsedSbomParser>.Instance);
|
||||
var sbom = await parser.ParseAsync(stream, SbomFormat.CycloneDX, CancellationToken.None);
|
||||
|
||||
var consumer = CreateConsumer();
|
||||
var result = await consumer.ConsumeFromSbomAsync(sbom, VexConsumptionPolicyDefaults.Default, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Statements);
|
||||
Assert.Equal("CVE-2026-9999", result.Statements[0].VulnerabilityId);
|
||||
Assert.Equal(VexStatus.NotAffected, result.Statements[0].Status);
|
||||
Assert.Contains("pkg:npm/example@1.0.0", result.Statements[0].AffectedComponents);
|
||||
}
|
||||
|
||||
private static VexConsumer CreateConsumer()
|
||||
{
|
||||
var evaluator = new VexTrustEvaluator(new StubTimeProvider());
|
||||
var resolver = new VexConflictResolver();
|
||||
var merger = new VexMerger(resolver);
|
||||
var extractors = new IVexStatementExtractor[]
|
||||
{
|
||||
new CycloneDxVexExtractor(),
|
||||
new SpdxVexExtractor()
|
||||
};
|
||||
|
||||
return new VexConsumer(evaluator, merger, extractors);
|
||||
}
|
||||
|
||||
private sealed class StubTimeProvider : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow()
|
||||
=> DateTimeOffset.Parse("2026-01-20T01:00:00Z");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using StellaOps.Concelier.SbomIntegration.Vex;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public sealed class VexMergerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_ExternalPriorityPrefersExternalStatements()
|
||||
{
|
||||
var resolver = new VexConflictResolver();
|
||||
var merger = new VexMerger(resolver);
|
||||
|
||||
var embedded = new[]
|
||||
{
|
||||
new VexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-3000",
|
||||
Status = VexStatus.Affected,
|
||||
Source = VexSource.SbomEmbedded,
|
||||
TrustLevel = VexTrustLevel.Trusted,
|
||||
Timestamp = DateTimeOffset.Parse("2026-01-20T00:00:00Z")
|
||||
}
|
||||
};
|
||||
|
||||
var external = new[]
|
||||
{
|
||||
new VexStatement
|
||||
{
|
||||
VulnerabilityId = "CVE-2026-3000",
|
||||
Status = VexStatus.Fixed,
|
||||
Source = VexSource.External,
|
||||
TrustLevel = VexTrustLevel.Unverified,
|
||||
Timestamp = DateTimeOffset.Parse("2026-01-21T00:00:00Z")
|
||||
}
|
||||
};
|
||||
|
||||
var mergePolicy = new VexMergePolicy
|
||||
{
|
||||
Mode = VexMergeMode.ExternalPriority
|
||||
};
|
||||
|
||||
var merged = merger.Merge(
|
||||
embedded,
|
||||
external,
|
||||
mergePolicy,
|
||||
VexConflictResolutionStrategy.MostRecent);
|
||||
|
||||
Assert.Single(merged.Statements);
|
||||
Assert.Equal(VexSource.External, merged.Statements[0].Source);
|
||||
}
|
||||
}
|
||||
@@ -291,7 +291,7 @@ public sealed class FederationEndpointTests
|
||||
};
|
||||
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<IOptions<ConcelierOptions>>(Options.Create(options));
|
||||
services.AddSingleton<IOptions<ConcelierOptions>>(Microsoft.Extensions.Options.Options.Create(options));
|
||||
services.AddSingleton<TimeProvider>(new FixedTimeProvider(_fixedNow));
|
||||
services.AddSingleton<IBundleExportService>(new FakeBundleExportService());
|
||||
services.AddSingleton<IBundleImportService>(new FakeBundleImportService(_fixedNow));
|
||||
@@ -309,8 +309,6 @@ public sealed class FederationEndpointTests
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public override long GetTimestamp() => 0;
|
||||
|
||||
public override TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp) => TimeSpan.Zero;
|
||||
}
|
||||
|
||||
private sealed class FakeBundleExportService : IBundleExportService
|
||||
|
||||
Reference in New Issue
Block a user