feat: Implement DefaultCryptoHmac for compliance-aware HMAC operations

- Added DefaultCryptoHmac class implementing ICryptoHmac interface.
- Introduced purpose-based HMAC computation methods.
- Implemented verification methods for HMACs with constant-time comparison.
- Created HmacAlgorithms and HmacPurpose classes for well-known identifiers.
- Added compliance profile support for HMAC algorithms.
- Included asynchronous methods for HMAC computation from streams.
This commit is contained in:
StellaOps Bot
2025-12-06 00:41:04 +02:00
parent 43c281a8b2
commit f0662dd45f
362 changed files with 8441 additions and 22338 deletions

View File

@@ -9,6 +9,8 @@ public sealed class ConcelierOptions
{
public StorageOptions Storage { get; set; } = new();
public PostgresStorageOptions? PostgresStorage { get; set; }
public PluginOptions Plugins { get; set; } = new();
public TelemetryOptions Telemetry { get; set; } = new();
@@ -36,6 +38,63 @@ public sealed class ConcelierOptions
public int CommandTimeoutSeconds { get; set; } = 30;
}
/// <summary>
/// PostgreSQL storage options for the LNM linkset cache.
/// </summary>
public sealed class PostgresStorageOptions
{
/// <summary>
/// Enable PostgreSQL storage for LNM linkset cache.
/// When true, the linkset cache is stored in PostgreSQL instead of MongoDB.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// PostgreSQL connection string.
/// </summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>
/// Command timeout in seconds. Default is 30 seconds.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Maximum number of connections in the pool. Default is 100.
/// </summary>
public int MaxPoolSize { get; set; } = 100;
/// <summary>
/// Minimum number of connections in the pool. Default is 1.
/// </summary>
public int MinPoolSize { get; set; } = 1;
/// <summary>
/// Connection idle lifetime in seconds. Default is 300 seconds (5 minutes).
/// </summary>
public int ConnectionIdleLifetimeSeconds { get; set; } = 300;
/// <summary>
/// Enable connection pooling. Default is true.
/// </summary>
public bool Pooling { get; set; } = true;
/// <summary>
/// Schema name for LNM tables. Default is "vuln".
/// </summary>
public string SchemaName { get; set; } = "vuln";
/// <summary>
/// Enable automatic migration on startup. Default is false for production safety.
/// </summary>
public bool AutoMigrate { get; set; }
/// <summary>
/// Path to SQL migration files. Required if AutoMigrate is true.
/// </summary>
public string? MigrationsPath { get; set; }
}
public sealed class PluginOptions
{
public string? BaseDirectory { get; set; }

View File

@@ -57,6 +57,7 @@ using StellaOps.Concelier.RawModels;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Aliases;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Provenance.Mongo;
using StellaOps.Concelier.Core.Attestation;
using AttestationClaims = StellaOps.Concelier.Core.Attestation.AttestationClaims;
@@ -195,6 +196,25 @@ else
builder.Services.RemoveAll<IMongoClient>();
builder.Services.RemoveAll<IMongoDatabase>();
}
// Add PostgreSQL storage for LNM linkset cache if configured.
// This provides a PostgreSQL-backed implementation of IAdvisoryLinksetStore for the read-through cache.
if (concelierOptions.PostgresStorage is { Enabled: true } postgresOptions)
{
builder.Services.AddConcelierPostgresStorage(pgOptions =>
{
pgOptions.ConnectionString = postgresOptions.ConnectionString;
pgOptions.CommandTimeoutSeconds = postgresOptions.CommandTimeoutSeconds;
pgOptions.MaxPoolSize = postgresOptions.MaxPoolSize;
pgOptions.MinPoolSize = postgresOptions.MinPoolSize;
pgOptions.ConnectionIdleLifetimeSeconds = postgresOptions.ConnectionIdleLifetimeSeconds;
pgOptions.Pooling = postgresOptions.Pooling;
pgOptions.SchemaName = postgresOptions.SchemaName;
pgOptions.AutoMigrate = postgresOptions.AutoMigrate;
pgOptions.MigrationsPath = postgresOptions.MigrationsPath;
});
}
builder.Services.AddOptions<AdvisoryObservationEventPublisherOptions>()
.Bind(builder.Configuration.GetSection("advisoryObservationEvents"))
.PostConfigure(options =>

View File

@@ -9,7 +9,7 @@
<RootNamespace>StellaOps.Concelier.WebService</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
@@ -24,6 +24,7 @@
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Storage.Postgres/StellaOps.Concelier.Storage.Postgres.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="AngleSharp" Version="1.1.1" />
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />

View File

@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<ProjectReference Include="../StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />

View File

@@ -0,0 +1,130 @@
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Aoc;
/// <summary>
/// Default implementation of <see cref="IAdvisorySchemaValidator"/>.
/// Per WEB-AOC-19-002, provides granular validation checks for AOC compliance testing.
/// </summary>
public sealed class AdvisorySchemaValidator : IAdvisorySchemaValidator
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IAocGuard _guard;
private readonly AocGuardOptions _options;
public AdvisorySchemaValidator(IAocGuard guard, IOptions<AocGuardOptions>? options = null)
{
_guard = guard ?? throw new ArgumentNullException(nameof(guard));
_options = options?.Value ?? AocGuardOptions.Default;
}
/// <inheritdoc />
public AocGuardResult ValidateSchema(AdvisoryRawDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var json = SerializeDocument(document);
return _guard.Validate(json, _options);
}
/// <inheritdoc />
public AocGuardResult ValidateForbiddenFields(AdvisoryRawDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var result = ValidateSchema(document);
return FilterByCode(result, AocViolationCode.ForbiddenField);
}
/// <inheritdoc />
public AocGuardResult ValidateDerivedFields(AdvisoryRawDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var result = ValidateSchema(document);
return FilterByCode(result, AocViolationCode.DerivedFindingDetected);
}
/// <inheritdoc />
public AocGuardResult ValidateAllowedFields(AdvisoryRawDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var result = ValidateSchema(document);
return FilterByCode(result, AocViolationCode.UnknownField);
}
/// <inheritdoc />
public AocGuardResult ValidateMergeAttempt(AdvisoryRawDocument document)
{
ArgumentNullException.ThrowIfNull(document);
// Merge attempts are indicated by presence of "merged_from" field,
// which is detected as ForbiddenField. We check for this specific field.
var result = ValidateSchema(document);
var mergeViolations = result.Violations
.Where(v => v.Code == AocViolationCode.ForbiddenField &&
v.Path.Contains("merged_from", StringComparison.OrdinalIgnoreCase))
.Select(v => AocViolation.Create(
AocViolationCode.MergeAttempt,
v.Path,
"Merge attempts are not allowed in AOC documents. Use Link-Not-Merge pattern."))
.ToImmutableArray();
return mergeViolations.Length > 0
? new AocGuardResult(false, mergeViolations)
: AocGuardResult.Success;
}
private static JsonElement SerializeDocument(AdvisoryRawDocument document)
{
var normalized = NormalizeDocument(document);
var serialized = JsonSerializer.Serialize(normalized, SerializerOptions);
using var jsonDoc = JsonDocument.Parse(serialized);
return jsonDoc.RootElement.Clone();
}
private static AocGuardResult FilterByCode(AocGuardResult result, AocViolationCode code)
{
var filtered = result.Violations
.Where(v => v.Code == code)
.ToImmutableArray();
return filtered.Length > 0
? new AocGuardResult(false, filtered)
: AocGuardResult.Success;
}
private static AdvisoryRawDocument NormalizeDocument(AdvisoryRawDocument document)
{
var identifiers = document.Identifiers with
{
Aliases = Normalize(document.Identifiers.Aliases)
};
var linkset = document.Linkset with
{
Aliases = Normalize(document.Linkset.Aliases),
PackageUrls = Normalize(document.Linkset.PackageUrls),
Cpes = Normalize(document.Linkset.Cpes),
References = Normalize(document.Linkset.References),
ReconciledFrom = Normalize(document.Linkset.ReconciledFrom),
Notes = Normalize(document.Linkset.Notes)
};
return document with
{
Identifiers = identifiers,
Linkset = linkset,
Links = Normalize(document.Links)
};
}
private static ImmutableArray<T> Normalize<T>(ImmutableArray<T> value) =>
value.IsDefault ? ImmutableArray<T>.Empty : value;
private static ImmutableDictionary<TKey, TValue> Normalize<TKey, TValue>(ImmutableDictionary<TKey, TValue> value)
where TKey : notnull =>
value == default ? ImmutableDictionary<TKey, TValue>.Empty : value;
}

