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:
@@ -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; }
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user