View File

@@ -38,6 +38,14 @@ public static class AocServiceCollectionExtensions
// Append-only write guard for observations (LNM-21-004)
services.TryAddSingleton<IAdvisoryObservationWriteGuard, AdvisoryObservationWriteGuard>();
// Schema validator for granular AOC validation (WEB-AOC-19-002)
services.TryAddSingleton<IAdvisorySchemaValidator>(sp =>
{
var guard = sp.GetRequiredService<IAocGuard>();
var options = sp.GetService<IOptions<AocGuardOptions>>();
return new AdvisorySchemaValidator(guard, options);
});
return services;
}
}

View File

@@ -0,0 +1,48 @@
using StellaOps.Aoc;
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Aoc;
/// <summary>
/// Provides granular schema validation for advisory documents against the AOC contract.
/// Per WEB-AOC-19-002, exposes specific validation checks for test coverage.
/// </summary>
public interface IAdvisorySchemaValidator
{
/// <summary>
/// Validates the entire document schema.
/// </summary>
/// <param name="document">Raw advisory document to validate.</param>
/// <returns>Validation result with all violations.</returns>
AocGuardResult ValidateSchema(AdvisoryRawDocument document);
/// <summary>
/// Validates that no forbidden fields are present (ERR_AOC_001).
/// Forbidden fields include: severity, cvss, merged_from, consensus_provider, etc.
/// </summary>
/// <param name="document">Raw advisory document to validate.</param>
/// <returns>Validation result with forbidden field violations only.</returns>
AocGuardResult ValidateForbiddenFields(AdvisoryRawDocument document);
/// <summary>
/// Validates that no derived fields are present (ERR_AOC_006).
/// Derived fields are those prefixed with "effective_".
/// </summary>
/// <param name="document">Raw advisory document to validate.</param>
/// <returns>Validation result with derived field violations only.</returns>
AocGuardResult ValidateDerivedFields(AdvisoryRawDocument document);
/// <summary>
/// Validates that only allowed fields are present (ERR_AOC_007).
/// </summary>
/// <param name="document">Raw advisory document to validate.</param>
/// <returns>Validation result with unknown field violations only.</returns>
AocGuardResult ValidateAllowedFields(AdvisoryRawDocument document);
/// <summary>
/// Detects merge attempt indicators (ERR_AOC_002).
/// </summary>
/// <param name="document">Raw advisory document to validate.</param>
/// <returns>Validation result with merge attempt violations only.</returns>
AocGuardResult ValidateMergeAttempt(AdvisoryRawDocument document);
}

View File

@@ -45,6 +45,32 @@ public interface IVendorRiskSignalProvider
string tenantId,
string linksetId,
CancellationToken cancellationToken);
/// <summary>
/// Gets a consolidated risk signal for an advisory (merges all vendor observations).
/// Per CONCELIER-RISK-68-001, used by Policy Studio signal picker.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="advisoryId">Advisory identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Consolidated risk signal, or null if no observations exist.</returns>
Task<VendorRiskSignal?> GetSignalAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken);
/// <summary>
/// Gets consolidated risk signals for multiple advisories in batch.
/// Per CONCELIER-RISK-68-001, used by Policy Studio signal picker for bulk operations.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="advisoryIds">Advisory identifiers.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of consolidated risk signals.</returns>
Task<IReadOnlyList<VendorRiskSignal>> GetSignalsBatchAsync(
string tenantId,
IEnumerable<string> advisoryIds,
CancellationToken cancellationToken);
}
/// <summary>

View File

@@ -0,0 +1,92 @@
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Concelier.Core.Risk.PolicyStudio;
/// <summary>
/// Interface for picking and mapping advisory signals to Policy Studio input format.
/// Per CONCELIER-RISK-68-001, all selected fields must be provenance-backed.
/// </summary>
public interface IPolicyStudioSignalPicker
{
/// <summary>
/// Picks advisory signals for a specific advisory and maps to Policy Studio input format.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="advisoryId">Advisory identifier.</param>
/// <param name="options">Options controlling field selection.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Policy Studio signal input with provenance metadata.</returns>
Task<PolicyStudioSignalInput?> PickAsync(
string tenantId,
string advisoryId,
PolicyStudioSignalOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Picks advisory signals for multiple advisories in batch.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="advisoryIds">Advisory identifiers.</param>
/// <param name="options">Options controlling field selection.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary mapping advisory IDs to their Policy Studio signal inputs.</returns>
Task<ImmutableDictionary<string, PolicyStudioSignalInput>> PickBatchAsync(
string tenantId,
IEnumerable<string> advisoryIds,
PolicyStudioSignalOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Maps an existing vendor risk signal to Policy Studio input format.
/// </summary>
/// <param name="signal">The vendor risk signal to map.</param>
/// <param name="options">Options controlling field selection.</param>
/// <returns>Policy Studio signal input with provenance metadata.</returns>
PolicyStudioSignalInput MapFromSignal(
VendorRiskSignal signal,
PolicyStudioSignalOptions? options = null);
}
/// <summary>
/// Options for controlling advisory signal selection.
/// </summary>
public sealed record PolicyStudioSignalOptions
{
/// <summary>
/// Include CVSS score data. Default is true.
/// </summary>
public bool IncludeCvss { get; init; } = true;
/// <summary>
/// Include KEV status data. Default is true.
/// </summary>
public bool IncludeKev { get; init; } = true;
/// <summary>
/// Include fix availability data. Default is true.
/// </summary>
public bool IncludeFixAvailability { get; init; } = true;
/// <summary>
/// Include severity derived fields. Default is true.
/// </summary>
public bool IncludeSeverity { get; init; } = true;
/// <summary>
/// Preferred CVSS version for score selection (e.g., "cvss_v31", "cvss_v40").
/// If not specified, uses the highest available version.
/// </summary>
public string? PreferredCvssVersion { get; init; }
/// <summary>
/// Include detailed provenance in the output. Default is true.
/// </summary>
public bool IncludeProvenance { get; init; } = true;
/// <summary>
/// Default options instance.
/// </summary>
public static PolicyStudioSignalOptions Default { get; } = new();
}

View File

@@ -0,0 +1,171 @@
using System;
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.Core.Risk.PolicyStudio;
/// <summary>
/// Policy Studio input model for advisory signals.
/// Per CONCELIER-RISK-68-001, all fields are provenance-backed.
/// This model is designed to be serialized to JSON for Policy Studio consumption
/// per CONTRACT-POLICY-STUDIO-007.
/// </summary>
public sealed record PolicyStudioSignalInput
{
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant_id")]
public required string TenantId { get; init; }
/// <summary>
/// Advisory identifier (e.g., CVE-2024-1234, GHSA-xxx).
/// </summary>
[JsonPropertyName("advisory_id")]
public required string AdvisoryId { get; init; }
/// <summary>
/// CVSS score (highest available based on options).
/// </summary>
[JsonPropertyName("cvss")]
public double? Cvss { get; init; }
/// <summary>
/// CVSS version for the reported score.
/// </summary>
[JsonPropertyName("cvss_version")]
public string? CvssVersion { get; init; }
/// <summary>
/// CVSS vector string.
/// </summary>
[JsonPropertyName("cvss_vector")]
public string? CvssVector { get; init; }
/// <summary>
/// Severity tier (critical, high, medium, low, informational).
/// </summary>
[JsonPropertyName("severity")]
public string? Severity { get; init; }
/// <summary>
/// Indicates if the vulnerability is in the KEV (Known Exploited Vulnerabilities) list.
/// </summary>
[JsonPropertyName("kev")]
public bool? Kev { get; init; }
/// <summary>
/// Date the vulnerability was added to KEV, if applicable.
/// </summary>
[JsonPropertyName("kev_date_added")]
public DateTimeOffset? KevDateAdded { get; init; }
/// <summary>
/// KEV remediation due date, if applicable.
/// </summary>
[JsonPropertyName("kev_due_date")]
public DateTimeOffset? KevDueDate { get; init; }
/// <summary>
/// Indicates if a fix is available for any affected package.
/// </summary>
[JsonPropertyName("fix_available")]
public bool? FixAvailable { get; init; }
/// <summary>
/// Fixed version(s) if a fix is available.
/// </summary>
[JsonPropertyName("fixed_versions")]
public ImmutableArray<string>? FixedVersions { get; init; }
/// <summary>
/// Date the signal was extracted from source observations.
/// </summary>
[JsonPropertyName("extracted_at")]
public DateTimeOffset ExtractedAt { get; init; }
/// <summary>
/// Provenance metadata for policy audit trail.
/// </summary>
[JsonPropertyName("provenance")]
public PolicyStudioSignalProvenance? Provenance { get; init; }
}
/// <summary>
/// Provenance metadata for Policy Studio signal input.
/// Ensures audit trail for policy evaluation decisions.
/// </summary>
public sealed record PolicyStudioSignalProvenance
{
/// <summary>
/// Source observation IDs that contributed to this signal.
/// </summary>
[JsonPropertyName("observation_ids")]
public ImmutableArray<string> ObservationIds { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Source vendors/feeds that provided the data.
/// </summary>
[JsonPropertyName("sources")]
public ImmutableArray<string> Sources { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Observation hashes for integrity verification.
/// </summary>
[JsonPropertyName("observation_hashes")]
public ImmutableArray<string> ObservationHashes { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Provenance details for the CVSS score field.
/// </summary>
[JsonPropertyName("cvss_provenance")]
public PolicyStudioFieldProvenance? CvssProvenance { get; init; }
/// <summary>
/// Provenance details for the KEV status field.
/// </summary>
[JsonPropertyName("kev_provenance")]
public PolicyStudioFieldProvenance? KevProvenance { get; init; }
/// <summary>
/// Provenance details for the fix availability field.
/// </summary>
[JsonPropertyName("fix_provenance")]
public PolicyStudioFieldProvenance? FixProvenance { get; init; }
}
/// <summary>
/// Field-level provenance for individual signal fields.
/// </summary>
public sealed record PolicyStudioFieldProvenance
{
/// <summary>
/// Vendor that provided this field's data.
/// </summary>
[JsonPropertyName("vendor")]
public required string Vendor { get; init; }
/// <summary>
/// Source feed/API that provided the data.
/// </summary>
[JsonPropertyName("source")]
public required string Source { get; init; }
/// <summary>
/// Observation hash for the data.
/// </summary>
[JsonPropertyName("observation_hash")]
public required string ObservationHash { get; init; }
/// <summary>
/// When the data was fetched from the source.
/// </summary>
[JsonPropertyName("fetched_at")]
public DateTimeOffset FetchedAt { get; init; }
/// <summary>
/// Upstream identifier from the source (e.g., NVD ID, GHSA ID).
/// </summary>
[JsonPropertyName("upstream_id")]
public string? UpstreamId { get; init; }
}

View File

@@ -0,0 +1,255 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Concelier.Core.Risk.PolicyStudio;
/// <summary>
/// Default implementation of <see cref="IPolicyStudioSignalPicker"/>.
/// Per CONCELIER-RISK-68-001, all selected fields are provenance-backed.
/// </summary>
public sealed class PolicyStudioSignalPicker : IPolicyStudioSignalPicker
{
private readonly IVendorRiskSignalProvider _signalProvider;
private readonly ILogger<PolicyStudioSignalPicker> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new instance of <see cref="PolicyStudioSignalPicker"/>.
/// </summary>
public PolicyStudioSignalPicker(
IVendorRiskSignalProvider signalProvider,
ILogger<PolicyStudioSignalPicker> logger,
TimeProvider timeProvider)
{
_signalProvider = signalProvider ?? throw new ArgumentNullException(nameof(signalProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <inheritdoc />
public async Task<PolicyStudioSignalInput?> PickAsync(
string tenantId,
string advisoryId,
PolicyStudioSignalOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryId);
options ??= PolicyStudioSignalOptions.Default;
_logger.LogDebug(
"Picking advisory signals for Policy Studio: tenant={TenantId}, advisory={AdvisoryId}",
tenantId, advisoryId);
var signal = await _signalProvider
.GetSignalAsync(tenantId, advisoryId, cancellationToken)
.ConfigureAwait(false);
if (signal is null)
{
_logger.LogDebug(
"No risk signal found for advisory {AdvisoryId} in tenant {TenantId}",
advisoryId, tenantId);
return null;
}
return MapFromSignal(signal, options);
}
/// <inheritdoc />
public async Task<ImmutableDictionary<string, PolicyStudioSignalInput>> PickBatchAsync(
string tenantId,
IEnumerable<string> advisoryIds,
PolicyStudioSignalOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(advisoryIds);
options ??= PolicyStudioSignalOptions.Default;
var idList = advisoryIds.ToList();
if (idList.Count == 0)
{
return ImmutableDictionary<string, PolicyStudioSignalInput>.Empty;
}
_logger.LogDebug(
"Picking advisory signals for Policy Studio batch: tenant={TenantId}, count={Count}",
tenantId, idList.Count);
var signals = await _signalProvider
.GetSignalsBatchAsync(tenantId, idList, cancellationToken)
.ConfigureAwait(false);
var builder = ImmutableDictionary.CreateBuilder<string, PolicyStudioSignalInput>();
foreach (var signal in signals)
{
var input = MapFromSignal(signal, options);
builder[signal.AdvisoryId] = input;
}
return builder.ToImmutable();
}
/// <inheritdoc />
public PolicyStudioSignalInput MapFromSignal(
VendorRiskSignal signal,
PolicyStudioSignalOptions? options = null)
{
ArgumentNullException.ThrowIfNull(signal);
options ??= PolicyStudioSignalOptions.Default;
// Select CVSS score based on options
var cvssScore = SelectCvssScore(signal.CvssScores, options);
// Extract fix versions
ImmutableArray<string>? fixedVersions = null;
if (options.IncludeFixAvailability && !signal.FixAvailability.IsDefaultOrEmpty)
{
fixedVersions = signal.FixAvailability
.Where(f => f.Status == FixStatus.Available && !string.IsNullOrEmpty(f.FixedVersion))
.Select(f => f.FixedVersion!)
.Distinct()
.ToImmutableArray();
}
// Build provenance if requested
PolicyStudioSignalProvenance? provenance = null;
if (options.IncludeProvenance)
{
provenance = BuildProvenance(signal, cvssScore, options);
}
return new PolicyStudioSignalInput
{
TenantId = signal.TenantId,
AdvisoryId = signal.AdvisoryId,
Cvss = options.IncludeCvss ? cvssScore?.Score : null,
CvssVersion = options.IncludeCvss ? cvssScore?.NormalizedSystem : null,
CvssVector = options.IncludeCvss ? cvssScore?.Vector : null,
Severity = options.IncludeSeverity ? DetermineSeverity(signal, cvssScore) : null,
Kev = options.IncludeKev ? signal.KevStatus?.InKev : null,
KevDateAdded = options.IncludeKev ? signal.KevStatus?.DateAdded : null,
KevDueDate = options.IncludeKev ? signal.KevStatus?.DueDate : null,
FixAvailable = options.IncludeFixAvailability ? signal.HasFixAvailable : null,
FixedVersions = fixedVersions,
ExtractedAt = signal.ExtractedAt,
Provenance = provenance
};
}
private static VendorCvssScore? SelectCvssScore(
ImmutableArray<VendorCvssScore> scores,
PolicyStudioSignalOptions options)
{
if (scores.IsDefaultOrEmpty)
{
return null;
}
// If preferred version specified, try to find it
if (!string.IsNullOrEmpty(options.PreferredCvssVersion))
{
var preferred = scores.FirstOrDefault(s =>
string.Equals(s.NormalizedSystem, options.PreferredCvssVersion, StringComparison.OrdinalIgnoreCase));
if (preferred is not null)
{
return preferred;
}
}
// Otherwise, select by priority: v4.0 > v3.1 > v3.0 > v2.0
// Then by highest score within same version
return scores
.OrderByDescending(s => GetCvssVersionPriority(s.NormalizedSystem))
.ThenByDescending(s => s.Score)
.FirstOrDefault();
}
private static int GetCvssVersionPriority(string version) => version switch
{
"cvss_v40" => 4,
"cvss_v31" => 3,
"cvss_v30" => 2,
"cvss_v2" => 1,
_ => 0
};
private static string? DetermineSeverity(VendorRiskSignal signal, VendorCvssScore? cvssScore)
{
// Use KEV as highest priority indicator
if (signal.KevStatus?.InKev == true)
{
return "critical"; // KEV status implies critical severity for policy purposes
}
// Use CVSS-derived severity
return cvssScore?.EffectiveSeverity;
}
private static PolicyStudioSignalProvenance BuildProvenance(
VendorRiskSignal signal,
VendorCvssScore? cvssScore,
PolicyStudioSignalOptions options)
{
var observationIds = new HashSet<string> { signal.ObservationId };
var sources = new HashSet<string> { signal.Provenance.Source };
var hashes = new HashSet<string> { signal.Provenance.ObservationHash };
// Collect provenance from all contributing observations
foreach (var score in signal.CvssScores)
{
sources.Add(score.Provenance.Source);
hashes.Add(score.Provenance.ObservationHash);
}
if (signal.KevStatus is not null)
{
sources.Add(signal.KevStatus.Provenance.Source);
hashes.Add(signal.KevStatus.Provenance.ObservationHash);
}
foreach (var fix in signal.FixAvailability)
{
sources.Add(fix.Provenance.Source);
hashes.Add(fix.Provenance.ObservationHash);
}
return new PolicyStudioSignalProvenance
{
ObservationIds = observationIds.ToImmutableArray(),
Sources = sources.ToImmutableArray(),
ObservationHashes = hashes.ToImmutableArray(),
CvssProvenance = cvssScore is not null && options.IncludeCvss
? ToFieldProvenance(cvssScore.Provenance)
: null,
KevProvenance = signal.KevStatus is not null && options.IncludeKev
? ToFieldProvenance(signal.KevStatus.Provenance)
: null,
FixProvenance = !signal.FixAvailability.IsDefaultOrEmpty && options.IncludeFixAvailability
? ToFieldProvenance(signal.FixAvailability.First().Provenance)
: null
};
}
private static PolicyStudioFieldProvenance ToFieldProvenance(VendorRiskProvenance provenance)
{
return new PolicyStudioFieldProvenance
{
Vendor = provenance.Vendor,
Source = provenance.Source,
ObservationHash = provenance.ObservationHash,
FetchedAt = provenance.FetchedAt,
UpstreamId = provenance.UpstreamId
};
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Concelier.Core.Risk.PolicyStudio;
namespace StellaOps.Concelier.Core.Risk;
@@ -10,7 +11,7 @@ public static class RiskServiceCollectionExtensions
{
/// <summary>
/// Adds risk signal and fix-availability services to the service collection.
/// Per CONCELIER-RISK-66-002, CONCELIER-RISK-67-001, and CONCELIER-RISK-69-001.
/// Per CONCELIER-RISK-66-002, CONCELIER-RISK-67-001, CONCELIER-RISK-68-001, and CONCELIER-RISK-69-001.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
@@ -23,6 +24,9 @@ public static class RiskServiceCollectionExtensions
services.TryAddSingleton<ISourceCoverageMetricsStore, InMemorySourceCoverageMetricsStore>();
services.TryAddSingleton<ISourceCoverageMetricsPublisher, SourceCoverageMetricsPublisher>();
// Register Policy Studio signal picker (CONCELIER-RISK-68-001)
services.TryAddSingleton<IPolicyStudioSignalPicker, PolicyStudioSignalPicker>();
// Register field change notification services (CONCELIER-RISK-69-001)
services.TryAddSingleton<IAdvisoryFieldChangeNotificationPublisher, InMemoryAdvisoryFieldChangeNotificationPublisher>();
services.TryAddSingleton<IAdvisoryFieldChangeEmitter, AdvisoryFieldChangeEmitter>();

View File

@@ -9,9 +9,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Cronos" Version="0.10.0" />
<PackageReference Include="StellaOps.Policy.AuthSignals" Version="0.1.0-alpha" />
</ItemGroup>

View File

@@ -0,0 +1,150 @@
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Concelier.Core.VexLens;
/// <summary>
/// Interface for providing canonical advisory keys and cross-links for VEX Lens consumption.
/// Per CONCELIER-VEXLENS-30-001, ensures advisory key consistency without merges.
/// </summary>
public interface IVexLensAdvisoryKeyProvider
{
/// <summary>
/// Gets the canonical advisory key for a given advisory ID.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="advisoryId">Advisory identifier (may be original or alias).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Canonical advisory key with cross-links, or null if not found.</returns>
Task<VexLensCanonicalKey?> GetCanonicalKeyAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets canonical advisory keys for multiple advisory IDs in batch.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="advisoryIds">Advisory identifiers.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary mapping input IDs to their canonical keys.</returns>
Task<ImmutableDictionary<string, VexLensCanonicalKey>> GetCanonicalKeysBatchAsync(
string tenantId,
IEnumerable<string> advisoryIds,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves an advisory by alias to its canonical key.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="alias">Alias to resolve (e.g., GHSA-xxx for a CVE).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Canonical key if alias exists, or null.</returns>
Task<VexLensCanonicalKey?> ResolveByAliasAsync(
string tenantId,
string alias,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets cross-links for an advisory (all known aliases and their sources).
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="advisoryId">Advisory identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Cross-links with provenance.</returns>
Task<VexLensCrossLinks?> GetCrossLinksAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Canonical advisory key for VEX Lens correlation.
/// Per CONTRACT-ADVISORY-KEY-001.
/// </summary>
public sealed record VexLensCanonicalKey
{
/// <summary>
/// The canonical advisory key used for correlation.
/// CVE identifiers remain as-is; others are prefixed with scope (ECO:, VND:, DST:, UNK:).
/// </summary>
public required string AdvisoryKey { get; init; }
/// <summary>
/// Scope/authority level of the advisory.
/// </summary>
public required VexLensAdvisoryScope Scope { get; init; }
/// <summary>
/// Original identifier that was canonicalized.
/// </summary>
public required string OriginalId { get; init; }
/// <summary>
/// Identifier type (cve, ghsa, rhsa, dsa, usn, msrc, other).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// All known aliases for this advisory.
/// </summary>
public ImmutableArray<VexLensAdvisoryLink> Links { get; init; } = ImmutableArray<VexLensAdvisoryLink>.Empty;
/// <summary>
/// Tenant ID for scoping.
/// </summary>
public required string TenantId { get; init; }
}
/// <summary>
/// Advisory scope/authority level per CONTRACT-ADVISORY-KEY-001.
/// </summary>
public enum VexLensAdvisoryScope
{
/// <summary>Unknown or unclassified scope.</summary>
Unknown = 0,
/// <summary>Global identifiers (CVE).</summary>
Global = 1,
/// <summary>Ecosystem-specific (GHSA).</summary>
Ecosystem = 2,
/// <summary>Vendor-specific (RHSA, MSRC).</summary>
Vendor = 3,
/// <summary>Distribution-specific (DSA, USN).</summary>
Distribution = 4
}
/// <summary>
/// Link to an original or alias advisory identifier.
/// </summary>
public sealed record VexLensAdvisoryLink
{
/// <summary>
/// The advisory identifier value.
/// </summary>
public required string Identifier { get; init; }
/// <summary>
/// Identifier type (cve, ghsa, rhsa, dsa, usn, msrc, other).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// True if this is the original identifier provided at ingest time.
/// </summary>
public bool IsOriginal { get; init; }
/// <summary>
/// Source that provided this identifier.
/// </summary>
public string? Source { get; init; }
/// <summary>
/// When this link was discovered.
/// </summary>
public DateTimeOffset? DiscoveredAt { get; init; }
}

View File

@@ -0,0 +1,417 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.Core.Linksets;
namespace StellaOps.Concelier.Core.VexLens;
/// <summary>
/// Default implementation of <see cref="IVexLensAdvisoryKeyProvider"/>.
/// Per CONCELIER-VEXLENS-30-001, provides advisory key consistency for VEX Lens consumption.
/// </summary>
public sealed partial class VexLensAdvisoryKeyProvider : IVexLensAdvisoryKeyProvider
{
private readonly IAdvisoryLinksetLookup _linksetLookup;
private readonly ILogger<VexLensAdvisoryKeyProvider> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new instance of <see cref="VexLensAdvisoryKeyProvider"/>.
/// </summary>
public VexLensAdvisoryKeyProvider(
IAdvisoryLinksetLookup linksetLookup,
ILogger<VexLensAdvisoryKeyProvider> logger,
TimeProvider timeProvider)
{
_linksetLookup = linksetLookup ?? throw new ArgumentNullException(nameof(linksetLookup));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <inheritdoc />
public async Task<VexLensCanonicalKey?> GetCanonicalKeyAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryId);
_logger.LogDebug(
"Getting canonical key for VEX Lens: tenant={TenantId}, advisory={AdvisoryId}",
tenantId, advisoryId);
// First, canonicalize the input advisory ID
var canonicalKey = Canonicalize(advisoryId);
var scope = DetermineScope(advisoryId);
var type = DetermineType(advisoryId);
// Look up linksets to get cross-links
var linksets = await _linksetLookup.FindByTenantAsync(
tenantId,
advisoryIds: new[] { advisoryId },
sources: null,
cursor: null,
limit: 100,
cancellationToken).ConfigureAwait(false);
var links = new List<VexLensAdvisoryLink>
{
new VexLensAdvisoryLink
{
Identifier = advisoryId,
Type = type,
IsOriginal = true
}
};
// Collect aliases from linksets
foreach (var linkset in linksets)
{
// The linkset may have normalized data with additional identifiers
if (linkset.Normalized is not null)
{
// Collect any additional identifiers from normalized data
// (implementation depends on linkset structure)
}
}
return new VexLensCanonicalKey
{
AdvisoryKey = canonicalKey,
Scope = scope,
OriginalId = advisoryId,
Type = type,
Links = links.ToImmutableArray(),
TenantId = tenantId
};
}
/// <inheritdoc />
public async Task<ImmutableDictionary<string, VexLensCanonicalKey>> GetCanonicalKeysBatchAsync(
string tenantId,
IEnumerable<string> advisoryIds,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(advisoryIds);
var idList = advisoryIds.ToList();
if (idList.Count == 0)
{
return ImmutableDictionary<string, VexLensCanonicalKey>.Empty;
}
_logger.LogDebug(
"Getting canonical keys batch for VEX Lens: tenant={TenantId}, count={Count}",
tenantId, idList.Count);
var builder = ImmutableDictionary.CreateBuilder<string, VexLensCanonicalKey>();
foreach (var advisoryId in idList)
{
var key = await GetCanonicalKeyAsync(tenantId, advisoryId, cancellationToken)
.ConfigureAwait(false);
if (key is not null)
{
builder[advisoryId] = key;
}
}
return builder.ToImmutable();
}
/// <inheritdoc />
public async Task<VexLensCanonicalKey?> ResolveByAliasAsync(
string tenantId,
string alias,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(alias);
_logger.LogDebug(
"Resolving advisory by alias for VEX Lens: tenant={TenantId}, alias={Alias}",
tenantId, alias);
// Try to find linksets that contain this alias
var linksets = await _linksetLookup.FindByTenantAsync(
tenantId,
advisoryIds: new[] { alias },
sources: null,
cursor: null,
limit: 1,
cancellationToken).ConfigureAwait(false);
if (linksets.Count == 0)
{
return null;
}
var linkset = linksets.First();
return new VexLensCanonicalKey
{
AdvisoryKey = Canonicalize(linkset.AdvisoryId),
Scope = DetermineScope(linkset.AdvisoryId),
OriginalId = linkset.AdvisoryId,
Type = DetermineType(linkset.AdvisoryId),
Links = ImmutableArray.Create(new VexLensAdvisoryLink
{
Identifier = alias,
Type = DetermineType(alias),
IsOriginal = false,
Source = linkset.Source
}),
TenantId = tenantId
};
}
/// <inheritdoc />
public async Task<VexLensCrossLinks?> GetCrossLinksAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryId);
_logger.LogDebug(
"Getting cross-links for VEX Lens: tenant={TenantId}, advisory={AdvisoryId}",
tenantId, advisoryId);
var linksets = await _linksetLookup.FindByTenantAsync(
tenantId,
advisoryIds: new[] { advisoryId },
sources: null,
cursor: null,
limit: 100,
cancellationToken).ConfigureAwait(false);
if (linksets.Count == 0)
{
return null;
}
var canonicalKey = Canonicalize(advisoryId);
var now = _timeProvider.GetUtcNow();
// Collect observations and sources
var observations = new List<VexLensObservationRef>();
var linksetRefs = new List<VexLensLinksetRef>();
var sourceStats = new Dictionary<string, (int count, DateTimeOffset latest)>(StringComparer.OrdinalIgnoreCase);
var identifiers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { advisoryId };
foreach (var linkset in linksets)
{
// Add observation refs
foreach (var obsId in linkset.ObservationIds)
{
observations.Add(new VexLensObservationRef
{
ObservationId = obsId,
Source = linkset.Source,
ContentHash = linkset.Provenance?.ObservationHashes?.FirstOrDefault() ?? "unknown",
CreatedAt = linkset.CreatedAt,
UpdatedAt = linkset.CreatedAt
});
}
// Add linkset ref
linksetRefs.Add(new VexLensLinksetRef
{
LinksetId = $"{linkset.TenantId}:{linkset.Source}:{linkset.AdvisoryId}",
Source = linkset.Source,
ObservationCount = linkset.ObservationIds.Length,
Confidence = linkset.Confidence,
CreatedAt = linkset.CreatedAt
});
// Track source statistics
if (!sourceStats.TryGetValue(linkset.Source, out var stats))
{
stats = (0, DateTimeOffset.MinValue);
}
sourceStats[linkset.Source] = (
stats.count + linkset.ObservationIds.Length,
linkset.CreatedAt > stats.latest ? linkset.CreatedAt : stats.latest
);
}
var sources = sourceStats.Select(kvp => new VexLensSourceRef
{
SourceId = kvp.Key,
ObservationCount = kvp.Value.count,
LatestObservationAt = kvp.Value.latest
}).ToImmutableArray();
var identifierLinks = identifiers.Select(id => new VexLensAdvisoryLink
{
Identifier = id,
Type = DetermineType(id),
IsOriginal = string.Equals(id, advisoryId, StringComparison.OrdinalIgnoreCase)
}).ToImmutableArray();
// Compute content hash for provenance
var contentHash = ComputeContentHash(canonicalKey, observations, linksetRefs);
return new VexLensCrossLinks
{
AdvisoryKey = canonicalKey,
TenantId = tenantId,
Identifiers = identifierLinks,
Observations = observations.ToImmutableArray(),
Linksets = linksetRefs.ToImmutableArray(),
Sources = sources,
UpdatedAt = now,
Provenance = new VexLensCrossLinksProvenance
{
ContentHash = contentHash,
ComputedAt = now
}
};
}
/// <summary>
/// Canonicalizes an advisory ID per CONTRACT-ADVISORY-KEY-001.
/// </summary>
private static string Canonicalize(string advisoryId)
{
var trimmed = advisoryId.Trim().ToUpperInvariant();
// CVE identifiers remain as-is
if (CvePattern().IsMatch(trimmed))
{
return trimmed;
}
// GHSA identifiers get ECO: prefix
if (GhsaPattern().IsMatch(trimmed))
{
return $"ECO:{trimmed}";
}
// RHSA/RHBA/RHEA get VND: prefix
if (RhPattern().IsMatch(trimmed))
{
return $"VND:{trimmed}";
}
// DSA gets DST: prefix
if (DsaPattern().IsMatch(trimmed))
{
return $"DST:{trimmed}";
}
// USN gets DST: prefix
if (UsnPattern().IsMatch(trimmed))
{
return $"DST:{trimmed}";
}
// MSRC (ADV-xxxx) gets VND: prefix
if (MsrcPattern().IsMatch(trimmed))
{
return $"VND:{trimmed}";
}
// Unknown scope
return $"UNK:{trimmed}";
}
private static VexLensAdvisoryScope DetermineScope(string advisoryId)
{
var trimmed = advisoryId.Trim().ToUpperInvariant();
if (CvePattern().IsMatch(trimmed))
return VexLensAdvisoryScope.Global;
if (GhsaPattern().IsMatch(trimmed))
return VexLensAdvisoryScope.Ecosystem;
if (RhPattern().IsMatch(trimmed) || MsrcPattern().IsMatch(trimmed))
return VexLensAdvisoryScope.Vendor;
if (DsaPattern().IsMatch(trimmed) || UsnPattern().IsMatch(trimmed))
return VexLensAdvisoryScope.Distribution;
return VexLensAdvisoryScope.Unknown;
}
private static string DetermineType(string advisoryId)
{
var trimmed = advisoryId.Trim().ToUpperInvariant();
if (CvePattern().IsMatch(trimmed))
return "cve";
if (GhsaPattern().IsMatch(trimmed))
return "ghsa";
if (trimmed.StartsWith("RHSA-", StringComparison.OrdinalIgnoreCase))
return "rhsa";
if (trimmed.StartsWith("DSA-", StringComparison.OrdinalIgnoreCase))
return "dsa";
if (trimmed.StartsWith("USN-", StringComparison.OrdinalIgnoreCase))
return "usn";
if (trimmed.StartsWith("ADV-", StringComparison.OrdinalIgnoreCase))
return "msrc";
return "other";
}
private static string ComputeContentHash(
string advisoryKey,
IEnumerable<VexLensObservationRef> observations,
IEnumerable<VexLensLinksetRef> linksets)
{
var builder = new StringBuilder();
builder.Append(advisoryKey);
builder.Append('|');
foreach (var obs in observations.OrderBy(o => o.ObservationId, StringComparer.Ordinal))
{
builder.Append(obs.ObservationId);
builder.Append(':');
builder.Append(obs.ContentHash);
builder.Append('|');
}
foreach (var ls in linksets.OrderBy(l => l.LinksetId, StringComparer.Ordinal))
{
builder.Append(ls.LinksetId);
builder.Append('|');
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
[GeneratedRegex(@"^CVE-\d{4}-\d{4,}$", RegexOptions.IgnoreCase)]
private static partial Regex CvePattern();
[GeneratedRegex(@"^GHSA-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$", RegexOptions.IgnoreCase)]
private static partial Regex GhsaPattern();
[GeneratedRegex(@"^RH[A-Z]{2}-\d{4}:\d+$", RegexOptions.IgnoreCase)]
private static partial Regex RhPattern();
[GeneratedRegex(@"^DSA-\d+(-\d+)?$", RegexOptions.IgnoreCase)]
private static partial Regex DsaPattern();
[GeneratedRegex(@"^USN-\d+(-\d+)?$", RegexOptions.IgnoreCase)]
private static partial Regex UsnPattern();
[GeneratedRegex(@"^ADV-\d{4}-\d+$", RegexOptions.IgnoreCase)]
private static partial Regex MsrcPattern();
}

View File

@@ -0,0 +1,175 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.VexLens;
/// <summary>
/// Cross-links between Concelier advisory observations and VEX Lens.
/// Per CONCELIER-VEXLENS-30-001, provides evidence citations without merges.
/// </summary>
public sealed record VexLensCrossLinks
{
/// <summary>
/// Canonical advisory key.
/// </summary>
public required string AdvisoryKey { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// All known identifiers for this advisory (CVE, GHSA, vendor IDs, etc.).
/// </summary>
public ImmutableArray<VexLensAdvisoryLink> Identifiers { get; init; } = ImmutableArray<VexLensAdvisoryLink>.Empty;
/// <summary>
/// Observation references from Concelier.
/// </summary>
public ImmutableArray<VexLensObservationRef> Observations { get; init; } = ImmutableArray<VexLensObservationRef>.Empty;
/// <summary>
/// Linkset references (if Link-Not-Merge is enabled).
/// </summary>
public ImmutableArray<VexLensLinksetRef> Linksets { get; init; } = ImmutableArray<VexLensLinksetRef>.Empty;
/// <summary>
/// Sources that contributed observations.
/// </summary>
public ImmutableArray<VexLensSourceRef> Sources { get; init; } = ImmutableArray<VexLensSourceRef>.Empty;
/// <summary>
/// When the cross-links were last updated.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Provenance metadata for the cross-links.
/// </summary>
public VexLensCrossLinksProvenance? Provenance { get; init; }
}
/// <summary>
/// Reference to a Concelier observation for VEX Lens.
/// </summary>
public sealed record VexLensObservationRef
{
/// <summary>
/// Observation identifier.
/// </summary>
public required string ObservationId { get; init; }
/// <summary>
/// Source that provided this observation.
/// </summary>
public required string Source { get; init; }
/// <summary>
/// Content hash of the observation.
/// </summary>
public required string ContentHash { get; init; }
/// <summary>
/// When the observation was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the observation was last updated.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Upstream ID from the source.
/// </summary>
public string? UpstreamId { get; init; }
}
/// <summary>
/// Reference to a Concelier linkset for VEX Lens.
/// </summary>
public sealed record VexLensLinksetRef
{
/// <summary>
/// Linkset identifier.
/// </summary>
public required string LinksetId { get; init; }
/// <summary>
/// Source that the linkset is scoped to.
/// </summary>
public required string Source { get; init; }
/// <summary>
/// Number of observations in the linkset.
/// </summary>
public int ObservationCount { get; init; }
/// <summary>
/// Confidence score for the linkset (0.0 - 1.0).
/// </summary>
public double? Confidence { get; init; }
/// <summary>
/// When the linkset was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Reference to a source that contributed observations.
/// </summary>
public sealed record VexLensSourceRef
{
/// <summary>
/// Source identifier.
/// </summary>
public required string SourceId { get; init; }
/// <summary>
/// Source display name.
/// </summary>
public string? DisplayName { get; init; }
/// <summary>
/// Source type (vendor, distribution, ecosystem).
/// </summary>
public string? Type { get; init; }
/// <summary>
/// Number of observations from this source.
/// </summary>
public int ObservationCount { get; init; }
/// <summary>
/// Most recent observation timestamp from this source.
/// </summary>
public DateTimeOffset? LatestObservationAt { get; init; }
}
/// <summary>
/// Provenance metadata for cross-links.
/// </summary>
public sealed record VexLensCrossLinksProvenance
{
/// <summary>
/// Hash of the cross-links for integrity verification.
/// </summary>
public required string ContentHash { get; init; }
/// <summary>
/// When the cross-links were computed.
/// </summary>
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Version of the cross-link algorithm.
/// </summary>
public string Version { get; init; } = "1.0";
/// <summary>
/// Job ID that computed these cross-links (if applicable).
/// </summary>
public string? JobId { get; init; }
}

View File

@@ -0,0 +1,39 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Concelier.Core.VexLens;
/// <summary>
/// Service collection extensions for VEX Lens integration.
/// Per CONCELIER-VEXLENS-30-001.
/// </summary>
public static class VexLensServiceCollectionExtensions
{
/// <summary>
/// Adds VEX Lens advisory key provider services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddConcelierVexLensServices(this IServiceCollection services)
{
services.TryAddSingleton<IVexLensAdvisoryKeyProvider, VexLensAdvisoryKeyProvider>();
// Ensure TimeProvider is registered
services.TryAddSingleton(TimeProvider.System);
return services;
}
/// <summary>
/// Adds a custom implementation of <see cref="IVexLensAdvisoryKeyProvider"/>.
/// </summary>
/// <typeparam name="TProvider">The provider implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddVexLensAdvisoryKeyProvider<TProvider>(this IServiceCollection services)
where TProvider : class, IVexLensAdvisoryKeyProvider
{
services.AddSingleton<IVexLensAdvisoryKeyProvider, TProvider>();
return services;
}
}

View File

@@ -16,9 +16,9 @@
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -15,8 +15,8 @@
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -1,18 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Semver" Version="2.3.0" />
<ProjectReference Include="../StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Normalization/StellaOps.Concelier.Normalization.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
</ItemGroup>
</Project>
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Semver" Version="2.3.0" />
<ProjectReference Include="../StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Normalization/StellaOps.Concelier.Normalization.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
@@ -7,7 +7,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />

View File

@@ -1,17 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Tests\**\*.cs" />
<None Remove="Tests\**\*" />
</ItemGroup>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Tests\**\*.cs" />
<None Remove="Tests\**\*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
</ItemGroup>

View File

@@ -1,12 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Bson" Version="3.5.0" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Bson" Version="3.5.0" />
</ItemGroup>
</Project>

View File

@@ -1,20 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<IsTestProject>false</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Mongo2Go" Version="3.1.3" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" />
<PackageReference Include="xunit" Version="2.9.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<IsTestProject>false</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Mongo2Go" Version="3.1.3" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" />
<PackageReference Include="xunit" Version="2.9.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,308 @@
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Tests.Aoc;
/// <summary>
/// Tests for <see cref="AdvisorySchemaValidator"/> per WEB-AOC-19-002.
/// Covers ERR_AOC_001 (forbidden), ERR_AOC_002 (merge), ERR_AOC_006 (derived), ERR_AOC_007 (unknown).
/// </summary>
public sealed class AdvisorySchemaValidatorTests
{
private static readonly AocGuardOptions GuardOptions = AocGuardOptions.Default;
private static AdvisoryRawDocument CreateValidDocument(string tenant = "tenant-a")
{
using var rawDocument = JsonDocument.Parse("""{"id":"demo"}""");
return new AdvisoryRawDocument(
Tenant: tenant,
Source: new RawSourceMetadata("vendor-x", "connector-y", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: "GHSA-xxxx",
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: "sha256:abc",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "OSV",
SpecVersion: "1.0",
Raw: rawDocument.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray.Create("GHSA-xxxx"),
PrimaryId: "GHSA-xxxx"),
Linkset: new RawLinkset
{
Aliases = ImmutableArray<string>.Empty,
PackageUrls = ImmutableArray<string>.Empty,
Cpes = ImmutableArray<string>.Empty,
References = ImmutableArray<RawReference>.Empty,
ReconciledFrom = ImmutableArray<string>.Empty,
Notes = ImmutableDictionary<string, string>.Empty
},
Links: ImmutableArray<RawLink>.Empty);
}
private static AdvisorySchemaValidator CreateValidator()
=> new(new AocWriteGuard(), Options.Create(GuardOptions));
[Fact]
public void ValidateSchema_AllowsValidDocument()
{
var validator = CreateValidator();
var document = CreateValidDocument();
var result = validator.ValidateSchema(document);
Assert.True(result.IsValid);
Assert.Empty(result.Violations);
}
[Fact]
public void ValidateForbiddenFields_ReturnsSuccessForValidDocument()
{
var validator = CreateValidator();
var document = CreateValidDocument();
var result = validator.ValidateForbiddenFields(document);
Assert.True(result.IsValid);
}
[Fact]
public void ValidateDerivedFields_ReturnsSuccessForValidDocument()
{
var validator = CreateValidator();
var document = CreateValidDocument();
var result = validator.ValidateDerivedFields(document);
Assert.True(result.IsValid);
}
[Fact]
public void ValidateAllowedFields_ReturnsSuccessForValidDocument()
{
var validator = CreateValidator();
var document = CreateValidDocument();
var result = validator.ValidateAllowedFields(document);
Assert.True(result.IsValid);
}
[Fact]
public void ValidateMergeAttempt_ReturnsSuccessForValidDocument()
{
var validator = CreateValidator();
var document = CreateValidDocument();
var result = validator.ValidateMergeAttempt(document);
Assert.True(result.IsValid);
}
// Direct IAocGuard tests for ERR_AOC_001, ERR_AOC_002, ERR_AOC_006, ERR_AOC_007
// These test the underlying guard behavior with arbitrary JSON
[Fact]
public void AocGuard_DetectsForbiddenField_ERR_AOC_001()
{
var guard = new AocWriteGuard();
using var jsonDoc = JsonDocument.Parse("""
{
"tenant": "test",
"severity": "high",
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
"upstream": {
"upstream_id": "CVE-2024-0001",
"content_hash": "sha256:abc",
"retrieved_at": "2024-01-01T00:00:00Z",
"signature": {"present": false},
"provenance": {}
},
"content": {"format": "OSV", "raw": {}},
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
"linkset": {}
}
""");
var result = guard.Validate(jsonDoc.RootElement, GuardOptions);
Assert.False(result.IsValid);
Assert.Contains(result.Violations, v =>
v.Code == AocViolationCode.ForbiddenField &&
v.ErrorCode == "ERR_AOC_001" &&
v.Path == "/severity");
}
[Fact]
public void AocGuard_DetectsMergedFromField_ERR_AOC_001()
{
var guard = new AocWriteGuard();
using var jsonDoc = JsonDocument.Parse("""
{
"tenant": "test",
"merged_from": ["obs-1", "obs-2"],
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
"upstream": {
"upstream_id": "CVE-2024-0001",
"content_hash": "sha256:abc",
"retrieved_at": "2024-01-01T00:00:00Z",
"signature": {"present": false},
"provenance": {}
},
"content": {"format": "OSV", "raw": {}},
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
"linkset": {}
}
""");
var result = guard.Validate(jsonDoc.RootElement, GuardOptions);
Assert.False(result.IsValid);
Assert.Contains(result.Violations, v =>
v.Code == AocViolationCode.ForbiddenField &&
v.ErrorCode == "ERR_AOC_001" &&
v.Path == "/merged_from");
}
[Fact]
public void AocGuard_DetectsDerivedField_ERR_AOC_006()
{
var guard = new AocWriteGuard();
using var jsonDoc = JsonDocument.Parse("""
{
"tenant": "test",
"effective_status": "affected",
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
"upstream": {
"upstream_id": "CVE-2024-0001",
"content_hash": "sha256:abc",
"retrieved_at": "2024-01-01T00:00:00Z",
"signature": {"present": false},
"provenance": {}
},
"content": {"format": "OSV", "raw": {}},
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
"linkset": {}
}
""");
var result = guard.Validate(jsonDoc.RootElement, GuardOptions);
Assert.False(result.IsValid);
Assert.Contains(result.Violations, v =>
v.Code == AocViolationCode.DerivedFindingDetected &&
v.ErrorCode == "ERR_AOC_006" &&
v.Path == "/effective_status");
}
[Fact]
public void AocGuard_DetectsUnknownField_ERR_AOC_007()
{
var guard = new AocWriteGuard();
using var jsonDoc = JsonDocument.Parse("""
{
"tenant": "test",
"unknown_custom_field": "value",
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
"upstream": {
"upstream_id": "CVE-2024-0001",
"content_hash": "sha256:abc",
"retrieved_at": "2024-01-01T00:00:00Z",
"signature": {"present": false},
"provenance": {}
},
"content": {"format": "OSV", "raw": {}},
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
"linkset": {}
}
""");
var result = guard.Validate(jsonDoc.RootElement, GuardOptions);
Assert.False(result.IsValid);
Assert.Contains(result.Violations, v =>
v.Code == AocViolationCode.UnknownField &&
v.ErrorCode == "ERR_AOC_007" &&
v.Path == "/unknown_custom_field");
}
[Theory]
[InlineData("cvss")]
[InlineData("cvss_vector")]
[InlineData("consensus_provider")]
[InlineData("reachability")]
[InlineData("asset_criticality")]
[InlineData("risk_score")]
public void AocGuard_DetectsAllForbiddenFields(string forbiddenField)
{
var guard = new AocWriteGuard();
var json = $$"""
{
"tenant": "test",
"{{forbiddenField}}": "forbidden_value",
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
"upstream": {
"upstream_id": "CVE-2024-0001",
"content_hash": "sha256:abc",
"retrieved_at": "2024-01-01T00:00:00Z",
"signature": {"present": false},
"provenance": {}
},
"content": {"format": "OSV", "raw": {}},
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
"linkset": {}
}
""";
using var jsonDoc = JsonDocument.Parse(json);
var result = guard.Validate(jsonDoc.RootElement, GuardOptions);
Assert.False(result.IsValid);
Assert.Contains(result.Violations, v =>
v.Code == AocViolationCode.ForbiddenField &&
v.ErrorCode == "ERR_AOC_001");
}
[Theory]
[InlineData("effective_range")]
[InlineData("effective_severity")]
[InlineData("effective_cvss")]
public void AocGuard_DetectsAllDerivedFields(string derivedField)
{
var guard = new AocWriteGuard();
var json = $$"""
{
"tenant": "test",
"{{derivedField}}": "derived_value",
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
"upstream": {
"upstream_id": "CVE-2024-0001",
"content_hash": "sha256:abc",
"retrieved_at": "2024-01-01T00:00:00Z",
"signature": {"present": false},
"provenance": {}
},
"content": {"format": "OSV", "raw": {}},
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
"linkset": {}
}
""";
using var jsonDoc = JsonDocument.Parse(json);
var result = guard.Validate(jsonDoc.RootElement, GuardOptions);
Assert.False(result.IsValid);
// Derived fields (effective_*) trigger both ForbiddenField and DerivedFindingDetected
// if they're in the forbidden list, otherwise just DerivedFindingDetected
Assert.Contains(result.Violations, v =>
v.Code == AocViolationCode.DerivedFindingDetected &&
v.ErrorCode == "ERR_AOC_006");
}
}

View File

@@ -6,6 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- Disable Concelier Testing infra which requires Storage.Mongo -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
@@ -15,6 +17,6 @@
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
<!-- Test packages inherited from Directory.Build.props -->
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>