sprints enhancements
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CanonicalAdvisoryEndpointExtensions.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Tasks: CANSVC-8200-016 through CANSVC-8200-019
|
||||
// Description: API endpoints for canonical advisory service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Concelier.WebService.Results;
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint extensions for canonical advisory operations.
|
||||
/// </summary>
|
||||
internal static class CanonicalAdvisoryEndpointExtensions
|
||||
{
|
||||
private const string CanonicalReadPolicy = "Concelier.Canonical.Read";
|
||||
private const string CanonicalIngestPolicy = "Concelier.Canonical.Ingest";
|
||||
|
||||
public static void MapCanonicalAdvisoryEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/canonical")
|
||||
.WithTags("Canonical Advisories");
|
||||
|
||||
// GET /api/v1/canonical/{id} - Get canonical advisory by ID
|
||||
group.MapGet("/{id:guid}", async (
|
||||
Guid id,
|
||||
ICanonicalAdvisoryService service,
|
||||
HttpContext context,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var canonical = await service.GetByIdAsync(id, ct).ConfigureAwait(false);
|
||||
|
||||
return canonical is null
|
||||
? HttpResults.NotFound(new { error = "Canonical advisory not found", id })
|
||||
: HttpResults.Ok(MapToResponse(canonical));
|
||||
})
|
||||
.WithName("GetCanonicalById")
|
||||
.WithSummary("Get canonical advisory by ID")
|
||||
.Produces<CanonicalAdvisoryResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /api/v1/canonical?cve={cve}&artifact={artifact} - Query canonical advisories
|
||||
group.MapGet("/", async (
|
||||
[FromQuery] string? cve,
|
||||
[FromQuery] string? artifact,
|
||||
[FromQuery] string? mergeHash,
|
||||
[FromQuery] int? offset,
|
||||
[FromQuery] int? limit,
|
||||
ICanonicalAdvisoryService service,
|
||||
HttpContext context,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
// Query by merge hash takes precedence
|
||||
if (!string.IsNullOrEmpty(mergeHash))
|
||||
{
|
||||
var byHash = await service.GetByMergeHashAsync(mergeHash, ct).ConfigureAwait(false);
|
||||
return byHash is null
|
||||
? HttpResults.Ok(new CanonicalAdvisoryListResponse { Items = [], TotalCount = 0 })
|
||||
: HttpResults.Ok(new CanonicalAdvisoryListResponse
|
||||
{
|
||||
Items = [MapToResponse(byHash)],
|
||||
TotalCount = 1
|
||||
});
|
||||
}
|
||||
|
||||
// Query by CVE
|
||||
if (!string.IsNullOrEmpty(cve))
|
||||
{
|
||||
var byCve = await service.GetByCveAsync(cve, ct).ConfigureAwait(false);
|
||||
return HttpResults.Ok(new CanonicalAdvisoryListResponse
|
||||
{
|
||||
Items = byCve.Select(MapToResponse).ToList(),
|
||||
TotalCount = byCve.Count
|
||||
});
|
||||
}
|
||||
|
||||
// Query by artifact
|
||||
if (!string.IsNullOrEmpty(artifact))
|
||||
{
|
||||
var byArtifact = await service.GetByArtifactAsync(artifact, ct).ConfigureAwait(false);
|
||||
return HttpResults.Ok(new CanonicalAdvisoryListResponse
|
||||
{
|
||||
Items = byArtifact.Select(MapToResponse).ToList(),
|
||||
TotalCount = byArtifact.Count
|
||||
});
|
||||
}
|
||||
|
||||
// Generic query with pagination
|
||||
var options = new CanonicalQueryOptions
|
||||
{
|
||||
Offset = offset ?? 0,
|
||||
Limit = limit ?? 50
|
||||
};
|
||||
|
||||
var result = await service.QueryAsync(options, ct).ConfigureAwait(false);
|
||||
return HttpResults.Ok(new CanonicalAdvisoryListResponse
|
||||
{
|
||||
Items = result.Items.Select(MapToResponse).ToList(),
|
||||
TotalCount = result.TotalCount,
|
||||
Offset = result.Offset,
|
||||
Limit = result.Limit
|
||||
});
|
||||
})
|
||||
.WithName("QueryCanonical")
|
||||
.WithSummary("Query canonical advisories by CVE, artifact, or merge hash")
|
||||
.Produces<CanonicalAdvisoryListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// POST /api/v1/canonical/ingest/{source} - Ingest raw advisory
|
||||
group.MapPost("/ingest/{source}", async (
|
||||
string source,
|
||||
[FromBody] RawAdvisoryRequest request,
|
||||
ICanonicalAdvisoryService service,
|
||||
HttpContext context,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "Source is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Cve))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "CVE is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.AffectsKey))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "AffectsKey is required" });
|
||||
}
|
||||
|
||||
var rawAdvisory = new RawAdvisory
|
||||
{
|
||||
SourceAdvisoryId = request.SourceAdvisoryId ?? $"{source.ToUpperInvariant()}-{request.Cve}",
|
||||
Cve = request.Cve,
|
||||
AffectsKey = request.AffectsKey,
|
||||
VersionRangeJson = request.VersionRangeJson,
|
||||
Weaknesses = request.Weaknesses ?? [],
|
||||
PatchLineage = request.PatchLineage,
|
||||
Severity = request.Severity,
|
||||
Title = request.Title,
|
||||
Summary = request.Summary,
|
||||
VendorStatus = request.VendorStatus,
|
||||
RawPayloadJson = request.RawPayloadJson,
|
||||
FetchedAt = request.FetchedAt ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var result = await service.IngestAsync(source, rawAdvisory, ct).ConfigureAwait(false);
|
||||
|
||||
var response = new IngestResultResponse
|
||||
{
|
||||
CanonicalId = result.CanonicalId,
|
||||
MergeHash = result.MergeHash,
|
||||
Decision = result.Decision.ToString(),
|
||||
SourceEdgeId = result.SourceEdgeId,
|
||||
SignatureRef = result.SignatureRef,
|
||||
ConflictReason = result.ConflictReason
|
||||
};
|
||||
|
||||
return result.Decision == MergeDecision.Conflict
|
||||
? HttpResults.Conflict(response)
|
||||
: HttpResults.Ok(response);
|
||||
})
|
||||
.WithName("IngestAdvisory")
|
||||
.WithSummary("Ingest raw advisory from source into canonical pipeline")
|
||||
.Produces<IngestResultResponse>(StatusCodes.Status200OK)
|
||||
.Produces<IngestResultResponse>(StatusCodes.Status409Conflict)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
// POST /api/v1/canonical/ingest/{source}/batch - Batch ingest advisories
|
||||
group.MapPost("/ingest/{source}/batch", async (
|
||||
string source,
|
||||
[FromBody] IEnumerable<RawAdvisoryRequest> requests,
|
||||
ICanonicalAdvisoryService service,
|
||||
HttpContext context,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "Source is required" });
|
||||
}
|
||||
|
||||
var rawAdvisories = requests.Select(request => new RawAdvisory
|
||||
{
|
||||
SourceAdvisoryId = request.SourceAdvisoryId ?? $"{source.ToUpperInvariant()}-{request.Cve}",
|
||||
Cve = request.Cve ?? throw new InvalidOperationException("CVE is required"),
|
||||
AffectsKey = request.AffectsKey ?? throw new InvalidOperationException("AffectsKey is required"),
|
||||
VersionRangeJson = request.VersionRangeJson,
|
||||
Weaknesses = request.Weaknesses ?? [],
|
||||
PatchLineage = request.PatchLineage,
|
||||
Severity = request.Severity,
|
||||
Title = request.Title,
|
||||
Summary = request.Summary,
|
||||
VendorStatus = request.VendorStatus,
|
||||
RawPayloadJson = request.RawPayloadJson,
|
||||
FetchedAt = request.FetchedAt ?? DateTimeOffset.UtcNow
|
||||
}).ToList();
|
||||
|
||||
var results = await service.IngestBatchAsync(source, rawAdvisories, ct).ConfigureAwait(false);
|
||||
|
||||
var response = new BatchIngestResultResponse
|
||||
{
|
||||
Results = results.Select(r => new IngestResultResponse
|
||||
{
|
||||
CanonicalId = r.CanonicalId,
|
||||
MergeHash = r.MergeHash,
|
||||
Decision = r.Decision.ToString(),
|
||||
SourceEdgeId = r.SourceEdgeId,
|
||||
SignatureRef = r.SignatureRef,
|
||||
ConflictReason = r.ConflictReason
|
||||
}).ToList(),
|
||||
Summary = new BatchIngestSummary
|
||||
{
|
||||
Total = results.Count,
|
||||
Created = results.Count(r => r.Decision == MergeDecision.Created),
|
||||
Merged = results.Count(r => r.Decision == MergeDecision.Merged),
|
||||
Duplicates = results.Count(r => r.Decision == MergeDecision.Duplicate),
|
||||
Conflicts = results.Count(r => r.Decision == MergeDecision.Conflict)
|
||||
}
|
||||
};
|
||||
|
||||
return HttpResults.Ok(response);
|
||||
})
|
||||
.WithName("IngestAdvisoryBatch")
|
||||
.WithSummary("Batch ingest multiple advisories from source")
|
||||
.Produces<BatchIngestResultResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
// PATCH /api/v1/canonical/{id}/status - Update canonical status
|
||||
group.MapPatch("/{id:guid}/status", async (
|
||||
Guid id,
|
||||
[FromBody] UpdateStatusRequest request,
|
||||
ICanonicalAdvisoryService service,
|
||||
HttpContext context,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!Enum.TryParse<CanonicalStatus>(request.Status, true, out var status))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "Invalid status", validValues = Enum.GetNames<CanonicalStatus>() });
|
||||
}
|
||||
|
||||
await service.UpdateStatusAsync(id, status, ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(new { id, status = status.ToString() });
|
||||
})
|
||||
.WithName("UpdateCanonicalStatus")
|
||||
.WithSummary("Update canonical advisory status")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
private static CanonicalAdvisoryResponse MapToResponse(CanonicalAdvisory canonical) => new()
|
||||
{
|
||||
Id = canonical.Id,
|
||||
Cve = canonical.Cve,
|
||||
AffectsKey = canonical.AffectsKey,
|
||||
MergeHash = canonical.MergeHash,
|
||||
Status = canonical.Status.ToString(),
|
||||
Severity = canonical.Severity,
|
||||
EpssScore = canonical.EpssScore,
|
||||
ExploitKnown = canonical.ExploitKnown,
|
||||
Title = canonical.Title,
|
||||
Summary = canonical.Summary,
|
||||
VersionRange = canonical.VersionRange,
|
||||
Weaknesses = canonical.Weaknesses,
|
||||
CreatedAt = canonical.CreatedAt,
|
||||
UpdatedAt = canonical.UpdatedAt,
|
||||
SourceEdges = canonical.SourceEdges.Select(e => new SourceEdgeResponse
|
||||
{
|
||||
Id = e.Id,
|
||||
SourceName = e.SourceName,
|
||||
SourceAdvisoryId = e.SourceAdvisoryId,
|
||||
SourceDocHash = e.SourceDocHash,
|
||||
VendorStatus = e.VendorStatus?.ToString(),
|
||||
PrecedenceRank = e.PrecedenceRank,
|
||||
HasDsseEnvelope = e.DsseEnvelope is not null,
|
||||
FetchedAt = e.FetchedAt
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
#region Response DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Response for a single canonical advisory.
|
||||
/// </summary>
|
||||
public sealed record CanonicalAdvisoryResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Cve { get; init; }
|
||||
public required string AffectsKey { get; init; }
|
||||
public required string MergeHash { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public decimal? EpssScore { get; init; }
|
||||
public bool ExploitKnown { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public VersionRange? VersionRange { get; init; }
|
||||
public IReadOnlyList<string> Weaknesses { get; init; } = [];
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
public IReadOnlyList<SourceEdgeResponse> SourceEdges { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a source edge.
|
||||
/// </summary>
|
||||
public sealed record SourceEdgeResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string SourceName { get; init; }
|
||||
public required string SourceAdvisoryId { get; init; }
|
||||
public required string SourceDocHash { get; init; }
|
||||
public string? VendorStatus { get; init; }
|
||||
public int PrecedenceRank { get; init; }
|
||||
public bool HasDsseEnvelope { get; init; }
|
||||
public DateTimeOffset FetchedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a list of canonical advisories.
|
||||
/// </summary>
|
||||
public sealed record CanonicalAdvisoryListResponse
|
||||
{
|
||||
public IReadOnlyList<CanonicalAdvisoryResponse> Items { get; init; } = [];
|
||||
public long TotalCount { get; init; }
|
||||
public int Offset { get; init; }
|
||||
public int Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for ingest result.
|
||||
/// </summary>
|
||||
public sealed record IngestResultResponse
|
||||
{
|
||||
public Guid CanonicalId { get; init; }
|
||||
public required string MergeHash { get; init; }
|
||||
public required string Decision { get; init; }
|
||||
public Guid? SourceEdgeId { get; init; }
|
||||
public Guid? SignatureRef { get; init; }
|
||||
public string? ConflictReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for batch ingest.
|
||||
/// </summary>
|
||||
public sealed record BatchIngestResultResponse
|
||||
{
|
||||
public IReadOnlyList<IngestResultResponse> Results { get; init; } = [];
|
||||
public required BatchIngestSummary Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of batch ingest results.
|
||||
/// </summary>
|
||||
public sealed record BatchIngestSummary
|
||||
{
|
||||
public int Total { get; init; }
|
||||
public int Created { get; init; }
|
||||
public int Merged { get; init; }
|
||||
public int Duplicates { get; init; }
|
||||
public int Conflicts { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Request DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Request to ingest a raw advisory.
|
||||
/// </summary>
|
||||
public sealed record RawAdvisoryRequest
|
||||
{
|
||||
public string? SourceAdvisoryId { get; init; }
|
||||
public string? Cve { get; init; }
|
||||
public string? AffectsKey { get; init; }
|
||||
public string? VersionRangeJson { get; init; }
|
||||
public IReadOnlyList<string>? Weaknesses { get; init; }
|
||||
public string? PatchLineage { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public VendorStatus? VendorStatus { get; init; }
|
||||
public string? RawPayloadJson { get; init; }
|
||||
public DateTimeOffset? FetchedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update canonical status.
|
||||
/// </summary>
|
||||
public sealed record UpdateStatusRequest
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -511,6 +511,9 @@ app.UseDeprecationHeaders();
|
||||
|
||||
app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
|
||||
|
||||
// Canonical advisory endpoints (Sprint 8200.0012.0003)
|
||||
app.MapCanonicalAdvisoryEndpoints();
|
||||
|
||||
app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvider provider, HttpContext context) =>
|
||||
{
|
||||
var (payload, etag) = provider.GetDocument();
|
||||
|
||||
@@ -16,6 +16,7 @@ using StellaOps.Concelier.Storage.Advisories;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Storage.ChangeHistory;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Plugin;
|
||||
using Json.Schema;
|
||||
using StellaOps.Cryptography;
|
||||
@@ -37,6 +38,7 @@ public sealed class NvdConnector : IFeedConnector
|
||||
private readonly ILogger<NvdConnector> _logger;
|
||||
private readonly NvdDiagnostics _diagnostics;
|
||||
private readonly ICryptoHash _hash;
|
||||
private readonly ICanonicalAdvisoryService? _canonicalService;
|
||||
|
||||
private static readonly JsonSchema Schema = NvdSchemaProvider.Schema;
|
||||
|
||||
@@ -53,7 +55,8 @@ public sealed class NvdConnector : IFeedConnector
|
||||
NvdDiagnostics diagnostics,
|
||||
ICryptoHash hash,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<NvdConnector> logger)
|
||||
ILogger<NvdConnector> logger,
|
||||
ICanonicalAdvisoryService? canonicalService = null)
|
||||
{
|
||||
_fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService));
|
||||
_rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage));
|
||||
@@ -69,6 +72,7 @@ public sealed class NvdConnector : IFeedConnector
|
||||
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_canonicalService = canonicalService; // Optional - canonical ingest
|
||||
}
|
||||
|
||||
public string SourceName => NvdConnectorPlugin.SourceName;
|
||||
@@ -292,6 +296,13 @@ public sealed class NvdConnector : IFeedConnector
|
||||
{
|
||||
await RecordChangeHistoryAsync(advisory, previous, document, now, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Ingest to canonical advisory service if available
|
||||
if (_canonicalService is not null)
|
||||
{
|
||||
await IngestToCanonicalAsync(advisory, json, document.FetchedAt, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
mappedCount++;
|
||||
}
|
||||
|
||||
@@ -565,4 +576,88 @@ public sealed class NvdConnector : IFeedConnector
|
||||
builder.Query = string.Join("&", parameters.Select(static kvp => $"{System.Net.WebUtility.UrlEncode(kvp.Key)}={System.Net.WebUtility.UrlEncode(kvp.Value)}"));
|
||||
return builder.Uri;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests NVD advisory to canonical advisory service for deduplication.
|
||||
/// Creates one RawAdvisory per affected package.
|
||||
/// </summary>
|
||||
private async Task IngestToCanonicalAsync(
|
||||
Advisory advisory,
|
||||
string rawPayloadJson,
|
||||
DateTimeOffset fetchedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_canonicalService is null || advisory.AffectedPackages.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// NVD advisories are keyed by CVE ID
|
||||
var cve = advisory.AdvisoryKey;
|
||||
|
||||
// Extract CWE weaknesses
|
||||
var weaknesses = advisory.Cwes
|
||||
.Where(w => w.Identifier.StartsWith("CWE-", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(w => w.Identifier)
|
||||
.ToList();
|
||||
|
||||
// Create one RawAdvisory per affected package (CPE)
|
||||
foreach (var affected in advisory.AffectedPackages)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(affected.Identifier))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build version range JSON
|
||||
string? versionRangeJson = null;
|
||||
if (!affected.VersionRanges.IsEmpty)
|
||||
{
|
||||
var firstRange = affected.VersionRanges[0];
|
||||
var rangeObj = new
|
||||
{
|
||||
introduced = firstRange.IntroducedVersion,
|
||||
@fixed = firstRange.FixedVersion,
|
||||
last_affected = firstRange.LastAffectedVersion
|
||||
};
|
||||
versionRangeJson = JsonSerializer.Serialize(rangeObj);
|
||||
}
|
||||
|
||||
var rawAdvisory = new RawAdvisory
|
||||
{
|
||||
SourceAdvisoryId = cve,
|
||||
Cve = cve,
|
||||
AffectsKey = affected.Identifier,
|
||||
VersionRangeJson = versionRangeJson,
|
||||
Weaknesses = weaknesses,
|
||||
PatchLineage = null,
|
||||
Severity = advisory.Severity,
|
||||
Title = advisory.Title,
|
||||
Summary = advisory.Summary,
|
||||
VendorStatus = VendorStatus.Affected,
|
||||
RawPayloadJson = rawPayloadJson,
|
||||
FetchedAt = fetchedAt
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _canonicalService.IngestAsync(SourceName, rawAdvisory, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Canonical ingest for {CveId}/{AffectsKey}: {Decision} (canonical={CanonicalId})",
|
||||
cve, affected.Identifier, result.Decision, result.CanonicalId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to ingest {CveId}/{AffectsKey} to canonical service",
|
||||
cve, affected.Identifier);
|
||||
// Don't fail the mapping operation for canonical ingest failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,7 @@ using StellaOps.Concelier.Connector.Osv.Configuration;
|
||||
using StellaOps.Concelier.Connector.Osv.Internal;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Storage.Advisories;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Plugin;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
@@ -41,6 +40,7 @@ public sealed class OsvConnector : IFeedConnector
|
||||
private readonly IDtoStore _dtoStore;
|
||||
private readonly IAdvisoryStore _advisoryStore;
|
||||
private readonly ISourceStateRepository _stateRepository;
|
||||
private readonly ICanonicalAdvisoryService? _canonicalService;
|
||||
private readonly OsvOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<OsvConnector> _logger;
|
||||
@@ -58,7 +58,8 @@ public sealed class OsvConnector : IFeedConnector
|
||||
OsvDiagnostics diagnostics,
|
||||
ICryptoHash hash,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<OsvConnector> logger)
|
||||
ILogger<OsvConnector> logger,
|
||||
ICanonicalAdvisoryService? canonicalService = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage));
|
||||
@@ -66,6 +67,7 @@ public sealed class OsvConnector : IFeedConnector
|
||||
_dtoStore = dtoStore ?? throw new ArgumentNullException(nameof(dtoStore));
|
||||
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
|
||||
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
|
||||
_canonicalService = canonicalService; // Optional - canonical ingest
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
|
||||
@@ -287,6 +289,12 @@ public sealed class OsvConnector : IFeedConnector
|
||||
await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false);
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ingest to canonical advisory service if available
|
||||
if (_canonicalService is not null)
|
||||
{
|
||||
await IngestToCanonicalAsync(osvDto, advisory, payloadJson, document.FetchedAt, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
pendingMappings.Remove(documentId);
|
||||
}
|
||||
|
||||
@@ -518,4 +526,91 @@ public sealed class OsvConnector : IFeedConnector
|
||||
var safeId = vulnerabilityId.Replace(' ', '-');
|
||||
return $"https://osv-vulnerabilities.storage.googleapis.com/{ecosystem}/{safeId}.json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests OSV advisory to canonical advisory service for deduplication.
|
||||
/// Creates one RawAdvisory per affected package.
|
||||
/// </summary>
|
||||
private async Task IngestToCanonicalAsync(
|
||||
OsvVulnerabilityDto dto,
|
||||
Advisory advisory,
|
||||
string rawPayloadJson,
|
||||
DateTimeOffset fetchedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_canonicalService is null || dto.Affected is null || dto.Affected.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Find primary CVE from aliases
|
||||
var cve = advisory.Aliases
|
||||
.FirstOrDefault(a => a.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
|
||||
?? dto.Id; // Fall back to OSV ID if no CVE
|
||||
|
||||
// Extract CWE weaknesses
|
||||
var weaknesses = advisory.Cwes
|
||||
.Where(w => w.Identifier.StartsWith("CWE-", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(w => w.Identifier)
|
||||
.ToList();
|
||||
|
||||
// Create one RawAdvisory per affected package
|
||||
foreach (var affected in advisory.AffectedPackages)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(affected.Identifier))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build version range JSON
|
||||
string? versionRangeJson = null;
|
||||
if (affected.VersionRanges.Length > 0)
|
||||
{
|
||||
var firstRange = affected.VersionRanges[0];
|
||||
var rangeObj = new
|
||||
{
|
||||
introduced = firstRange.IntroducedVersion,
|
||||
@fixed = firstRange.FixedVersion,
|
||||
last_affected = firstRange.LastAffectedVersion
|
||||
};
|
||||
versionRangeJson = JsonSerializer.Serialize(rangeObj, SerializerOptions);
|
||||
}
|
||||
|
||||
var rawAdvisory = new RawAdvisory
|
||||
{
|
||||
SourceAdvisoryId = dto.Id,
|
||||
Cve = cve,
|
||||
AffectsKey = affected.Identifier,
|
||||
VersionRangeJson = versionRangeJson,
|
||||
Weaknesses = weaknesses,
|
||||
PatchLineage = null, // OSV doesn't have patch lineage
|
||||
Severity = advisory.Severity,
|
||||
Title = advisory.Title,
|
||||
Summary = advisory.Summary,
|
||||
VendorStatus = VendorStatus.Affected,
|
||||
RawPayloadJson = rawPayloadJson,
|
||||
FetchedAt = fetchedAt
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _canonicalService.IngestAsync(SourceName, rawAdvisory, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Canonical ingest for {OsvId}/{AffectsKey}: {Decision} (canonical={CanonicalId})",
|
||||
dto.Id, affected.Identifier, result.Decision, result.CanonicalId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to ingest {OsvId}/{AffectsKey} to canonical service",
|
||||
dto.Id, affected.Identifier);
|
||||
// Don't fail the mapping operation for canonical ingest failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,66 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
|
||||
---
|
||||
|
||||
## Canonical Advisory Service
|
||||
|
||||
### Role
|
||||
Deduplicated canonical advisory management with provenance-scoped source edges. Ingests raw advisories from multiple sources (NVD, GHSA, OSV, vendor, distro), computes merge hashes for deduplication, and maintains canonical records with linked source edges.
|
||||
|
||||
### Scope
|
||||
- **Ingestion**: `IngestAsync` and `IngestBatchAsync` - Raw advisory to canonical pipeline with merge hash computation, duplicate detection, and source edge creation.
|
||||
- **Query**: `GetByIdAsync`, `GetByCveAsync`, `GetByArtifactAsync`, `GetByMergeHashAsync`, `QueryAsync` - Lookup canonical advisories with source edges.
|
||||
- **Status**: `UpdateStatusAsync`, `DegradeToStubsAsync` - Lifecycle management (Active, Stub, Withdrawn).
|
||||
- **Caching**: `CachingCanonicalAdvisoryService` decorator with configurable TTLs for hot queries.
|
||||
- **Signing**: Optional DSSE signing of source edges via `ISourceEdgeSigner` integration.
|
||||
|
||||
### Interfaces & Contracts
|
||||
- **ICanonicalAdvisoryService**: Main service interface for ingest and query operations.
|
||||
- **ICanonicalAdvisoryStore**: Storage abstraction for canonical/source edge persistence.
|
||||
- **IMergeHashCalculator**: Merge hash computation (CVE + PURL + version range + CWE + patch lineage).
|
||||
- **ISourceEdgeSigner**: Optional DSSE envelope signing for source edges.
|
||||
|
||||
### Domain Models
|
||||
- **CanonicalAdvisory**: Deduplicated advisory record with merge hash, status, severity, EPSS, weaknesses.
|
||||
- **SourceEdge**: Link from source advisory to canonical with precedence rank, doc hash, DSSE envelope.
|
||||
- **IngestResult**: Outcome with MergeDecision (Created, Merged, Duplicate, Conflict).
|
||||
- **RawAdvisory**: Input from connectors with CVE, affects key, version range, weaknesses.
|
||||
|
||||
### Source Precedence
|
||||
Lower rank = higher priority for metadata updates:
|
||||
- `vendor` = 10 (authoritative)
|
||||
- `redhat/debian/suse/ubuntu/alpine` = 20 (distro)
|
||||
- `osv` = 30
|
||||
- `ghsa` = 35
|
||||
- `nvd` = 40 (fallback)
|
||||
|
||||
### API Endpoints
|
||||
- `GET /api/v1/canonical/{id}` - Get by ID
|
||||
- `GET /api/v1/canonical?cve={cve}&artifact={purl}&mergeHash={hash}` - Query
|
||||
- `POST /api/v1/canonical/ingest/{source}` - Ingest single advisory
|
||||
- `POST /api/v1/canonical/ingest/{source}/batch` - Batch ingest
|
||||
- `PATCH /api/v1/canonical/{id}/status` - Update status
|
||||
|
||||
### In/Out of Scope
|
||||
**In**: Merge hash computation, canonical upsert, source edge linking, duplicate detection, caching, DSSE signing.
|
||||
**Out**: Raw advisory fetching (connectors), database schema (Storage.Postgres), HTTP routing (WebService).
|
||||
|
||||
### Observability
|
||||
- Logs: canonical ID, merge hash, decision, source, precedence rank, signing status.
|
||||
- Cache: hit/miss tracing at Trace level.
|
||||
|
||||
### Tests
|
||||
- Unit tests in `Core.Tests/Canonical/` covering ingest pipeline, caching, signing.
|
||||
- Integration tests in `WebService.Tests/Canonical/` for API endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Job Orchestration
|
||||
|
||||
### Role
|
||||
Job orchestration and lifecycle. Registers job definitions, schedules execution, triggers runs, reports status for connectors and exporters.
|
||||
## Scope
|
||||
|
||||
### Scope
|
||||
- Contracts: IJob (execute with CancellationToken), JobRunStatus, JobTriggerOutcome/Result.
|
||||
- Registration: JobSchedulerBuilder.AddJob<T>(kind, cronExpression?, timeout?, leaseDuration?); options recorded in JobSchedulerOptions.
|
||||
- Plugin host integration discovers IJob providers via registered IDependencyInjectionRoutine implementations.
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CachingCanonicalAdvisoryService.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Task: CANSVC-8200-014
|
||||
// Description: Caching decorator for canonical advisory service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Canonical;
|
||||
|
||||
/// <summary>
|
||||
/// Caching decorator for canonical advisory service.
|
||||
/// Caches hot queries (by ID, merge hash, CVE) with short TTL.
|
||||
/// </summary>
|
||||
public sealed class CachingCanonicalAdvisoryService : ICanonicalAdvisoryService
|
||||
{
|
||||
private readonly ICanonicalAdvisoryService _inner;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<CachingCanonicalAdvisoryService> _logger;
|
||||
private readonly CanonicalCacheOptions _options;
|
||||
|
||||
private const string CacheKeyPrefix = "canonical:";
|
||||
|
||||
public CachingCanonicalAdvisoryService(
|
||||
ICanonicalAdvisoryService inner,
|
||||
IMemoryCache cache,
|
||||
IOptions<CanonicalCacheOptions> options,
|
||||
ILogger<CachingCanonicalAdvisoryService> logger)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new CanonicalCacheOptions();
|
||||
}
|
||||
|
||||
#region Ingest Operations (Pass-through with cache invalidation)
|
||||
|
||||
public async Task<IngestResult> IngestAsync(
|
||||
string source,
|
||||
RawAdvisory rawAdvisory,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var result = await _inner.IngestAsync(source, rawAdvisory, ct).ConfigureAwait(false);
|
||||
|
||||
// Invalidate cache for affected entries
|
||||
if (result.Decision != MergeDecision.Duplicate)
|
||||
{
|
||||
InvalidateCacheForCanonical(result.CanonicalId, result.MergeHash, rawAdvisory.Cve);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<IngestResult>> IngestBatchAsync(
|
||||
string source,
|
||||
IEnumerable<RawAdvisory> advisories,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = await _inner.IngestBatchAsync(source, advisories, ct).ConfigureAwait(false);
|
||||
|
||||
// Invalidate cache for all affected entries
|
||||
foreach (var result in results.Where(r => r.Decision != MergeDecision.Duplicate))
|
||||
{
|
||||
InvalidateCacheForCanonical(result.CanonicalId, result.MergeHash, null);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query Operations (Cached)
|
||||
|
||||
public async Task<CanonicalAdvisory?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}id:{id}";
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out CanonicalAdvisory? cached))
|
||||
{
|
||||
_logger.LogTrace("Cache hit for canonical {CanonicalId}", id);
|
||||
return cached;
|
||||
}
|
||||
|
||||
var result = await _inner.GetByIdAsync(id, ct).ConfigureAwait(false);
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
SetCache(cacheKey, result, _options.DefaultTtl);
|
||||
// Also cache by merge hash for cross-lookup
|
||||
SetCache($"{CacheKeyPrefix}hash:{result.MergeHash}", result, _options.DefaultTtl);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<CanonicalAdvisory?> GetByMergeHashAsync(string mergeHash, CancellationToken ct = default)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}hash:{mergeHash}";
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out CanonicalAdvisory? cached))
|
||||
{
|
||||
_logger.LogTrace("Cache hit for merge hash {MergeHash}", mergeHash);
|
||||
return cached;
|
||||
}
|
||||
|
||||
var result = await _inner.GetByMergeHashAsync(mergeHash, ct).ConfigureAwait(false);
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
SetCache(cacheKey, result, _options.DefaultTtl);
|
||||
// Also cache by ID for cross-lookup
|
||||
SetCache($"{CacheKeyPrefix}id:{result.Id}", result, _options.DefaultTtl);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CanonicalAdvisory>> GetByCveAsync(string cve, CancellationToken ct = default)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}cve:{cve.ToUpperInvariant()}";
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out IReadOnlyList<CanonicalAdvisory>? cached) && cached is not null)
|
||||
{
|
||||
_logger.LogTrace("Cache hit for CVE {Cve} ({Count} items)", cve, cached.Count);
|
||||
return cached;
|
||||
}
|
||||
|
||||
var result = await _inner.GetByCveAsync(cve, ct).ConfigureAwait(false);
|
||||
|
||||
if (result.Count > 0)
|
||||
{
|
||||
SetCache(cacheKey, result, _options.CveTtl);
|
||||
|
||||
// Also cache individual items
|
||||
foreach (var item in result)
|
||||
{
|
||||
SetCache($"{CacheKeyPrefix}id:{item.Id}", item, _options.DefaultTtl);
|
||||
SetCache($"{CacheKeyPrefix}hash:{item.MergeHash}", item, _options.DefaultTtl);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CanonicalAdvisory>> GetByArtifactAsync(
|
||||
string artifactKey,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}artifact:{artifactKey.ToLowerInvariant()}";
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out IReadOnlyList<CanonicalAdvisory>? cached) && cached is not null)
|
||||
{
|
||||
_logger.LogTrace("Cache hit for artifact {ArtifactKey} ({Count} items)", artifactKey, cached.Count);
|
||||
return cached;
|
||||
}
|
||||
|
||||
var result = await _inner.GetByArtifactAsync(artifactKey, ct).ConfigureAwait(false);
|
||||
|
||||
if (result.Count > 0)
|
||||
{
|
||||
SetCache(cacheKey, result, _options.ArtifactTtl);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<PagedResult<CanonicalAdvisory>> QueryAsync(
|
||||
CanonicalQueryOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Don't cache complex queries - pass through
|
||||
return _inner.QueryAsync(options, ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Status Operations (Pass-through with cache invalidation)
|
||||
|
||||
public async Task UpdateStatusAsync(Guid id, CanonicalStatus status, CancellationToken ct = default)
|
||||
{
|
||||
await _inner.UpdateStatusAsync(id, status, ct).ConfigureAwait(false);
|
||||
|
||||
// Invalidate cache for this canonical
|
||||
InvalidateCacheById(id);
|
||||
}
|
||||
|
||||
public Task<int> DegradeToStubsAsync(double scoreThreshold, CancellationToken ct = default)
|
||||
{
|
||||
// This may affect many entries - don't try to invalidate individually
|
||||
// The cache will naturally expire
|
||||
return _inner.DegradeToStubsAsync(scoreThreshold, ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helpers
|
||||
|
||||
private void SetCache<T>(string key, T value, TimeSpan ttl) where T : class
|
||||
{
|
||||
if (ttl <= TimeSpan.Zero || !_options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = ttl,
|
||||
Size = 1 // For size-limited caches
|
||||
};
|
||||
|
||||
_cache.Set(key, value, options);
|
||||
}
|
||||
|
||||
private void InvalidateCacheForCanonical(Guid id, string? mergeHash, string? cve)
|
||||
{
|
||||
InvalidateCacheById(id);
|
||||
|
||||
if (!string.IsNullOrEmpty(mergeHash))
|
||||
{
|
||||
_cache.Remove($"{CacheKeyPrefix}hash:{mergeHash}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(cve))
|
||||
{
|
||||
_cache.Remove($"{CacheKeyPrefix}cve:{cve.ToUpperInvariant()}");
|
||||
}
|
||||
}
|
||||
|
||||
private void InvalidateCacheById(Guid id)
|
||||
{
|
||||
_cache.Remove($"{CacheKeyPrefix}id:{id}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for canonical advisory caching.
|
||||
/// </summary>
|
||||
public sealed class CanonicalCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether caching is enabled. Default: true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default TTL for individual canonical lookups. Default: 5 minutes.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTtl { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// TTL for CVE-based queries. Default: 2 minutes.
|
||||
/// </summary>
|
||||
public TimeSpan CveTtl { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>
|
||||
/// TTL for artifact-based queries. Default: 2 minutes.
|
||||
/// </summary>
|
||||
public TimeSpan ArtifactTtl { get; set; } = TimeSpan.FromMinutes(2);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CanonicalAdvisory.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Task: CANSVC-8200-001
|
||||
// Description: Domain model for canonical advisory with source edges
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Core.Canonical;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical advisory with all source edges.
|
||||
/// </summary>
|
||||
public sealed record CanonicalAdvisory
|
||||
{
|
||||
/// <summary>Unique canonical advisory identifier.</summary>
|
||||
public Guid Id { get; init; }
|
||||
|
||||
/// <summary>CVE identifier (e.g., "CVE-2024-1234").</summary>
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>Normalized PURL or CPE identifying the affected package.</summary>
|
||||
public required string AffectsKey { get; init; }
|
||||
|
||||
/// <summary>Structured version range (introduced, fixed, last_affected).</summary>
|
||||
public VersionRange? VersionRange { get; init; }
|
||||
|
||||
/// <summary>Sorted CWE identifiers.</summary>
|
||||
public IReadOnlyList<string> Weaknesses { get; init; } = [];
|
||||
|
||||
/// <summary>Deterministic SHA256 hash of identity components.</summary>
|
||||
public required string MergeHash { get; init; }
|
||||
|
||||
/// <summary>Status: active, stub, or withdrawn.</summary>
|
||||
public CanonicalStatus Status { get; init; } = CanonicalStatus.Active;
|
||||
|
||||
/// <summary>Normalized severity: critical, high, medium, low, none.</summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>EPSS exploit prediction probability (0.0000-1.0000).</summary>
|
||||
public decimal? EpssScore { get; init; }
|
||||
|
||||
/// <summary>Whether an exploit is known to exist.</summary>
|
||||
public bool ExploitKnown { get; init; }
|
||||
|
||||
/// <summary>Advisory title.</summary>
|
||||
public string? Title { get; init; }
|
||||
|
||||
/// <summary>Advisory summary.</summary>
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>When the canonical record was created.</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>When the canonical record was last updated.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>All source edges for this canonical, ordered by precedence.</summary>
|
||||
public IReadOnlyList<SourceEdge> SourceEdges { get; init; } = [];
|
||||
|
||||
/// <summary>Primary source edge (highest precedence).</summary>
|
||||
public SourceEdge? PrimarySource => SourceEdges.Count > 0 ? SourceEdges[0] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a canonical advisory.
|
||||
/// </summary>
|
||||
public enum CanonicalStatus
|
||||
{
|
||||
/// <summary>Full active record with all data.</summary>
|
||||
Active,
|
||||
|
||||
/// <summary>Minimal record for low-interest advisories.</summary>
|
||||
Stub,
|
||||
|
||||
/// <summary>Withdrawn or superseded advisory.</summary>
|
||||
Withdrawn
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured version range for affected packages.
|
||||
/// </summary>
|
||||
public sealed record VersionRange
|
||||
{
|
||||
/// <summary>Version where vulnerability was introduced.</summary>
|
||||
public string? Introduced { get; init; }
|
||||
|
||||
/// <summary>Version where vulnerability was fixed.</summary>
|
||||
public string? Fixed { get; init; }
|
||||
|
||||
/// <summary>Last known affected version.</summary>
|
||||
public string? LastAffected { get; init; }
|
||||
|
||||
/// <summary>Canonical range expression (e.g., ">=1.0.0,<2.0.0").</summary>
|
||||
public string? RangeExpression { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CanonicalAdvisoryService.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Tasks: CANSVC-8200-004 through CANSVC-8200-008
|
||||
// Description: Service implementation for canonical advisory management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Canonical;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing canonical advisories with provenance-scoped deduplication.
|
||||
/// </summary>
|
||||
public sealed class CanonicalAdvisoryService : ICanonicalAdvisoryService
|
||||
{
|
||||
private readonly ICanonicalAdvisoryStore _store;
|
||||
private readonly IMergeHashCalculator _mergeHashCalculator;
|
||||
private readonly ISourceEdgeSigner? _signer;
|
||||
private readonly ILogger<CanonicalAdvisoryService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Source precedence ranks (lower = higher priority).
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> SourcePrecedence = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["vendor"] = 10,
|
||||
["redhat"] = 20,
|
||||
["debian"] = 20,
|
||||
["suse"] = 20,
|
||||
["ubuntu"] = 20,
|
||||
["alpine"] = 20,
|
||||
["osv"] = 30,
|
||||
["ghsa"] = 35,
|
||||
["nvd"] = 40
|
||||
};
|
||||
|
||||
public CanonicalAdvisoryService(
|
||||
ICanonicalAdvisoryStore store,
|
||||
IMergeHashCalculator mergeHashCalculator,
|
||||
ILogger<CanonicalAdvisoryService> logger,
|
||||
ISourceEdgeSigner? signer = null)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_mergeHashCalculator = mergeHashCalculator ?? throw new ArgumentNullException(nameof(mergeHashCalculator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_signer = signer; // Optional - if not provided, source edges are stored unsigned
|
||||
}
|
||||
|
||||
#region Ingest Operations
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IngestResult> IngestAsync(
|
||||
string source,
|
||||
RawAdvisory rawAdvisory,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(source);
|
||||
ArgumentNullException.ThrowIfNull(rawAdvisory);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Ingesting advisory {SourceAdvisoryId} from {Source}",
|
||||
rawAdvisory.SourceAdvisoryId, source);
|
||||
|
||||
// 1. Compute merge hash from identity components
|
||||
var mergeHashInput = new MergeHashInput
|
||||
{
|
||||
Cve = rawAdvisory.Cve,
|
||||
AffectsKey = rawAdvisory.AffectsKey,
|
||||
VersionRange = rawAdvisory.VersionRangeJson,
|
||||
Weaknesses = rawAdvisory.Weaknesses,
|
||||
PatchLineage = rawAdvisory.PatchLineage
|
||||
};
|
||||
var mergeHash = _mergeHashCalculator.ComputeMergeHash(mergeHashInput);
|
||||
|
||||
// 2. Check for existing canonical
|
||||
var existing = await _store.GetByMergeHashAsync(mergeHash, ct).ConfigureAwait(false);
|
||||
|
||||
MergeDecision decision;
|
||||
Guid canonicalId;
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
// 3a. Create new canonical
|
||||
var upsertRequest = new UpsertCanonicalRequest
|
||||
{
|
||||
Cve = rawAdvisory.Cve,
|
||||
AffectsKey = rawAdvisory.AffectsKey,
|
||||
MergeHash = mergeHash,
|
||||
VersionRangeJson = rawAdvisory.VersionRangeJson,
|
||||
Weaknesses = rawAdvisory.Weaknesses,
|
||||
Severity = rawAdvisory.Severity,
|
||||
Title = rawAdvisory.Title,
|
||||
Summary = rawAdvisory.Summary
|
||||
};
|
||||
|
||||
canonicalId = await _store.UpsertCanonicalAsync(upsertRequest, ct).ConfigureAwait(false);
|
||||
decision = MergeDecision.Created;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created canonical {CanonicalId} with merge_hash {MergeHash} for {Cve}",
|
||||
canonicalId, mergeHash, rawAdvisory.Cve);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 3b. Merge into existing canonical
|
||||
canonicalId = existing.Id;
|
||||
decision = MergeDecision.Merged;
|
||||
|
||||
// Update metadata if we have better data
|
||||
await UpdateCanonicalMetadataIfBetterAsync(existing, rawAdvisory, source, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Merging into existing canonical {CanonicalId} for {Cve}",
|
||||
canonicalId, rawAdvisory.Cve);
|
||||
}
|
||||
|
||||
// 4. Compute source document hash
|
||||
var sourceDocHash = ComputeDocumentHash(rawAdvisory);
|
||||
|
||||
// 5. Resolve source ID
|
||||
var sourceId = await _store.ResolveSourceIdAsync(source, ct).ConfigureAwait(false);
|
||||
|
||||
// 6. Check if source edge already exists (duplicate detection)
|
||||
var edgeExists = await _store.SourceEdgeExistsAsync(canonicalId, sourceId, sourceDocHash, ct).ConfigureAwait(false);
|
||||
if (edgeExists)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Duplicate source edge detected for canonical {CanonicalId} from {Source}",
|
||||
canonicalId, source);
|
||||
|
||||
return IngestResult.Duplicate(canonicalId, mergeHash, source, rawAdvisory.SourceAdvisoryId);
|
||||
}
|
||||
|
||||
// 7. Sign source edge if signer is available
|
||||
string? dsseEnvelopeJson = null;
|
||||
Guid? signatureRef = null;
|
||||
|
||||
if (_signer is not null && rawAdvisory.RawPayloadJson is not null)
|
||||
{
|
||||
var signingRequest = new SourceEdgeSigningRequest
|
||||
{
|
||||
SourceAdvisoryId = rawAdvisory.SourceAdvisoryId,
|
||||
SourceName = source,
|
||||
PayloadHash = sourceDocHash,
|
||||
PayloadJson = rawAdvisory.RawPayloadJson
|
||||
};
|
||||
|
||||
var signingResult = await _signer.SignAsync(signingRequest, ct).ConfigureAwait(false);
|
||||
|
||||
if (signingResult.Success && signingResult.Envelope is not null)
|
||||
{
|
||||
dsseEnvelopeJson = JsonSerializer.Serialize(signingResult.Envelope);
|
||||
signatureRef = signingResult.SignatureRef;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Signed source edge for {SourceAdvisoryId} from {Source} (ref: {SignatureRef})",
|
||||
rawAdvisory.SourceAdvisoryId, source, signatureRef);
|
||||
}
|
||||
else if (!signingResult.Success)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to sign source edge for {SourceAdvisoryId}: {Error}",
|
||||
rawAdvisory.SourceAdvisoryId, signingResult.ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Create source edge
|
||||
var precedenceRank = GetPrecedenceRank(source);
|
||||
var addEdgeRequest = new AddSourceEdgeRequest
|
||||
{
|
||||
CanonicalId = canonicalId,
|
||||
SourceId = sourceId,
|
||||
SourceAdvisoryId = rawAdvisory.SourceAdvisoryId,
|
||||
SourceDocHash = sourceDocHash,
|
||||
VendorStatus = rawAdvisory.VendorStatus,
|
||||
PrecedenceRank = precedenceRank,
|
||||
DsseEnvelopeJson = dsseEnvelopeJson,
|
||||
RawPayloadJson = rawAdvisory.RawPayloadJson,
|
||||
FetchedAt = rawAdvisory.FetchedAt
|
||||
};
|
||||
|
||||
var edgeResult = await _store.AddSourceEdgeAsync(addEdgeRequest, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Added source edge {EdgeId} from {Source} ({SourceAdvisoryId}) to canonical {CanonicalId}{Signed}",
|
||||
edgeResult.EdgeId, source, rawAdvisory.SourceAdvisoryId, canonicalId,
|
||||
dsseEnvelopeJson is not null ? " [signed]" : "");
|
||||
|
||||
return decision == MergeDecision.Created
|
||||
? IngestResult.Created(canonicalId, mergeHash, edgeResult.EdgeId, source, rawAdvisory.SourceAdvisoryId, signatureRef)
|
||||
: IngestResult.Merged(canonicalId, mergeHash, edgeResult.EdgeId, source, rawAdvisory.SourceAdvisoryId, signatureRef);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<IngestResult>> IngestBatchAsync(
|
||||
string source,
|
||||
IEnumerable<RawAdvisory> advisories,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(source);
|
||||
ArgumentNullException.ThrowIfNull(advisories);
|
||||
|
||||
var results = new List<IngestResult>();
|
||||
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await IngestAsync(source, advisory, ct).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to ingest advisory {SourceAdvisoryId} from {Source}",
|
||||
advisory.SourceAdvisoryId, source);
|
||||
|
||||
// Create a conflict result for failed ingestion
|
||||
results.Add(IngestResult.Conflict(
|
||||
Guid.Empty,
|
||||
string.Empty,
|
||||
ex.Message,
|
||||
source,
|
||||
advisory.SourceAdvisoryId));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Batch ingest complete: {Created} created, {Merged} merged, {Duplicates} duplicates, {Conflicts} conflicts",
|
||||
results.Count(r => r.Decision == MergeDecision.Created),
|
||||
results.Count(r => r.Decision == MergeDecision.Merged),
|
||||
results.Count(r => r.Decision == MergeDecision.Duplicate),
|
||||
results.Count(r => r.Decision == MergeDecision.Conflict));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query Operations
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CanonicalAdvisory?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
=> _store.GetByIdAsync(id, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CanonicalAdvisory?> GetByMergeHashAsync(string mergeHash, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(mergeHash);
|
||||
return _store.GetByMergeHashAsync(mergeHash, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<CanonicalAdvisory>> GetByCveAsync(string cve, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cve);
|
||||
return _store.GetByCveAsync(cve, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<CanonicalAdvisory>> GetByArtifactAsync(string artifactKey, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactKey);
|
||||
return _store.GetByArtifactAsync(artifactKey, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PagedResult<CanonicalAdvisory>> QueryAsync(CanonicalQueryOptions options, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return _store.QueryAsync(options, ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Status Operations
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateStatusAsync(Guid id, CanonicalStatus status, CancellationToken ct = default)
|
||||
{
|
||||
await _store.UpdateStatusAsync(id, status, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated canonical {CanonicalId} status to {Status}",
|
||||
id, status);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> DegradeToStubsAsync(double scoreThreshold, CancellationToken ct = default)
|
||||
{
|
||||
// TODO: Implement stub degradation based on EPSS score or other criteria
|
||||
// This would query for low-interest canonicals and update their status to Stub
|
||||
_logger.LogWarning(
|
||||
"DegradeToStubsAsync not yet implemented (threshold={Threshold})",
|
||||
scoreThreshold);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helpers
|
||||
|
||||
private async Task UpdateCanonicalMetadataIfBetterAsync(
|
||||
CanonicalAdvisory existing,
|
||||
RawAdvisory newAdvisory,
|
||||
string source,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Only update if the new source has higher precedence
|
||||
var newPrecedence = GetPrecedenceRank(source);
|
||||
var existingPrecedence = existing.PrimarySource?.PrecedenceRank ?? int.MaxValue;
|
||||
|
||||
if (newPrecedence >= existingPrecedence)
|
||||
{
|
||||
return; // New source is lower or equal precedence, don't update
|
||||
}
|
||||
|
||||
// Update with better metadata
|
||||
var updateRequest = new UpsertCanonicalRequest
|
||||
{
|
||||
Cve = existing.Cve,
|
||||
AffectsKey = existing.AffectsKey,
|
||||
MergeHash = existing.MergeHash,
|
||||
Severity = newAdvisory.Severity ?? existing.Severity,
|
||||
Title = newAdvisory.Title ?? existing.Title,
|
||||
Summary = newAdvisory.Summary ?? existing.Summary
|
||||
};
|
||||
|
||||
await _store.UpsertCanonicalAsync(updateRequest, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Updated canonical {CanonicalId} metadata from higher-precedence source {Source}",
|
||||
existing.Id, source);
|
||||
}
|
||||
|
||||
private static string ComputeDocumentHash(RawAdvisory advisory)
|
||||
{
|
||||
// Hash the raw payload if available, otherwise hash the key identity fields
|
||||
var content = advisory.RawPayloadJson
|
||||
?? JsonSerializer.Serialize(new
|
||||
{
|
||||
advisory.SourceAdvisoryId,
|
||||
advisory.Cve,
|
||||
advisory.AffectsKey,
|
||||
advisory.VersionRangeJson,
|
||||
advisory.Weaknesses,
|
||||
advisory.Title,
|
||||
advisory.Summary
|
||||
});
|
||||
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
|
||||
}
|
||||
|
||||
private static int GetPrecedenceRank(string source)
|
||||
{
|
||||
if (SourcePrecedence.TryGetValue(source, out var rank))
|
||||
{
|
||||
return rank;
|
||||
}
|
||||
|
||||
// Unknown sources get default precedence
|
||||
return 100;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ICanonicalAdvisoryService.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Task: CANSVC-8200-000
|
||||
// Description: Service interface for canonical advisory management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Canonical;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing canonical advisories with provenance-scoped deduplication.
|
||||
/// </summary>
|
||||
public interface ICanonicalAdvisoryService
|
||||
{
|
||||
// === Ingest Operations ===
|
||||
|
||||
/// <summary>
|
||||
/// Ingest raw advisory from source, creating or updating canonical record.
|
||||
/// </summary>
|
||||
/// <param name="source">Source identifier (osv, nvd, ghsa, redhat, debian, etc.)</param>
|
||||
/// <param name="rawAdvisory">Raw advisory document</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Ingest result with canonical ID and merge decision</returns>
|
||||
Task<IngestResult> IngestAsync(
|
||||
string source,
|
||||
RawAdvisory rawAdvisory,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch ingest multiple advisories from same source.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<IngestResult>> IngestBatchAsync(
|
||||
string source,
|
||||
IEnumerable<RawAdvisory> advisories,
|
||||
CancellationToken ct = default);
|
||||
|
||||
// === Query Operations ===
|
||||
|
||||
/// <summary>
|
||||
/// Get canonical advisory by ID with all source edges.
|
||||
/// </summary>
|
||||
Task<CanonicalAdvisory?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get canonical advisory by merge hash.
|
||||
/// </summary>
|
||||
Task<CanonicalAdvisory?> GetByMergeHashAsync(string mergeHash, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all canonical advisories for a CVE.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<CanonicalAdvisory>> GetByCveAsync(string cve, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get canonical advisories affecting an artifact (PURL or CPE).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<CanonicalAdvisory>> GetByArtifactAsync(
|
||||
string artifactKey,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Query canonical advisories with filters.
|
||||
/// </summary>
|
||||
Task<PagedResult<CanonicalAdvisory>> QueryAsync(
|
||||
CanonicalQueryOptions options,
|
||||
CancellationToken ct = default);
|
||||
|
||||
// === Status Operations ===
|
||||
|
||||
/// <summary>
|
||||
/// Update canonical status (active, stub, withdrawn).
|
||||
/// </summary>
|
||||
Task UpdateStatusAsync(Guid id, CanonicalStatus status, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Degrade low-interest canonicals to stub status.
|
||||
/// </summary>
|
||||
Task<int> DegradeToStubsAsync(double scoreThreshold, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raw advisory document before normalization.
|
||||
/// </summary>
|
||||
public sealed record RawAdvisory
|
||||
{
|
||||
/// <summary>Source advisory ID (DSA-5678, RHSA-2024:1234, etc.)</summary>
|
||||
public required string SourceAdvisoryId { get; init; }
|
||||
|
||||
/// <summary>Primary CVE identifier.</summary>
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>Affected package identifier (PURL or CPE).</summary>
|
||||
public required string AffectsKey { get; init; }
|
||||
|
||||
/// <summary>Affected version range as JSON string.</summary>
|
||||
public string? VersionRangeJson { get; init; }
|
||||
|
||||
/// <summary>CWE identifiers.</summary>
|
||||
public IReadOnlyList<string> Weaknesses { get; init; } = [];
|
||||
|
||||
/// <summary>Patch lineage (commit SHA, patch ID).</summary>
|
||||
public string? PatchLineage { get; init; }
|
||||
|
||||
/// <summary>Advisory title.</summary>
|
||||
public string? Title { get; init; }
|
||||
|
||||
/// <summary>Advisory summary.</summary>
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>Severity level.</summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>VEX-style vendor status.</summary>
|
||||
public VendorStatus? VendorStatus { get; init; }
|
||||
|
||||
/// <summary>Raw payload as JSON.</summary>
|
||||
public string? RawPayloadJson { get; init; }
|
||||
|
||||
/// <summary>When the advisory was fetched.</summary>
|
||||
public DateTimeOffset FetchedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query options for canonical advisories.
|
||||
/// </summary>
|
||||
public sealed record CanonicalQueryOptions
|
||||
{
|
||||
/// <summary>Filter by CVE (exact match).</summary>
|
||||
public string? Cve { get; init; }
|
||||
|
||||
/// <summary>Filter by artifact key (PURL or CPE).</summary>
|
||||
public string? ArtifactKey { get; init; }
|
||||
|
||||
/// <summary>Filter by severity.</summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>Filter by status.</summary>
|
||||
public CanonicalStatus? Status { get; init; }
|
||||
|
||||
/// <summary>Only include canonicals with known exploits.</summary>
|
||||
public bool? ExploitKnown { get; init; }
|
||||
|
||||
/// <summary>Include canonicals updated since this time.</summary>
|
||||
public DateTimeOffset? UpdatedSince { get; init; }
|
||||
|
||||
/// <summary>Page size.</summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>Page offset.</summary>
|
||||
public int Offset { get; init; } = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paged result for queries.
|
||||
/// </summary>
|
||||
public sealed record PagedResult<T>
|
||||
{
|
||||
/// <summary>Items in this page.</summary>
|
||||
public required IReadOnlyList<T> Items { get; init; }
|
||||
|
||||
/// <summary>Total count across all pages.</summary>
|
||||
public long TotalCount { get; init; }
|
||||
|
||||
/// <summary>Current page offset.</summary>
|
||||
public int Offset { get; init; }
|
||||
|
||||
/// <summary>Page size.</summary>
|
||||
public int Limit { get; init; }
|
||||
|
||||
/// <summary>Whether there are more items.</summary>
|
||||
public bool HasMore => Offset + Items.Count < TotalCount;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ICanonicalAdvisoryStore.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Task: CANSVC-8200-004
|
||||
// Description: Storage abstraction for canonical advisory persistence
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Core.Canonical;
|
||||
|
||||
/// <summary>
|
||||
/// Storage abstraction for canonical advisory and source edge persistence.
|
||||
/// Implemented by PostgresCanonicalAdvisoryStore.
|
||||
/// </summary>
|
||||
public interface ICanonicalAdvisoryStore
|
||||
{
|
||||
#region Canonical Advisory Operations
|
||||
|
||||
/// <summary>
|
||||
/// Gets a canonical advisory by ID with source edges.
|
||||
/// </summary>
|
||||
Task<CanonicalAdvisory?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a canonical advisory by merge hash.
|
||||
/// </summary>
|
||||
Task<CanonicalAdvisory?> GetByMergeHashAsync(string mergeHash, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all canonical advisories for a CVE.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<CanonicalAdvisory>> GetByCveAsync(string cve, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets canonical advisories affecting an artifact (PURL or CPE).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<CanonicalAdvisory>> GetByArtifactAsync(string artifactKey, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries canonical advisories with filters.
|
||||
/// </summary>
|
||||
Task<PagedResult<CanonicalAdvisory>> QueryAsync(CanonicalQueryOptions options, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Upserts a canonical advisory (creates or updates by merge_hash).
|
||||
/// </summary>
|
||||
Task<Guid> UpsertCanonicalAsync(UpsertCanonicalRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status of a canonical advisory.
|
||||
/// </summary>
|
||||
Task UpdateStatusAsync(Guid id, CanonicalStatus status, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts active canonicals.
|
||||
/// </summary>
|
||||
Task<long> CountAsync(CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Source Edge Operations
|
||||
|
||||
/// <summary>
|
||||
/// Adds a source edge to a canonical advisory.
|
||||
/// Returns existing edge ID if duplicate (canonical_id, source_id, doc_hash).
|
||||
/// </summary>
|
||||
Task<SourceEdgeResult> AddSourceEdgeAsync(AddSourceEdgeRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all source edges for a canonical.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SourceEdge>> GetSourceEdgesAsync(Guid canonicalId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a source edge already exists.
|
||||
/// </summary>
|
||||
Task<bool> SourceEdgeExistsAsync(Guid canonicalId, Guid sourceId, string docHash, CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Source Operations
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a source key to its ID, creating if necessary.
|
||||
/// </summary>
|
||||
Task<Guid> ResolveSourceIdAsync(string sourceKey, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the precedence rank for a source.
|
||||
/// </summary>
|
||||
Task<int> GetSourcePrecedenceAsync(string sourceKey, CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to upsert a canonical advisory.
|
||||
/// </summary>
|
||||
public sealed record UpsertCanonicalRequest
|
||||
{
|
||||
public required string Cve { get; init; }
|
||||
public required string AffectsKey { get; init; }
|
||||
public required string MergeHash { get; init; }
|
||||
public string? VersionRangeJson { get; init; }
|
||||
public IReadOnlyList<string> Weaknesses { get; init; } = [];
|
||||
public string? Severity { get; init; }
|
||||
public decimal? EpssScore { get; init; }
|
||||
public bool ExploitKnown { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to add a source edge.
|
||||
/// </summary>
|
||||
public sealed record AddSourceEdgeRequest
|
||||
{
|
||||
public required Guid CanonicalId { get; init; }
|
||||
public required Guid SourceId { get; init; }
|
||||
public required string SourceAdvisoryId { get; init; }
|
||||
public required string SourceDocHash { get; init; }
|
||||
public VendorStatus? VendorStatus { get; init; }
|
||||
public int PrecedenceRank { get; init; } = 100;
|
||||
public string? DsseEnvelopeJson { get; init; }
|
||||
public string? RawPayloadJson { get; init; }
|
||||
public DateTimeOffset FetchedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of adding a source edge.
|
||||
/// </summary>
|
||||
public sealed record SourceEdgeResult
|
||||
{
|
||||
public required Guid EdgeId { get; init; }
|
||||
public required bool WasCreated { get; init; }
|
||||
|
||||
public static SourceEdgeResult Created(Guid edgeId) => new() { EdgeId = edgeId, WasCreated = true };
|
||||
public static SourceEdgeResult Existing(Guid edgeId) => new() { EdgeId = edgeId, WasCreated = false };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IMergeHashCalculator.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Task: CANSVC-8200-004
|
||||
// Description: Merge hash calculator abstraction for Core (avoids circular ref)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Core.Canonical;
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic semantic merge hash for advisory deduplication.
|
||||
/// This is a local abstraction in Core to avoid circular dependency with Merge library.
|
||||
/// The Merge library's MergeHashCalculator implements this interface.
|
||||
/// </summary>
|
||||
public interface IMergeHashCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute merge hash from advisory identity components.
|
||||
/// </summary>
|
||||
/// <param name="input">The identity components to hash.</param>
|
||||
/// <returns>Hex-encoded SHA256 hash prefixed with "sha256:".</returns>
|
||||
string ComputeMergeHash(MergeHashInput input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input components for merge hash computation.
|
||||
/// </summary>
|
||||
public sealed record MergeHashInput
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier (e.g., "CVE-2024-1234"). Required.
|
||||
/// </summary>
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected package identifier (PURL or CPE). Required.
|
||||
/// </summary>
|
||||
public required string AffectsKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected version range expression. Optional.
|
||||
/// </summary>
|
||||
public string? VersionRange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated CWE identifiers. Optional.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Weaknesses { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Upstream patch provenance (commit SHA, patch ID). Optional.
|
||||
/// </summary>
|
||||
public string? PatchLineage { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISourceEdgeSigner.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Task: CANSVC-8200-008
|
||||
// Description: Interface for DSSE signing of source edges
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Core.Canonical;
|
||||
|
||||
/// <summary>
|
||||
/// Service for signing source edges with DSSE envelopes.
|
||||
/// This is an optional component - if not registered, source edges are stored unsigned.
|
||||
/// </summary>
|
||||
public interface ISourceEdgeSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs a source edge payload and returns a DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="request">The signing request with payload.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Signing result with envelope or error.</returns>
|
||||
Task<SourceEdgeSigningResult> SignAsync(SourceEdgeSigningRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to sign a source edge.
|
||||
/// </summary>
|
||||
public sealed record SourceEdgeSigningRequest
|
||||
{
|
||||
/// <summary>Source advisory ID being signed.</summary>
|
||||
public required string SourceAdvisoryId { get; init; }
|
||||
|
||||
/// <summary>Source name (e.g., "nvd", "debian").</summary>
|
||||
public required string SourceName { get; init; }
|
||||
|
||||
/// <summary>SHA256 hash of the payload.</summary>
|
||||
public required string PayloadHash { get; init; }
|
||||
|
||||
/// <summary>Raw payload JSON to be signed.</summary>
|
||||
public required string PayloadJson { get; init; }
|
||||
|
||||
/// <summary>Payload type URI.</summary>
|
||||
public string PayloadType { get; init; } = "application/vnd.stellaops.advisory.v1+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signing a source edge.
|
||||
/// </summary>
|
||||
public sealed record SourceEdgeSigningResult
|
||||
{
|
||||
/// <summary>Whether signing was successful.</summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>DSSE envelope (if successful).</summary>
|
||||
public DsseEnvelope? Envelope { get; init; }
|
||||
|
||||
/// <summary>Error message (if failed).</summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>Signature reference ID for audit.</summary>
|
||||
public Guid? SignatureRef { get; init; }
|
||||
|
||||
/// <summary>Creates a successful result.</summary>
|
||||
public static SourceEdgeSigningResult Signed(DsseEnvelope envelope, Guid signatureRef) => new()
|
||||
{
|
||||
Success = true,
|
||||
Envelope = envelope,
|
||||
SignatureRef = signatureRef
|
||||
};
|
||||
|
||||
/// <summary>Creates a failed result.</summary>
|
||||
public static SourceEdgeSigningResult Failed(string errorMessage) => new()
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
|
||||
/// <summary>Creates a skipped result (signer not available).</summary>
|
||||
public static SourceEdgeSigningResult Skipped() => new()
|
||||
{
|
||||
Success = true,
|
||||
ErrorMessage = "Signing skipped - no signer configured"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IngestResult.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Task: CANSVC-8200-003
|
||||
// Description: Result type for advisory ingestion with merge decision
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Core.Canonical;
|
||||
|
||||
/// <summary>
|
||||
/// Result of ingesting a raw advisory.
|
||||
/// </summary>
|
||||
public sealed record IngestResult
|
||||
{
|
||||
/// <summary>ID of the canonical advisory (new or existing).</summary>
|
||||
public required Guid CanonicalId { get; init; }
|
||||
|
||||
/// <summary>Computed merge hash for the ingested advisory.</summary>
|
||||
public required string MergeHash { get; init; }
|
||||
|
||||
/// <summary>Decision made during ingestion.</summary>
|
||||
public required MergeDecision Decision { get; init; }
|
||||
|
||||
/// <summary>Reference to the signature (if DSSE signed).</summary>
|
||||
public Guid? SignatureRef { get; init; }
|
||||
|
||||
/// <summary>Reason for conflict (if Decision is Conflict).</summary>
|
||||
public string? ConflictReason { get; init; }
|
||||
|
||||
/// <summary>ID of the created source edge.</summary>
|
||||
public Guid? SourceEdgeId { get; init; }
|
||||
|
||||
/// <summary>Source that provided the advisory.</summary>
|
||||
public string? SourceName { get; init; }
|
||||
|
||||
/// <summary>Source's advisory ID.</summary>
|
||||
public string? SourceAdvisoryId { get; init; }
|
||||
|
||||
/// <summary>Creates a successful creation result.</summary>
|
||||
public static IngestResult Created(
|
||||
Guid canonicalId,
|
||||
string mergeHash,
|
||||
Guid sourceEdgeId,
|
||||
string sourceName,
|
||||
string sourceAdvisoryId,
|
||||
Guid? signatureRef = null) => new()
|
||||
{
|
||||
CanonicalId = canonicalId,
|
||||
MergeHash = mergeHash,
|
||||
Decision = MergeDecision.Created,
|
||||
SourceEdgeId = sourceEdgeId,
|
||||
SourceName = sourceName,
|
||||
SourceAdvisoryId = sourceAdvisoryId,
|
||||
SignatureRef = signatureRef
|
||||
};
|
||||
|
||||
/// <summary>Creates a successful merge result.</summary>
|
||||
public static IngestResult Merged(
|
||||
Guid canonicalId,
|
||||
string mergeHash,
|
||||
Guid sourceEdgeId,
|
||||
string sourceName,
|
||||
string sourceAdvisoryId,
|
||||
Guid? signatureRef = null) => new()
|
||||
{
|
||||
CanonicalId = canonicalId,
|
||||
MergeHash = mergeHash,
|
||||
Decision = MergeDecision.Merged,
|
||||
SourceEdgeId = sourceEdgeId,
|
||||
SourceName = sourceName,
|
||||
SourceAdvisoryId = sourceAdvisoryId,
|
||||
SignatureRef = signatureRef
|
||||
};
|
||||
|
||||
/// <summary>Creates a duplicate result (no changes made).</summary>
|
||||
public static IngestResult Duplicate(
|
||||
Guid canonicalId,
|
||||
string mergeHash,
|
||||
string sourceName,
|
||||
string sourceAdvisoryId) => new()
|
||||
{
|
||||
CanonicalId = canonicalId,
|
||||
MergeHash = mergeHash,
|
||||
Decision = MergeDecision.Duplicate,
|
||||
SourceName = sourceName,
|
||||
SourceAdvisoryId = sourceAdvisoryId
|
||||
};
|
||||
|
||||
/// <summary>Creates a conflict result.</summary>
|
||||
public static IngestResult Conflict(
|
||||
Guid canonicalId,
|
||||
string mergeHash,
|
||||
string conflictReason,
|
||||
string sourceName,
|
||||
string sourceAdvisoryId) => new()
|
||||
{
|
||||
CanonicalId = canonicalId,
|
||||
MergeHash = mergeHash,
|
||||
Decision = MergeDecision.Conflict,
|
||||
ConflictReason = conflictReason,
|
||||
SourceName = sourceName,
|
||||
SourceAdvisoryId = sourceAdvisoryId
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision made when ingesting an advisory.
|
||||
/// </summary>
|
||||
public enum MergeDecision
|
||||
{
|
||||
/// <summary>New canonical advisory was created.</summary>
|
||||
Created,
|
||||
|
||||
/// <summary>Advisory was merged into an existing canonical.</summary>
|
||||
Merged,
|
||||
|
||||
/// <summary>Exact duplicate was detected, no changes made.</summary>
|
||||
Duplicate,
|
||||
|
||||
/// <summary>Merge conflict was detected.</summary>
|
||||
Conflict
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceEdge.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Task: CANSVC-8200-002
|
||||
// Description: Domain model for source edge linking canonical to source document
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Core.Canonical;
|
||||
|
||||
/// <summary>
|
||||
/// Link from canonical advisory to source document.
|
||||
/// </summary>
|
||||
public sealed record SourceEdge
|
||||
{
|
||||
/// <summary>Unique source edge identifier.</summary>
|
||||
public Guid Id { get; init; }
|
||||
|
||||
/// <summary>Reference to the canonical advisory.</summary>
|
||||
public Guid CanonicalId { get; init; }
|
||||
|
||||
/// <summary>Source identifier (osv, nvd, ghsa, redhat, debian, etc.).</summary>
|
||||
public required string SourceName { get; init; }
|
||||
|
||||
/// <summary>Source's advisory ID (DSA-5678, RHSA-2024:1234, etc.).</summary>
|
||||
public required string SourceAdvisoryId { get; init; }
|
||||
|
||||
/// <summary>SHA256 hash of the raw source document.</summary>
|
||||
public required string SourceDocHash { get; init; }
|
||||
|
||||
/// <summary>VEX-style status from the source.</summary>
|
||||
public VendorStatus? VendorStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source priority: vendor=10, distro=20, osv=30, nvd=40, default=100.
|
||||
/// Lower value = higher priority.
|
||||
/// </summary>
|
||||
public int PrecedenceRank { get; init; } = 100;
|
||||
|
||||
/// <summary>DSSE signature envelope.</summary>
|
||||
public DsseEnvelope? DsseEnvelope { get; init; }
|
||||
|
||||
/// <summary>When the source document was fetched.</summary>
|
||||
public DateTimeOffset FetchedAt { get; init; }
|
||||
|
||||
/// <summary>When the edge record was created.</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX-style vendor status for vulnerability.
|
||||
/// </summary>
|
||||
public enum VendorStatus
|
||||
{
|
||||
/// <summary>The product is affected by the vulnerability.</summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>The product is not affected by the vulnerability.</summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>The vulnerability has been fixed in this version.</summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>The vendor is investigating the vulnerability.</summary>
|
||||
UnderInvestigation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE (Dead Simple Signing Envelope) for cryptographic signatures.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelope
|
||||
{
|
||||
/// <summary>Payload type URI (e.g., "application/vnd.stellaops.advisory.v1+json").</summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>Base64-encoded payload.</summary>
|
||||
public required string Payload { get; init; }
|
||||
|
||||
/// <summary>Signatures over the payload.</summary>
|
||||
public IReadOnlyList<DsseSignature> Signatures { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single signature in a DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record DsseSignature
|
||||
{
|
||||
/// <summary>Key ID or identifier for the signing key.</summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>Base64-encoded signature.</summary>
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IMergeHashCalculator.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-002
|
||||
// Description: Interface for deterministic semantic merge hash computation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic semantic merge hash for advisory deduplication.
|
||||
/// Unlike content hashing, merge hash is based on identity components only:
|
||||
/// (CVE + affects_key + version_range + weaknesses + patch_lineage).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same CVE affecting the same package should produce the same merge hash
|
||||
/// regardless of which source (Debian, RHEL, etc.) reported it.
|
||||
/// </remarks>
|
||||
public interface IMergeHashCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute merge hash from advisory identity components.
|
||||
/// </summary>
|
||||
/// <param name="input">The identity components to hash.</param>
|
||||
/// <returns>Hex-encoded SHA256 hash prefixed with "sha256:".</returns>
|
||||
string ComputeMergeHash(MergeHashInput input);
|
||||
|
||||
/// <summary>
|
||||
/// Compute merge hash directly from Advisory domain model.
|
||||
/// Extracts identity components from the advisory and computes hash.
|
||||
/// </summary>
|
||||
/// <param name="advisory">The advisory to compute hash for.</param>
|
||||
/// <returns>Hex-encoded SHA256 hash prefixed with "sha256:".</returns>
|
||||
string ComputeMergeHash(Advisory advisory);
|
||||
|
||||
/// <summary>
|
||||
/// Compute merge hash for a specific affected package within an advisory.
|
||||
/// </summary>
|
||||
/// <param name="advisory">The advisory containing the CVE and weaknesses.</param>
|
||||
/// <param name="affectedPackage">The specific affected package.</param>
|
||||
/// <returns>Hex-encoded SHA256 hash prefixed with "sha256:".</returns>
|
||||
string ComputeMergeHash(Advisory advisory, AffectedPackage affectedPackage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input components for merge hash computation.
|
||||
/// </summary>
|
||||
public sealed record MergeHashInput
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier (e.g., "CVE-2024-1234"). Required.
|
||||
/// Will be normalized to uppercase.
|
||||
/// </summary>
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected package identifier (PURL or CPE). Required.
|
||||
/// Will be normalized according to package type rules.
|
||||
/// </summary>
|
||||
public required string AffectsKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected version range expression. Optional.
|
||||
/// Will be normalized to canonical interval notation.
|
||||
/// </summary>
|
||||
public string? VersionRange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated CWE identifiers. Optional.
|
||||
/// Will be normalized to uppercase, sorted, deduplicated.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Weaknesses { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Upstream patch provenance (commit SHA, patch ID). Optional.
|
||||
/// Enables differentiation of distro backports from upstream fixes.
|
||||
/// </summary>
|
||||
public string? PatchLineage { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MergeHashCalculator.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Tasks: MHASH-8200-009, MHASH-8200-010, MHASH-8200-011
|
||||
// Description: Core merge hash calculator implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic semantic merge hash for advisory deduplication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The merge hash is computed from identity components only:
|
||||
/// <list type="bullet">
|
||||
/// <item>CVE identifier (normalized, uppercase)</item>
|
||||
/// <item>Affected package identifier (PURL/CPE, normalized)</item>
|
||||
/// <item>Version range (canonical interval notation)</item>
|
||||
/// <item>CWE weaknesses (sorted, deduplicated)</item>
|
||||
/// <item>Patch lineage (optional, for backport differentiation)</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class MergeHashCalculator : IMergeHashCalculator
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8NoBom = new(false);
|
||||
|
||||
private readonly ICveNormalizer _cveNormalizer;
|
||||
private readonly IPurlNormalizer _purlNormalizer;
|
||||
private readonly ICpeNormalizer _cpeNormalizer;
|
||||
private readonly IVersionRangeNormalizer _versionRangeNormalizer;
|
||||
private readonly ICweNormalizer _cweNormalizer;
|
||||
private readonly IPatchLineageNormalizer _patchLineageNormalizer;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new MergeHashCalculator with default normalizers.
|
||||
/// </summary>
|
||||
public MergeHashCalculator()
|
||||
: this(
|
||||
CveNormalizer.Instance,
|
||||
PurlNormalizer.Instance,
|
||||
CpeNormalizer.Instance,
|
||||
VersionRangeNormalizer.Instance,
|
||||
CweNormalizer.Instance,
|
||||
PatchLineageNormalizer.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new MergeHashCalculator with custom normalizers.
|
||||
/// </summary>
|
||||
public MergeHashCalculator(
|
||||
ICveNormalizer cveNormalizer,
|
||||
IPurlNormalizer purlNormalizer,
|
||||
ICpeNormalizer cpeNormalizer,
|
||||
IVersionRangeNormalizer versionRangeNormalizer,
|
||||
ICweNormalizer cweNormalizer,
|
||||
IPatchLineageNormalizer patchLineageNormalizer)
|
||||
{
|
||||
_cveNormalizer = cveNormalizer ?? throw new ArgumentNullException(nameof(cveNormalizer));
|
||||
_purlNormalizer = purlNormalizer ?? throw new ArgumentNullException(nameof(purlNormalizer));
|
||||
_cpeNormalizer = cpeNormalizer ?? throw new ArgumentNullException(nameof(cpeNormalizer));
|
||||
_versionRangeNormalizer = versionRangeNormalizer ?? throw new ArgumentNullException(nameof(versionRangeNormalizer));
|
||||
_cweNormalizer = cweNormalizer ?? throw new ArgumentNullException(nameof(cweNormalizer));
|
||||
_patchLineageNormalizer = patchLineageNormalizer ?? throw new ArgumentNullException(nameof(patchLineageNormalizer));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ComputeMergeHash(MergeHashInput input)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
var canonical = BuildCanonicalString(input);
|
||||
return ComputeHash(canonical);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ComputeMergeHash(Advisory advisory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(advisory);
|
||||
|
||||
// Extract CVE from advisory key or aliases
|
||||
var cve = ExtractCve(advisory);
|
||||
|
||||
// If no affected packages, compute hash from CVE and weaknesses only
|
||||
if (advisory.AffectedPackages.IsDefaultOrEmpty)
|
||||
{
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = cve,
|
||||
AffectsKey = string.Empty,
|
||||
VersionRange = null,
|
||||
Weaknesses = ExtractWeaknesses(advisory),
|
||||
PatchLineage = null
|
||||
};
|
||||
return ComputeMergeHash(input);
|
||||
}
|
||||
|
||||
// Compute hash for first affected package (primary identity)
|
||||
// For multi-package advisories, each package gets its own hash
|
||||
return ComputeMergeHash(advisory, advisory.AffectedPackages[0]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ComputeMergeHash(Advisory advisory, AffectedPackage affectedPackage)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(advisory);
|
||||
ArgumentNullException.ThrowIfNull(affectedPackage);
|
||||
|
||||
var cve = ExtractCve(advisory);
|
||||
var affectsKey = BuildAffectsKey(affectedPackage);
|
||||
var versionRange = BuildVersionRange(affectedPackage);
|
||||
var weaknesses = ExtractWeaknesses(advisory);
|
||||
var patchLineage = ExtractPatchLineage(advisory, affectedPackage);
|
||||
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = cve,
|
||||
AffectsKey = affectsKey,
|
||||
VersionRange = versionRange,
|
||||
Weaknesses = weaknesses,
|
||||
PatchLineage = patchLineage
|
||||
};
|
||||
|
||||
return ComputeMergeHash(input);
|
||||
}
|
||||
|
||||
private string BuildCanonicalString(MergeHashInput input)
|
||||
{
|
||||
// Normalize all components
|
||||
var cve = _cveNormalizer.Normalize(input.Cve);
|
||||
var affectsKey = NormalizeAffectsKey(input.AffectsKey);
|
||||
var versionRange = _versionRangeNormalizer.Normalize(input.VersionRange);
|
||||
var weaknesses = _cweNormalizer.Normalize(input.Weaknesses);
|
||||
var patchLineage = _patchLineageNormalizer.Normalize(input.PatchLineage);
|
||||
|
||||
// Build deterministic canonical string with field ordering
|
||||
// Format: CVE|AFFECTS|VERSION|CWE|LINEAGE
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.Append("CVE:");
|
||||
sb.Append(cve);
|
||||
sb.Append('|');
|
||||
|
||||
sb.Append("AFFECTS:");
|
||||
sb.Append(affectsKey);
|
||||
sb.Append('|');
|
||||
|
||||
sb.Append("VERSION:");
|
||||
sb.Append(versionRange);
|
||||
sb.Append('|');
|
||||
|
||||
sb.Append("CWE:");
|
||||
sb.Append(weaknesses);
|
||||
sb.Append('|');
|
||||
|
||||
sb.Append("LINEAGE:");
|
||||
sb.Append(patchLineage ?? string.Empty);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string NormalizeAffectsKey(string affectsKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(affectsKey))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = affectsKey.Trim();
|
||||
|
||||
// Route to appropriate normalizer
|
||||
if (trimmed.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return _purlNormalizer.Normalize(trimmed);
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return _cpeNormalizer.Normalize(trimmed);
|
||||
}
|
||||
|
||||
// Default to PURL normalizer for unknown formats
|
||||
return _purlNormalizer.Normalize(trimmed);
|
||||
}
|
||||
|
||||
private static string ComputeHash(string canonical)
|
||||
{
|
||||
var bytes = Utf8NoBom.GetBytes(canonical);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ExtractCve(Advisory advisory)
|
||||
{
|
||||
// Check if advisory key is a CVE
|
||||
if (advisory.AdvisoryKey.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return advisory.AdvisoryKey;
|
||||
}
|
||||
|
||||
// Look for CVE in aliases
|
||||
var cveAlias = advisory.Aliases
|
||||
.FirstOrDefault(static a => a.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return cveAlias ?? advisory.AdvisoryKey;
|
||||
}
|
||||
|
||||
private static string BuildAffectsKey(AffectedPackage package)
|
||||
{
|
||||
// Build PURL-like identifier from package
|
||||
return package.Identifier;
|
||||
}
|
||||
|
||||
private static string? BuildVersionRange(AffectedPackage package)
|
||||
{
|
||||
if (package.VersionRanges.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Combine all version ranges - use RangeExpression or build from primitives
|
||||
var ranges = package.VersionRanges
|
||||
.Select(static r => r.RangeExpression ?? BuildRangeFromPrimitives(r))
|
||||
.Where(static r => !string.IsNullOrWhiteSpace(r))
|
||||
.OrderBy(static r => r, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (ranges.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.Join(",", ranges);
|
||||
}
|
||||
|
||||
private static string? BuildRangeFromPrimitives(AffectedVersionRange range)
|
||||
{
|
||||
// Build a range expression from introduced/fixed/lastAffected
|
||||
var parts = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(range.IntroducedVersion))
|
||||
{
|
||||
parts.Add($">={range.IntroducedVersion}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(range.FixedVersion))
|
||||
{
|
||||
parts.Add($"<{range.FixedVersion}");
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(range.LastAffectedVersion))
|
||||
{
|
||||
parts.Add($"<={range.LastAffectedVersion}");
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? string.Join(",", parts) : null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractWeaknesses(Advisory advisory)
|
||||
{
|
||||
if (advisory.Cwes.IsDefaultOrEmpty)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return advisory.Cwes
|
||||
.Select(static w => w.Identifier)
|
||||
.Where(static w => !string.IsNullOrWhiteSpace(w))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? ExtractPatchLineage(Advisory advisory, AffectedPackage package)
|
||||
{
|
||||
// Look for patch lineage in provenance or references
|
||||
// This is a simplified implementation - real implementation would
|
||||
// extract from backport proof or upstream references
|
||||
var patchRef = advisory.References
|
||||
.Where(static r => r.Kind is "patch" or "fix" or "commit")
|
||||
.Select(static r => r.Url)
|
||||
.FirstOrDefault();
|
||||
|
||||
return patchRef;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MergeHashShadowWriteService.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-020
|
||||
// Description: Shadow-write merge hashes for existing advisories during migration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Advisories;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Service to compute and persist merge hashes for existing advisories
|
||||
/// without changing their identity. Used during migration to backfill
|
||||
/// merge_hash for pre-existing data.
|
||||
/// </summary>
|
||||
public sealed class MergeHashShadowWriteService
|
||||
{
|
||||
private readonly IAdvisoryStore _advisoryStore;
|
||||
private readonly IMergeHashCalculator _mergeHashCalculator;
|
||||
private readonly ILogger<MergeHashShadowWriteService> _logger;
|
||||
|
||||
public MergeHashShadowWriteService(
|
||||
IAdvisoryStore advisoryStore,
|
||||
IMergeHashCalculator mergeHashCalculator,
|
||||
ILogger<MergeHashShadowWriteService> logger)
|
||||
{
|
||||
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
|
||||
_mergeHashCalculator = mergeHashCalculator ?? throw new ArgumentNullException(nameof(mergeHashCalculator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfills merge hashes for all advisories that don't have one.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Summary of the backfill operation.</returns>
|
||||
public async Task<ShadowWriteResult> BackfillAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var processed = 0;
|
||||
var updated = 0;
|
||||
var skipped = 0;
|
||||
var failed = 0;
|
||||
|
||||
await foreach (var advisory in _advisoryStore.StreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
processed++;
|
||||
|
||||
// Skip if already has merge hash
|
||||
if (!string.IsNullOrEmpty(advisory.MergeHash))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mergeHash = _mergeHashCalculator.ComputeMergeHash(advisory);
|
||||
var enriched = EnrichWithMergeHash(advisory, mergeHash);
|
||||
await _advisoryStore.UpsertAsync(enriched, cancellationToken).ConfigureAwait(false);
|
||||
updated++;
|
||||
|
||||
if (updated % 100 == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Merge hash backfill progress: processed={Processed}, updated={Updated}, skipped={Skipped}, failed={Failed}",
|
||||
processed, updated, skipped, failed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
_logger.LogWarning(ex, "Failed to compute merge hash for {AdvisoryKey}", advisory.AdvisoryKey);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Merge hash backfill complete: processed={Processed}, updated={Updated}, skipped={Skipped}, failed={Failed}",
|
||||
processed, updated, skipped, failed);
|
||||
|
||||
return new ShadowWriteResult(processed, updated, skipped, failed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes and persists merge hash for a single advisory.
|
||||
/// </summary>
|
||||
/// <param name="advisoryKey">The advisory key to process.</param>
|
||||
/// <param name="force">If true, recomputes even if hash exists.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if advisory was updated, false otherwise.</returns>
|
||||
public async Task<bool> BackfillOneAsync(string advisoryKey, bool force, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
|
||||
|
||||
var advisory = await _advisoryStore.FindAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
if (advisory is null)
|
||||
{
|
||||
_logger.LogWarning("Advisory {AdvisoryKey} not found for merge hash backfill", advisoryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if already has merge hash and not forcing
|
||||
if (!force && !string.IsNullOrEmpty(advisory.MergeHash))
|
||||
{
|
||||
_logger.LogDebug("Skipping {AdvisoryKey}: already has merge hash", advisoryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mergeHash = _mergeHashCalculator.ComputeMergeHash(advisory);
|
||||
var enriched = EnrichWithMergeHash(advisory, mergeHash);
|
||||
await _advisoryStore.UpsertAsync(enriched, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Computed merge hash for {AdvisoryKey}: {MergeHash}", advisoryKey, mergeHash);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to compute merge hash for {AdvisoryKey}", advisoryKey);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static Advisory EnrichWithMergeHash(Advisory advisory, string mergeHash)
|
||||
{
|
||||
return new Advisory(
|
||||
advisory.AdvisoryKey,
|
||||
advisory.Title,
|
||||
advisory.Summary,
|
||||
advisory.Language,
|
||||
advisory.Published,
|
||||
advisory.Modified,
|
||||
advisory.Severity,
|
||||
advisory.ExploitKnown,
|
||||
advisory.Aliases,
|
||||
advisory.Credits,
|
||||
advisory.References,
|
||||
advisory.AffectedPackages,
|
||||
advisory.CvssMetrics,
|
||||
advisory.Provenance,
|
||||
advisory.Description,
|
||||
advisory.Cwes,
|
||||
advisory.CanonicalMetricId,
|
||||
mergeHash);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a shadow-write backfill operation.
|
||||
/// </summary>
|
||||
/// <param name="Processed">Total advisories examined.</param>
|
||||
/// <param name="Updated">Advisories updated with new merge hash.</param>
|
||||
/// <param name="Skipped">Advisories skipped (already had merge hash).</param>
|
||||
/// <param name="Failed">Advisories that failed hash computation.</param>
|
||||
public sealed record ShadowWriteResult(int Processed, int Updated, int Skipped, int Failed);
|
||||
@@ -0,0 +1,120 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CpeNormalizer.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-004
|
||||
// Description: CPE normalization for merge hash
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes CPE identifiers to canonical CPE 2.3 format.
|
||||
/// </summary>
|
||||
public sealed partial class CpeNormalizer : ICpeNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static CpeNormalizer Instance { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for CPE 2.3 formatted string binding.
|
||||
/// </summary>
|
||||
[GeneratedRegex(
|
||||
@"^cpe:2\.3:([aho]):([^:]+):([^:]+):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*)$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex Cpe23Pattern();
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for CPE 2.2 URI binding.
|
||||
/// </summary>
|
||||
[GeneratedRegex(
|
||||
@"^cpe:/([aho]):([^:]+):([^:]+)(?::([^:]+))?(?::([^:]+))?(?::([^:]+))?(?::([^:]+))?$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex Cpe22Pattern();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Normalize(string cpe)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cpe))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = cpe.Trim();
|
||||
|
||||
// Try CPE 2.3 format first
|
||||
var match23 = Cpe23Pattern().Match(trimmed);
|
||||
if (match23.Success)
|
||||
{
|
||||
return NormalizeCpe23(match23);
|
||||
}
|
||||
|
||||
// Try CPE 2.2 format
|
||||
var match22 = Cpe22Pattern().Match(trimmed);
|
||||
if (match22.Success)
|
||||
{
|
||||
return ConvertCpe22ToCpe23(match22);
|
||||
}
|
||||
|
||||
// Return as lowercase if unrecognized
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeCpe23(Match match)
|
||||
{
|
||||
var part = match.Groups[1].Value.ToLowerInvariant();
|
||||
var vendor = NormalizeComponent(match.Groups[2].Value);
|
||||
var product = NormalizeComponent(match.Groups[3].Value);
|
||||
var version = NormalizeComponent(match.Groups[4].Value);
|
||||
var update = NormalizeComponent(match.Groups[5].Value);
|
||||
var edition = NormalizeComponent(match.Groups[6].Value);
|
||||
var language = NormalizeComponent(match.Groups[7].Value);
|
||||
var swEdition = NormalizeComponent(match.Groups[8].Value);
|
||||
var targetSw = NormalizeComponent(match.Groups[9].Value);
|
||||
var targetHw = NormalizeComponent(match.Groups[10].Value);
|
||||
var other = NormalizeComponent(match.Groups[11].Value);
|
||||
|
||||
return $"cpe:2.3:{part}:{vendor}:{product}:{version}:{update}:{edition}:{language}:{swEdition}:{targetSw}:{targetHw}:{other}";
|
||||
}
|
||||
|
||||
private static string ConvertCpe22ToCpe23(Match match)
|
||||
{
|
||||
var part = match.Groups[1].Value.ToLowerInvariant();
|
||||
var vendor = NormalizeComponent(match.Groups[2].Value);
|
||||
var product = NormalizeComponent(match.Groups[3].Value);
|
||||
var version = match.Groups[4].Success ? NormalizeComponent(match.Groups[4].Value) : "*";
|
||||
var update = match.Groups[5].Success ? NormalizeComponent(match.Groups[5].Value) : "*";
|
||||
var edition = match.Groups[6].Success ? NormalizeComponent(match.Groups[6].Value) : "*";
|
||||
var language = match.Groups[7].Success ? NormalizeComponent(match.Groups[7].Value) : "*";
|
||||
|
||||
return $"cpe:2.3:{part}:{vendor}:{product}:{version}:{update}:{edition}:{language}:*:*:*:*";
|
||||
}
|
||||
|
||||
private static string NormalizeComponent(string component)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(component))
|
||||
{
|
||||
return "*";
|
||||
}
|
||||
|
||||
var trimmed = component.Trim();
|
||||
|
||||
// Wildcards
|
||||
if (trimmed is "*" or "-" or "ANY" or "NA")
|
||||
{
|
||||
return trimmed switch
|
||||
{
|
||||
"ANY" => "*",
|
||||
"NA" => "-",
|
||||
_ => trimmed
|
||||
};
|
||||
}
|
||||
|
||||
// Lowercase and handle escaping
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CveNormalizer.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-003 (part of normalization helpers)
|
||||
// Description: CVE identifier normalization for merge hash
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes CVE identifiers to canonical uppercase format.
|
||||
/// </summary>
|
||||
public sealed partial class CveNormalizer : ICveNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static CveNormalizer Instance { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Pattern matching CVE identifier: CVE-YYYY-NNNNN (4+ digits after year).
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"^CVE-(\d{4})-(\d{4,})$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex CvePattern();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Normalize(string? cve)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = cve.Trim();
|
||||
|
||||
// Handle common prefixes
|
||||
if (trimmed.StartsWith("cve-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
trimmed = "CVE-" + trimmed[4..];
|
||||
}
|
||||
else if (!trimmed.StartsWith("CVE-", StringComparison.Ordinal))
|
||||
{
|
||||
// Try to extract CVE from the string
|
||||
var match = CvePattern().Match(trimmed);
|
||||
if (match.Success)
|
||||
{
|
||||
trimmed = match.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Assume it's just the number part: 2024-1234 -> CVE-2024-1234
|
||||
if (Regex.IsMatch(trimmed, @"^\d{4}-\d{4,}$"))
|
||||
{
|
||||
trimmed = "CVE-" + trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and uppercase
|
||||
var normalized = trimmed.ToUpperInvariant();
|
||||
if (!CvePattern().IsMatch(normalized))
|
||||
{
|
||||
// Return as-is if not a valid CVE (will still be hashed consistently)
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CweNormalizer.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-006
|
||||
// Description: CWE identifier list normalization for merge hash
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes CWE identifier lists for deterministic hashing.
|
||||
/// </summary>
|
||||
public sealed partial class CweNormalizer : ICweNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static CweNormalizer Instance { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Pattern matching CWE identifier: CWE-NNN or just NNN.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"(?:CWE-)?(\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex CwePattern();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Normalize(IEnumerable<string>? cwes)
|
||||
{
|
||||
if (cwes is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = cwes
|
||||
.Where(static cwe => !string.IsNullOrWhiteSpace(cwe))
|
||||
.Select(NormalizeSingle)
|
||||
.Where(static cwe => cwe is not null)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(ExtractCweNumber)
|
||||
.ThenBy(static cwe => cwe, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.Join(",", normalized);
|
||||
}
|
||||
|
||||
private static string? NormalizeSingle(string cwe)
|
||||
{
|
||||
var trimmed = cwe.Trim();
|
||||
var match = CwePattern().Match(trimmed);
|
||||
|
||||
if (!match.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var number = match.Groups[1].Value;
|
||||
return $"CWE-{number}";
|
||||
}
|
||||
|
||||
private static int ExtractCweNumber(string? cwe)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cwe))
|
||||
{
|
||||
return int.MaxValue;
|
||||
}
|
||||
|
||||
var match = CwePattern().Match(cwe);
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// INormalizer.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Tasks: MHASH-8200-003 to MHASH-8200-007
|
||||
// Description: Normalizer interfaces for merge hash components
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes PURL identifiers to canonical form for deterministic hashing.
|
||||
/// </summary>
|
||||
public interface IPurlNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalize PURL to canonical form.
|
||||
/// - Lowercase package type
|
||||
/// - URL-encode special characters in namespace
|
||||
/// - Strip non-essential qualifiers (arch, type, checksum)
|
||||
/// - Sort remaining qualifiers alphabetically
|
||||
/// </summary>
|
||||
string Normalize(string purl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes CPE identifiers to canonical CPE 2.3 format.
|
||||
/// </summary>
|
||||
public interface ICpeNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalize CPE to canonical CPE 2.3 format.
|
||||
/// - Convert CPE 2.2 URI format to CPE 2.3 formatted string
|
||||
/// - Lowercase vendor and product
|
||||
/// - Normalize wildcards
|
||||
/// </summary>
|
||||
string Normalize(string cpe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes version range expressions to canonical interval notation.
|
||||
/// </summary>
|
||||
public interface IVersionRangeNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalize version range to canonical expression.
|
||||
/// - Convert various formats to canonical interval notation
|
||||
/// - Trim whitespace
|
||||
/// - Normalize operators (e.g., "[1.0, 2.0)" → ">=1.0,<2.0")
|
||||
/// </summary>
|
||||
string Normalize(string? range);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes CWE identifier lists for deterministic hashing.
|
||||
/// </summary>
|
||||
public interface ICweNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalize CWE list to sorted, deduplicated, uppercase set.
|
||||
/// - Uppercase all identifiers
|
||||
/// - Ensure "CWE-" prefix
|
||||
/// - Sort numerically by CWE number
|
||||
/// - Deduplicate
|
||||
/// - Return comma-joined string
|
||||
/// </summary>
|
||||
string Normalize(IEnumerable<string>? cwes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes patch lineage references for deterministic hashing.
|
||||
/// </summary>
|
||||
public interface IPatchLineageNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalize patch lineage to canonical commit reference.
|
||||
/// - Extract commit SHAs from various formats
|
||||
/// - Normalize to lowercase hex
|
||||
/// - Handle patch IDs, bug tracker references
|
||||
/// </summary>
|
||||
string? Normalize(string? lineage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes CVE identifiers for deterministic hashing.
|
||||
/// </summary>
|
||||
public interface ICveNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalize CVE identifier to canonical uppercase format.
|
||||
/// - Ensure "CVE-" prefix
|
||||
/// - Uppercase
|
||||
/// - Validate format (CVE-YYYY-NNNNN+)
|
||||
/// </summary>
|
||||
string Normalize(string? cve);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PatchLineageNormalizer.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-007
|
||||
// Description: Patch lineage normalization for merge hash
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes patch lineage references for deterministic hashing.
|
||||
/// Extracts upstream commit references from various formats.
|
||||
/// </summary>
|
||||
public sealed partial class PatchLineageNormalizer : IPatchLineageNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static PatchLineageNormalizer Instance { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for full Git commit SHA (40 hex chars).
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"\b([0-9a-f]{40})\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex FullShaPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for abbreviated Git commit SHA (7-12 hex chars).
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"\b([0-9a-f]{7,12})\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex AbbrevShaPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for GitHub/GitLab commit URLs.
|
||||
/// </summary>
|
||||
[GeneratedRegex(
|
||||
@"(?:github\.com|gitlab\.com)/[^/]+/[^/]+/commit/([0-9a-f]{7,40})",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex CommitUrlPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for patch IDs in format "patch-NNNNN" or "PATCH-NNNNN".
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"\b(PATCH-\d+)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex PatchIdPattern();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? Normalize(string? lineage)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lineage))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = lineage.Trim();
|
||||
|
||||
// Try to extract commit SHA from URL first
|
||||
var urlMatch = CommitUrlPattern().Match(trimmed);
|
||||
if (urlMatch.Success)
|
||||
{
|
||||
return NormalizeSha(urlMatch.Groups[1].Value);
|
||||
}
|
||||
|
||||
// Try full SHA
|
||||
var fullMatch = FullShaPattern().Match(trimmed);
|
||||
if (fullMatch.Success)
|
||||
{
|
||||
return NormalizeSha(fullMatch.Groups[1].Value);
|
||||
}
|
||||
|
||||
// Try abbreviated SHA (only if it looks like a commit reference)
|
||||
if (LooksLikeCommitReference(trimmed))
|
||||
{
|
||||
var abbrevMatch = AbbrevShaPattern().Match(trimmed);
|
||||
if (abbrevMatch.Success)
|
||||
{
|
||||
return NormalizeSha(abbrevMatch.Groups[1].Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Try patch ID
|
||||
var patchMatch = PatchIdPattern().Match(trimmed);
|
||||
if (patchMatch.Success)
|
||||
{
|
||||
return patchMatch.Groups[1].Value.ToUpperInvariant();
|
||||
}
|
||||
|
||||
// Return null if no recognizable pattern
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool LooksLikeCommitReference(string value)
|
||||
{
|
||||
// Heuristic: if it contains "commit", "sha", "fix", "patch" it's likely a commit ref
|
||||
var lower = value.ToLowerInvariant();
|
||||
return lower.Contains("commit") ||
|
||||
lower.Contains("sha") ||
|
||||
lower.Contains("fix") ||
|
||||
lower.Contains("patch") ||
|
||||
lower.Contains("backport");
|
||||
}
|
||||
|
||||
private static string NormalizeSha(string sha)
|
||||
{
|
||||
// Lowercase and ensure we have the full SHA or a consistent abbreviation
|
||||
var normalized = sha.ToLowerInvariant();
|
||||
|
||||
// If it's a full SHA, return it
|
||||
if (normalized.Length == 40)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// For abbreviated SHAs, return as-is (they'll still hash consistently)
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PurlNormalizer.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-003
|
||||
// Description: PURL normalization for merge hash
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Web;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes PURL identifiers to canonical form for deterministic hashing.
|
||||
/// </summary>
|
||||
public sealed partial class PurlNormalizer : IPurlNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static PurlNormalizer Instance { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Qualifiers to strip from PURL for identity hashing (architecture-specific, non-identity).
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> StrippedQualifiers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"arch",
|
||||
"architecture",
|
||||
"os",
|
||||
"platform",
|
||||
"type",
|
||||
"classifier",
|
||||
"checksum",
|
||||
"download_url",
|
||||
"vcs_url",
|
||||
"repository_url"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for parsing PURL: pkg:type/namespace/name@version?qualifiers#subpath
|
||||
/// </summary>
|
||||
[GeneratedRegex(
|
||||
@"^pkg:([a-zA-Z][a-zA-Z0-9+.-]*)(?:/([^/@#?]+))?/([^/@#?]+)(?:@([^?#]+))?(?:\?([^#]+))?(?:#(.+))?$",
|
||||
RegexOptions.Compiled)]
|
||||
private static partial Regex PurlPattern();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Normalize(string purl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = purl.Trim();
|
||||
|
||||
// Handle non-PURL identifiers (CPE, plain package names)
|
||||
if (!trimmed.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// If it looks like a CPE, return as-is for CPE normalizer
|
||||
if (trimmed.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Return lowercase for plain identifiers
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
var match = PurlPattern().Match(trimmed);
|
||||
if (!match.Success)
|
||||
{
|
||||
// Invalid PURL format, return lowercase
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
var type = match.Groups[1].Value.ToLowerInvariant();
|
||||
var ns = match.Groups[2].Success ? NormalizeNamespace(match.Groups[2].Value, type) : null;
|
||||
var name = NormalizeName(match.Groups[3].Value, type);
|
||||
var version = match.Groups[4].Success ? match.Groups[4].Value : null;
|
||||
var qualifiers = match.Groups[5].Success ? NormalizeQualifiers(match.Groups[5].Value) : null;
|
||||
// Subpath is stripped for identity purposes
|
||||
|
||||
return BuildPurl(type, ns, name, version, qualifiers);
|
||||
}
|
||||
|
||||
private static string NormalizeNamespace(string ns, string type)
|
||||
{
|
||||
// URL-decode then re-encode consistently
|
||||
var decoded = HttpUtility.UrlDecode(ns);
|
||||
|
||||
// For npm, handle scoped packages (@org/pkg)
|
||||
if (type == "npm" && decoded.StartsWith("@"))
|
||||
{
|
||||
decoded = decoded.ToLowerInvariant();
|
||||
return HttpUtility.UrlEncode(decoded)?.Replace("%40", "%40") ?? decoded;
|
||||
}
|
||||
|
||||
// Most ecosystems: lowercase namespace
|
||||
return decoded.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeName(string name, string type)
|
||||
{
|
||||
var decoded = HttpUtility.UrlDecode(name);
|
||||
|
||||
// Most ecosystems use lowercase names
|
||||
return type switch
|
||||
{
|
||||
"golang" => decoded, // Go uses mixed case
|
||||
"nuget" => decoded.ToLowerInvariant(), // NuGet is case-insensitive
|
||||
_ => decoded.ToLowerInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeQualifiers(string qualifiers)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(qualifiers))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var pairs = qualifiers
|
||||
.Split('&', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(static pair =>
|
||||
{
|
||||
var eqIndex = pair.IndexOf('=');
|
||||
if (eqIndex < 0)
|
||||
{
|
||||
return (Key: pair.ToLowerInvariant(), Value: (string?)null);
|
||||
}
|
||||
|
||||
return (Key: pair[..eqIndex].ToLowerInvariant(), Value: pair[(eqIndex + 1)..]);
|
||||
})
|
||||
.Where(pair => !StrippedQualifiers.Contains(pair.Key))
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (pairs.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.Join("&", pairs.Select(static p =>
|
||||
p.Value is null ? p.Key : $"{p.Key}={p.Value}"));
|
||||
}
|
||||
|
||||
private static string BuildPurl(string type, string? ns, string name, string? version, string? qualifiers)
|
||||
{
|
||||
var sb = new StringBuilder("pkg:");
|
||||
sb.Append(type);
|
||||
sb.Append('/');
|
||||
|
||||
if (!string.IsNullOrEmpty(ns))
|
||||
{
|
||||
sb.Append(ns);
|
||||
sb.Append('/');
|
||||
}
|
||||
|
||||
sb.Append(name);
|
||||
|
||||
if (!string.IsNullOrEmpty(version))
|
||||
{
|
||||
sb.Append('@');
|
||||
sb.Append(version);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(qualifiers))
|
||||
{
|
||||
sb.Append('?');
|
||||
sb.Append(qualifiers);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VersionRangeNormalizer.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-005
|
||||
// Description: Version range normalization for merge hash
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes version range expressions to canonical interval notation.
|
||||
/// </summary>
|
||||
public sealed partial class VersionRangeNormalizer : IVersionRangeNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static VersionRangeNormalizer Instance { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for mathematical interval notation: [1.0, 2.0) or (1.0, 2.0]
|
||||
/// </summary>
|
||||
[GeneratedRegex(
|
||||
@"^([\[\(])\s*([^,\s]*)\s*,\s*([^)\]\s]*)\s*([\]\)])$",
|
||||
RegexOptions.Compiled)]
|
||||
private static partial Regex IntervalPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for comparison operators: >= 1.0, < 2.0
|
||||
/// </summary>
|
||||
[GeneratedRegex(
|
||||
@"^(>=?|<=?|=|!=|~=|~>|\^)\s*(.+)$",
|
||||
RegexOptions.Compiled)]
|
||||
private static partial Regex ComparisonPattern();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Normalize(string? range)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(range))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = range.Trim();
|
||||
|
||||
// Handle "all versions" markers
|
||||
if (trimmed is "*" or "all" or "any")
|
||||
{
|
||||
return "*";
|
||||
}
|
||||
|
||||
// Try interval notation: [1.0, 2.0)
|
||||
var intervalMatch = IntervalPattern().Match(trimmed);
|
||||
if (intervalMatch.Success)
|
||||
{
|
||||
return NormalizeInterval(intervalMatch);
|
||||
}
|
||||
|
||||
// Try comparison operators: >= 1.0
|
||||
var compMatch = ComparisonPattern().Match(trimmed);
|
||||
if (compMatch.Success)
|
||||
{
|
||||
return NormalizeComparison(compMatch);
|
||||
}
|
||||
|
||||
// Handle comma-separated constraints: >=1.0, <2.0
|
||||
if (trimmed.Contains(','))
|
||||
{
|
||||
return NormalizeMultiConstraint(trimmed);
|
||||
}
|
||||
|
||||
// Handle "fixed" version notation
|
||||
if (trimmed.StartsWith("fixed:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var fixedVersion = trimmed[6..].Trim();
|
||||
return $">={fixedVersion}";
|
||||
}
|
||||
|
||||
// Handle plain version (treat as exact match)
|
||||
if (Regex.IsMatch(trimmed, @"^[\d.]+"))
|
||||
{
|
||||
return $"={trimmed}";
|
||||
}
|
||||
|
||||
// Return trimmed if unrecognized
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string NormalizeInterval(Match match)
|
||||
{
|
||||
var leftBracket = match.Groups[1].Value;
|
||||
var lower = match.Groups[2].Value.Trim();
|
||||
var upper = match.Groups[3].Value.Trim();
|
||||
var rightBracket = match.Groups[4].Value;
|
||||
|
||||
var parts = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(lower))
|
||||
{
|
||||
var op = leftBracket == "[" ? ">=" : ">";
|
||||
parts.Add($"{op}{lower}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(upper))
|
||||
{
|
||||
var op = rightBracket == "]" ? "<=" : "<";
|
||||
parts.Add($"{op}{upper}");
|
||||
}
|
||||
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
|
||||
private static string NormalizeComparison(Match match)
|
||||
{
|
||||
var op = NormalizeOperator(match.Groups[1].Value);
|
||||
var version = match.Groups[2].Value.Trim();
|
||||
return $"{op}{version}";
|
||||
}
|
||||
|
||||
private static string NormalizeMultiConstraint(string range)
|
||||
{
|
||||
var constraints = range
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(static c => c.Trim())
|
||||
.Where(static c => !string.IsNullOrEmpty(c))
|
||||
.Select(NormalizeSingleConstraint)
|
||||
.OrderBy(static c => c, StringComparer.Ordinal)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return string.Join(",", constraints);
|
||||
}
|
||||
|
||||
private static string NormalizeSingleConstraint(string constraint)
|
||||
{
|
||||
var match = ComparisonPattern().Match(constraint);
|
||||
if (match.Success)
|
||||
{
|
||||
var op = NormalizeOperator(match.Groups[1].Value);
|
||||
var version = match.Groups[2].Value.Trim();
|
||||
return $"{op}{version}";
|
||||
}
|
||||
|
||||
return constraint;
|
||||
}
|
||||
|
||||
private static string NormalizeOperator(string op)
|
||||
{
|
||||
return op switch
|
||||
{
|
||||
"~=" or "~>" => "~=",
|
||||
"^" => "^",
|
||||
">=" => ">=",
|
||||
">" => ">",
|
||||
"<=" => "<=",
|
||||
"<" => "<",
|
||||
"=" => "=",
|
||||
"!=" => "!=",
|
||||
_ => op
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MergeHashBackfillJob.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-020
|
||||
// Description: Job to backfill merge hashes for existing advisories
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Merge.Identity;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Job to backfill merge hashes for existing advisories during migration.
|
||||
/// Can target all advisories or a specific advisory key.
|
||||
/// </summary>
|
||||
public sealed class MergeHashBackfillJob : IJob
|
||||
{
|
||||
private readonly MergeHashShadowWriteService _shadowWriteService;
|
||||
private readonly ILogger<MergeHashBackfillJob> _logger;
|
||||
|
||||
public MergeHashBackfillJob(
|
||||
MergeHashShadowWriteService shadowWriteService,
|
||||
ILogger<MergeHashBackfillJob> logger)
|
||||
{
|
||||
_shadowWriteService = shadowWriteService ?? throw new ArgumentNullException(nameof(shadowWriteService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the backfill job.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Parameters:
|
||||
/// - "seed" (optional): Specific advisory key to backfill. If empty, backfills all.
|
||||
/// - "force" (optional): If "true", recomputes hash even for advisories that have one.
|
||||
/// </remarks>
|
||||
public async Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var hasSeed = context.Parameters.TryGetValue("seed", out var seedValue);
|
||||
var seed = seedValue as string;
|
||||
var force = context.Parameters.TryGetValue("force", out var forceValue)
|
||||
&& forceValue is string forceStr
|
||||
&& string.Equals(forceStr, "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (hasSeed && !string.IsNullOrWhiteSpace(seed))
|
||||
{
|
||||
_logger.LogInformation("Starting merge hash backfill for single advisory: {AdvisoryKey}, force={Force}", seed, force);
|
||||
var updated = await _shadowWriteService.BackfillOneAsync(seed, force, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Merge hash backfill for {AdvisoryKey} complete: updated={Updated}",
|
||||
seed,
|
||||
updated);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Starting merge hash backfill for all advisories");
|
||||
var result = await _shadowWriteService.BackfillAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Merge hash backfill complete: processed={Processed}, updated={Updated}, skipped={Skipped}, failed={Failed}",
|
||||
result.Processed,
|
||||
result.Updated,
|
||||
result.Skipped,
|
||||
result.Failed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,5 @@ namespace StellaOps.Concelier.Merge.Jobs;
|
||||
internal static class MergeJobKinds
|
||||
{
|
||||
public const string Reconcile = "merge:reconcile";
|
||||
public const string HashBackfill = "merge:hash-backfill";
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Core;
|
||||
using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Merge.Identity;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Advisories;
|
||||
using StellaOps.Concelier.Storage.Aliases;
|
||||
@@ -41,6 +42,7 @@ public sealed class AdvisoryMergeService
|
||||
private readonly IAdvisoryEventLog _eventLog;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly CanonicalMerger _canonicalMerger;
|
||||
private readonly IMergeHashCalculator? _mergeHashCalculator;
|
||||
private readonly ILogger<AdvisoryMergeService> _logger;
|
||||
|
||||
public AdvisoryMergeService(
|
||||
@@ -51,7 +53,8 @@ public sealed class AdvisoryMergeService
|
||||
CanonicalMerger canonicalMerger,
|
||||
IAdvisoryEventLog eventLog,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AdvisoryMergeService> logger)
|
||||
ILogger<AdvisoryMergeService> logger,
|
||||
IMergeHashCalculator? mergeHashCalculator = null)
|
||||
{
|
||||
_aliasResolver = aliasResolver ?? throw new ArgumentNullException(nameof(aliasResolver));
|
||||
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
|
||||
@@ -61,6 +64,7 @@ public sealed class AdvisoryMergeService
|
||||
_eventLog = eventLog ?? throw new ArgumentNullException(nameof(eventLog));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_mergeHashCalculator = mergeHashCalculator; // Optional during migration
|
||||
}
|
||||
|
||||
public async Task<AdvisoryMergeResult> MergeAsync(string seedAdvisoryKey, CancellationToken cancellationToken)
|
||||
@@ -102,7 +106,7 @@ public sealed class AdvisoryMergeService
|
||||
throw;
|
||||
}
|
||||
|
||||
var merged = precedenceResult.Advisory;
|
||||
var merged = EnrichWithMergeHash(precedenceResult.Advisory);
|
||||
var conflictDetails = precedenceResult.Conflicts;
|
||||
|
||||
if (component.Collisions.Count > 0)
|
||||
@@ -309,7 +313,48 @@ public sealed class AdvisoryMergeService
|
||||
source.Provenance,
|
||||
source.Description,
|
||||
source.Cwes,
|
||||
source.CanonicalMetricId);
|
||||
source.CanonicalMetricId,
|
||||
source.MergeHash);
|
||||
|
||||
/// <summary>
|
||||
/// Enriches an advisory with its computed merge hash if calculator is available.
|
||||
/// </summary>
|
||||
private Advisory EnrichWithMergeHash(Advisory advisory)
|
||||
{
|
||||
if (_mergeHashCalculator is null)
|
||||
{
|
||||
return advisory;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mergeHash = _mergeHashCalculator.ComputeMergeHash(advisory);
|
||||
return new Advisory(
|
||||
advisory.AdvisoryKey,
|
||||
advisory.Title,
|
||||
advisory.Summary,
|
||||
advisory.Language,
|
||||
advisory.Published,
|
||||
advisory.Modified,
|
||||
advisory.Severity,
|
||||
advisory.ExploitKnown,
|
||||
advisory.Aliases,
|
||||
advisory.Credits,
|
||||
advisory.References,
|
||||
advisory.AffectedPackages,
|
||||
advisory.CvssMetrics,
|
||||
advisory.Provenance,
|
||||
advisory.Description,
|
||||
advisory.Cwes,
|
||||
advisory.CanonicalMetricId,
|
||||
mergeHash);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to compute merge hash for {AdvisoryKey}, continuing without hash", advisory.AdvisoryKey);
|
||||
return advisory;
|
||||
}
|
||||
}
|
||||
|
||||
private CanonicalMergeResult? ApplyCanonicalMergeIfNeeded(string canonicalKey, List<Advisory> inputs)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MergeHashBackfillService.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-020
|
||||
// Description: Shadow-write mode for computing merge_hash on existing advisories
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Merge.Identity;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Advisories;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for backfilling merge hashes on existing advisories without changing their identity.
|
||||
/// Runs in shadow-write mode: computes merge_hash and updates only that field.
|
||||
/// </summary>
|
||||
public sealed class MergeHashBackfillService
|
||||
{
|
||||
private readonly IAdvisoryStore _advisoryStore;
|
||||
private readonly IMergeHashCalculator _mergeHashCalculator;
|
||||
private readonly ILogger<MergeHashBackfillService> _logger;
|
||||
|
||||
public MergeHashBackfillService(
|
||||
IAdvisoryStore advisoryStore,
|
||||
IMergeHashCalculator mergeHashCalculator,
|
||||
ILogger<MergeHashBackfillService> logger)
|
||||
{
|
||||
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
|
||||
_mergeHashCalculator = mergeHashCalculator ?? throw new ArgumentNullException(nameof(mergeHashCalculator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfills merge hashes for all advisories that don't have one.
|
||||
/// </summary>
|
||||
/// <param name="batchSize">Number of advisories to process before yielding progress.</param>
|
||||
/// <param name="dryRun">If true, computes hashes but doesn't persist them.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Backfill result with statistics.</returns>
|
||||
public async Task<MergeHashBackfillResult> BackfillAsync(
|
||||
int batchSize = 100,
|
||||
bool dryRun = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var processed = 0;
|
||||
var updated = 0;
|
||||
var skipped = 0;
|
||||
var errors = 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting merge hash backfill (dryRun={DryRun}, batchSize={BatchSize})",
|
||||
dryRun, batchSize);
|
||||
|
||||
await foreach (var advisory in _advisoryStore.StreamAsync(cancellationToken))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
processed++;
|
||||
|
||||
// Skip if already has merge hash
|
||||
if (!string.IsNullOrEmpty(advisory.MergeHash))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mergeHash = _mergeHashCalculator.ComputeMergeHash(advisory);
|
||||
|
||||
if (!dryRun)
|
||||
{
|
||||
var enrichedAdvisory = CreateAdvisoryWithMergeHash(advisory, mergeHash);
|
||||
await _advisoryStore.UpsertAsync(enrichedAdvisory, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
updated++;
|
||||
|
||||
if (updated % batchSize == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Backfill progress: {Updated} updated, {Skipped} skipped, {Errors} errors (of {Processed} processed)",
|
||||
updated, skipped, errors, processed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors++;
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to compute/update merge hash for {AdvisoryKey}",
|
||||
advisory.AdvisoryKey);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var result = new MergeHashBackfillResult(
|
||||
TotalProcessed: processed,
|
||||
Updated: updated,
|
||||
Skipped: skipped,
|
||||
Errors: errors,
|
||||
DryRun: dryRun,
|
||||
Duration: stopwatch.Elapsed);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Merge hash backfill completed: {Updated} updated, {Skipped} skipped, {Errors} errors (of {Processed} processed) in {Duration}",
|
||||
result.Updated, result.Skipped, result.Errors, result.TotalProcessed, result.Duration);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes merge hash for a single advisory without persisting.
|
||||
/// Useful for testing or preview mode.
|
||||
/// </summary>
|
||||
public string ComputeMergeHash(Advisory advisory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(advisory);
|
||||
return _mergeHashCalculator.ComputeMergeHash(advisory);
|
||||
}
|
||||
|
||||
private static Advisory CreateAdvisoryWithMergeHash(Advisory source, string mergeHash)
|
||||
=> new(
|
||||
source.AdvisoryKey,
|
||||
source.Title,
|
||||
source.Summary,
|
||||
source.Language,
|
||||
source.Published,
|
||||
source.Modified,
|
||||
source.Severity,
|
||||
source.ExploitKnown,
|
||||
source.Aliases,
|
||||
source.Credits,
|
||||
source.References,
|
||||
source.AffectedPackages,
|
||||
source.CvssMetrics,
|
||||
source.Provenance,
|
||||
source.Description,
|
||||
source.Cwes,
|
||||
source.CanonicalMetricId,
|
||||
mergeHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a merge hash backfill operation.
|
||||
/// </summary>
|
||||
public sealed record MergeHashBackfillResult(
|
||||
int TotalProcessed,
|
||||
int Updated,
|
||||
int Skipped,
|
||||
int Errors,
|
||||
bool DryRun,
|
||||
TimeSpan Duration)
|
||||
{
|
||||
/// <summary>
|
||||
/// Percentage of advisories that were successfully updated.
|
||||
/// </summary>
|
||||
public double SuccessRate => TotalProcessed > 0
|
||||
? (double)(Updated + Skipped) / TotalProcessed * 100
|
||||
: 100;
|
||||
|
||||
/// <summary>
|
||||
/// Average time per advisory in milliseconds.
|
||||
/// </summary>
|
||||
public double AvgTimePerAdvisoryMs => TotalProcessed > 0
|
||||
? Duration.TotalMilliseconds / TotalProcessed
|
||||
: 0;
|
||||
}
|
||||
@@ -26,7 +26,8 @@ public sealed record Advisory
|
||||
provenance: Array.Empty<AdvisoryProvenance>(),
|
||||
description: null,
|
||||
cwes: Array.Empty<AdvisoryWeakness>(),
|
||||
canonicalMetricId: null);
|
||||
canonicalMetricId: null,
|
||||
mergeHash: null);
|
||||
|
||||
public Advisory(
|
||||
string advisoryKey,
|
||||
@@ -44,7 +45,8 @@ public sealed record Advisory
|
||||
IEnumerable<AdvisoryProvenance>? provenance,
|
||||
string? description = null,
|
||||
IEnumerable<AdvisoryWeakness>? cwes = null,
|
||||
string? canonicalMetricId = null)
|
||||
string? canonicalMetricId = null,
|
||||
string? mergeHash = null)
|
||||
: this(
|
||||
advisoryKey,
|
||||
title,
|
||||
@@ -62,7 +64,8 @@ public sealed record Advisory
|
||||
provenance,
|
||||
description,
|
||||
cwes,
|
||||
canonicalMetricId)
|
||||
canonicalMetricId,
|
||||
mergeHash)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -83,7 +86,8 @@ public sealed record Advisory
|
||||
IEnumerable<AdvisoryProvenance>? provenance,
|
||||
string? description = null,
|
||||
IEnumerable<AdvisoryWeakness>? cwes = null,
|
||||
string? canonicalMetricId = null)
|
||||
string? canonicalMetricId = null,
|
||||
string? mergeHash = null)
|
||||
{
|
||||
AdvisoryKey = Validation.EnsureNotNullOrWhiteSpace(advisoryKey, nameof(advisoryKey));
|
||||
Title = Validation.EnsureNotNullOrWhiteSpace(title, nameof(title));
|
||||
@@ -145,6 +149,8 @@ public sealed record Advisory
|
||||
.ThenBy(static p => p.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(static p => p.RecordedAt)
|
||||
.ToImmutableArray();
|
||||
|
||||
MergeHash = Validation.TrimToNull(mergeHash);
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
@@ -165,7 +171,8 @@ public sealed record Advisory
|
||||
ImmutableArray<AdvisoryProvenance> provenance,
|
||||
string? description,
|
||||
ImmutableArray<AdvisoryWeakness> cwes,
|
||||
string? canonicalMetricId)
|
||||
string? canonicalMetricId,
|
||||
string? mergeHash = null)
|
||||
: this(
|
||||
advisoryKey,
|
||||
title,
|
||||
@@ -183,7 +190,8 @@ public sealed record Advisory
|
||||
provenance.IsDefault ? null : provenance.AsEnumerable(),
|
||||
description,
|
||||
cwes.IsDefault ? null : cwes.AsEnumerable(),
|
||||
canonicalMetricId)
|
||||
canonicalMetricId,
|
||||
mergeHash)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -220,4 +228,10 @@ public sealed record Advisory
|
||||
public string? CanonicalMetricId { get; }
|
||||
|
||||
public ImmutableArray<AdvisoryProvenance> Provenance { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Semantic merge hash for provenance-scoped deduplication.
|
||||
/// Nullable during migration; computed from (CVE + PURL + version-range + CWE + patch-lineage).
|
||||
/// </summary>
|
||||
public string? MergeHash { get; }
|
||||
}
|
||||
|
||||
@@ -8,21 +8,22 @@
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `advisoryKey` | string | yes | Globally unique identifier selected by the merge layer (often a CVE/GHSA/vendor key). Stored lowercased unless vendor casing is significant. |
|
||||
| `title` | string | yes | Human readable title. Must be non-empty and trimmed. |
|
||||
| `summary` | string? | optional | Short description; trimmed to `null` when empty. |
|
||||
| `language` | string? | optional | ISO language code (lowercase). |
|
||||
| `published` | DateTimeOffset? | optional | UTC timestamp when vendor originally published. |
|
||||
| `modified` | DateTimeOffset? | optional | UTC timestamp when vendor last updated. |
|
||||
| `severity` | string? | optional | Normalized severity label (`critical`, `high`, etc.). |
|
||||
| `exploitKnown` | bool | yes | Whether KEV/other sources confirm active exploitation. |
|
||||
| `aliases` | string[] | yes | Sorted, de-duplicated list of normalized aliases (see [Alias Schemes](#alias-schemes)). |
|
||||
| `credits` | AdvisoryCredit[] | yes | Deterministically ordered acknowledgements (role + contact metadata). |
|
||||
| `references` | AdvisoryReference[] | yes | Deterministically ordered reference set. |
|
||||
| `affectedPackages` | AffectedPackage[] | yes | Deterministically ordered affected packages. |
|
||||
| `cvssMetrics` | CvssMetric[] | yes | Deterministically ordered CVSS metrics (v3, v4 first). |
|
||||
| `provenance` | AdvisoryProvenance[] | yes | Normalized provenance entries sorted by source then kind then recorded timestamp. |
|
||||
|
||||
| `advisoryKey` | string | yes | Globally unique identifier selected by the merge layer (often a CVE/GHSA/vendor key). Stored lowercased unless vendor casing is significant. |
|
||||
| `title` | string | yes | Human readable title. Must be non-empty and trimmed. |
|
||||
| `summary` | string? | optional | Short description; trimmed to `null` when empty. |
|
||||
| `language` | string? | optional | ISO language code (lowercase). |
|
||||
| `published` | DateTimeOffset? | optional | UTC timestamp when vendor originally published. |
|
||||
| `modified` | DateTimeOffset? | optional | UTC timestamp when vendor last updated. |
|
||||
| `severity` | string? | optional | Normalized severity label (`critical`, `high`, etc.). |
|
||||
| `exploitKnown` | bool | yes | Whether KEV/other sources confirm active exploitation. |
|
||||
| `aliases` | string[] | yes | Sorted, de-duplicated list of normalized aliases (see [Alias Schemes](#alias-schemes)). |
|
||||
| `credits` | AdvisoryCredit[] | yes | Deterministically ordered acknowledgements (role + contact metadata). |
|
||||
| `references` | AdvisoryReference[] | yes | Deterministically ordered reference set. |
|
||||
| `affectedPackages` | AffectedPackage[] | yes | Deterministically ordered affected packages. |
|
||||
| `cvssMetrics` | CvssMetric[] | yes | Deterministically ordered CVSS metrics (v3, v4 first). |
|
||||
| `provenance` | AdvisoryProvenance[] | yes | Normalized provenance entries sorted by source then kind then recorded timestamp. |
|
||||
| `mergeHash` | string? | optional | Semantic identity hash for deduplication (see [Merge Hash](#merge-hash)). |
|
||||
|
||||
### Invariants
|
||||
- Collections are immutable (`ImmutableArray<T>`) and always sorted deterministically.
|
||||
- `AdvisoryKey` and `Title` are mandatory and trimmed.
|
||||
@@ -36,27 +37,27 @@
|
||||
| `url` | string | yes | Absolute HTTP/HTTPS URL. |
|
||||
| `kind` | string? | optional | Categorized reference role (e.g. `advisory`, `patch`, `changelog`). |
|
||||
| `sourceTag` | string? | optional | Free-form tag identifying originating source. |
|
||||
| `summary` | string? | optional | Short description. |
|
||||
| `provenance` | AdvisoryProvenance | yes | Provenance entry describing how the reference was mapped. |
|
||||
|
||||
Deterministic ordering: by `url`, then `kind`, then `sourceTag`, then `provenance.RecordedAt`.
|
||||
|
||||
## AdvisoryCredit
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `displayName` | string | yes | Human-readable acknowledgement (reporter, maintainer, analyst, etc.). |
|
||||
| `role` | string? | optional | Normalized role token (lowercase with `_` separators). |
|
||||
| `contacts` | string[] | yes | Sorted set of vendor-supplied handles or URLs; may be empty. |
|
||||
| `provenance` | AdvisoryProvenance | yes | Provenance entry describing how the credit was captured. |
|
||||
|
||||
Deterministic ordering: by `role` (nulls first) then `displayName`.
|
||||
| `summary` | string? | optional | Short description. |
|
||||
| `provenance` | AdvisoryProvenance | yes | Provenance entry describing how the reference was mapped. |
|
||||
|
||||
Deterministic ordering: by `url`, then `kind`, then `sourceTag`, then `provenance.RecordedAt`.
|
||||
|
||||
## AdvisoryCredit
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `displayName` | string | yes | Human-readable acknowledgement (reporter, maintainer, analyst, etc.). |
|
||||
| `role` | string? | optional | Normalized role token (lowercase with `_` separators). |
|
||||
| `contacts` | string[] | yes | Sorted set of vendor-supplied handles or URLs; may be empty. |
|
||||
| `provenance` | AdvisoryProvenance | yes | Provenance entry describing how the credit was captured. |
|
||||
|
||||
Deterministic ordering: by `role` (nulls first) then `displayName`.
|
||||
|
||||
## AffectedPackage
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `type` | string | yes | Semantic type (`semver`, `rpm`, `deb`, `apk`, `purl`, `cpe`, etc.). Lowercase. |
|
||||
| `type` | string | yes | Semantic type (`semver`, `rpm`, `deb`, `apk`, `purl`, `cpe`, etc.). Lowercase. |
|
||||
| `identifier` | string | yes | Canonical identifier (package name, PURL, CPE, NEVRA, etc.). |
|
||||
| `platform` | string? | optional | Explicit platform / distro (e.g. `ubuntu`, `rhel-8`). |
|
||||
| `versionRanges` | AffectedVersionRange[] | yes | Deduplicated + sorted by introduced/fixed/last/expr/kind. |
|
||||
@@ -69,7 +70,7 @@ Deterministic ordering: packages sorted by `type`, then `identifier`, then `plat
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `rangeKind` | string | yes | Classification of range semantics (`semver`, `evr`, `nevra`, `apk`, `version`, `purl`). Lowercase. |
|
||||
| `rangeKind` | string | yes | Classification of range semantics (`semver`, `evr`, `nevra`, `apk`, `version`, `purl`). Lowercase. |
|
||||
| `introducedVersion` | string? | optional | Inclusive lower bound when impact begins. |
|
||||
| `fixedVersion` | string? | optional | Exclusive bounding version containing the fix. |
|
||||
| `lastAffectedVersion` | string? | optional | Inclusive upper bound when no fix exists. |
|
||||
@@ -95,18 +96,18 @@ Sorted by version then vector for determinism.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `source` | string | yes | Logical source identifier (`nvd`, `redhat`, `osv`, etc.). |
|
||||
| `kind` | string | yes | Operation performed (`fetch`, `parse`, `map`, `merge`, `enrich`). |
|
||||
| `value` | string? | optional | Free-form pipeline detail (parser identifier, rule set, resume cursor). |
|
||||
| `recordedAt` | DateTimeOffset | yes | UTC timestamp when provenance was captured. |
|
||||
| `fieldMask` | string[] | optional | Canonical field coverage expressed as lowercase masks (e.g. `affectedpackages[]`, `affectedpackages[].versionranges[]`). |
|
||||
| `source` | string | yes | Logical source identifier (`nvd`, `redhat`, `osv`, etc.). |
|
||||
| `kind` | string | yes | Operation performed (`fetch`, `parse`, `map`, `merge`, `enrich`). |
|
||||
| `value` | string? | optional | Free-form pipeline detail (parser identifier, rule set, resume cursor). |
|
||||
| `recordedAt` | DateTimeOffset | yes | UTC timestamp when provenance was captured. |
|
||||
| `fieldMask` | string[] | optional | Canonical field coverage expressed as lowercase masks (e.g. `affectedpackages[]`, `affectedpackages[].versionranges[]`). |
|
||||
|
||||
### Provenance Mask Expectations
|
||||
Each canonical field is expected to carry at least one provenance entry derived from the
|
||||
responsible pipeline stage. Populate `fieldMask` with the lowercase canonical mask(s) describing the
|
||||
covered field(s); downstream metrics and resume helpers rely on this signal to reason about
|
||||
coverage. When aggregating provenance from subcomponents (e.g., affected package ranges), merge code
|
||||
should ensure:
|
||||
Each canonical field is expected to carry at least one provenance entry derived from the
|
||||
responsible pipeline stage. Populate `fieldMask` with the lowercase canonical mask(s) describing the
|
||||
covered field(s); downstream metrics and resume helpers rely on this signal to reason about
|
||||
coverage. When aggregating provenance from subcomponents (e.g., affected package ranges), merge code
|
||||
should ensure:
|
||||
|
||||
- Advisory level provenance documents the source document and merge actions.
|
||||
- References, packages, ranges, and metrics each include their own provenance entry reflecting
|
||||
@@ -142,3 +143,112 @@ Supported alias scheme prefixes:
|
||||
|
||||
The registry exposed via `AliasSchemes` and `AliasSchemeRegistry` can be used to validate aliases and
|
||||
drive downstream conditionals without re-implementing pattern rules.
|
||||
|
||||
## Merge Hash
|
||||
|
||||
The merge hash is a deterministic semantic identity hash that enables provenance-scoped deduplication.
|
||||
Unlike content hashing (which changes when any field changes), merge hash is computed from identity
|
||||
components only, allowing the same CVE from different sources (Debian, RHEL, NVD, etc.) to produce
|
||||
identical hashes when semantically equivalent.
|
||||
|
||||
### Purpose
|
||||
|
||||
- **Deduplication**: Identify equivalent advisories across multiple sources
|
||||
- **Stable Identity**: Hash remains constant despite variations in non-identity fields (title, description, CVSS scores)
|
||||
- **Source Independence**: Same CVE affecting the same package produces the same hash regardless of source
|
||||
|
||||
### Hash Format
|
||||
|
||||
The merge hash is a hex-encoded SHA256 hash prefixed with `sha256:`:
|
||||
|
||||
```
|
||||
sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
|
||||
```
|
||||
|
||||
Total length: 71 characters (`sha256:` prefix + 64 hex characters).
|
||||
|
||||
### Identity Components
|
||||
|
||||
The merge hash is computed from the following canonical string format:
|
||||
|
||||
```
|
||||
CVE:{cve}|AFFECTS:{affects_key}|VERSION:{version_range}|CWE:{cwes}|LINEAGE:{patch_lineage}
|
||||
```
|
||||
|
||||
| Component | Source | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `cve` | Advisory key or CVE alias | Normalized to uppercase (e.g., `CVE-2024-1234`) |
|
||||
| `affects_key` | First affected package identifier | PURL or CPE, normalized to canonical form |
|
||||
| `version_range` | First affected package version ranges | Canonical interval notation, sorted |
|
||||
| `cwes` | Advisory weaknesses | Uppercase, sorted numerically, comma-joined |
|
||||
| `patch_lineage` | Patch references | Extracted commit SHA or PATCH-ID (optional) |
|
||||
|
||||
### Normalization Rules
|
||||
|
||||
#### CVE Normalization
|
||||
|
||||
- Uppercase: `cve-2024-1234` → `CVE-2024-1234`
|
||||
- Numeric-only input prefixed: `2024-1234` → `CVE-2024-1234`
|
||||
- Non-CVE advisories use advisory key as-is
|
||||
|
||||
#### PURL Normalization
|
||||
|
||||
- Type lowercase: `pkg:NPM/lodash` → `pkg:npm/lodash`
|
||||
- Namespace/name lowercase: `pkg:npm/LODASH` → `pkg:npm/lodash`
|
||||
- Strip non-identity qualifiers: `?arch=amd64`, `?checksum=...`, `?platform=linux`
|
||||
- Preserve version: `@4.17.0` retained
|
||||
|
||||
#### CPE Normalization
|
||||
|
||||
- Convert CPE 2.2 to 2.3: `cpe:/a:vendor:product:1.0` → `cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*`
|
||||
- Lowercase all components
|
||||
- Normalize wildcards: `ANY` → `*`, `NA` → `-`
|
||||
|
||||
#### Version Range Normalization
|
||||
|
||||
- Interval to comparison: `[1.0.0, 2.0.0)` → `>=1.0.0,<2.0.0`
|
||||
- Trim whitespace: `< 1.5.0` → `<1.5.0`
|
||||
- Fixed notation: `fixed: 1.5.1` → `>=1.5.1`
|
||||
- Multiple constraints sorted and comma-joined
|
||||
|
||||
#### CWE Normalization
|
||||
|
||||
- Uppercase: `cwe-79` → `CWE-79`
|
||||
- Sort numerically: `CWE-89,CWE-79` → `CWE-79,CWE-89`
|
||||
- Deduplicate
|
||||
- Comma-joined output
|
||||
|
||||
#### Patch Lineage Normalization
|
||||
|
||||
- Extract 40-character SHA from GitHub/GitLab URLs
|
||||
- Extract SHA from `commit {sha}` or `backport of {sha}` patterns
|
||||
- Normalize PATCH-ID to uppercase: `patch-12345` → `PATCH-12345`
|
||||
- Returns `null` for unrecognized formats (produces empty string in canonical form)
|
||||
|
||||
### Multi-Package Advisories
|
||||
|
||||
When an advisory affects multiple packages, the merge hash is computed from the first affected package.
|
||||
Use `ComputeMergeHash(advisory, affectedPackage)` to compute per-package hashes for deduplication
|
||||
at the package level.
|
||||
|
||||
### Implementation
|
||||
|
||||
The merge hash is computed by `MergeHashCalculator` in `StellaOps.Concelier.Merge.Identity`:
|
||||
|
||||
```csharp
|
||||
var calculator = new MergeHashCalculator();
|
||||
var hash = calculator.ComputeMergeHash(advisory);
|
||||
// or for specific package:
|
||||
var packageHash = calculator.ComputeMergeHash(advisory, affectedPackage);
|
||||
```
|
||||
|
||||
### Migration
|
||||
|
||||
During migration, the `mergeHash` field is nullable. Use `MergeHashShadowWriteService` to backfill
|
||||
hashes for existing advisories:
|
||||
|
||||
```csharp
|
||||
var shadowWriter = new MergeHashShadowWriteService(advisoryStore, calculator, logger);
|
||||
var result = await shadowWriter.BackfillAllAsync(cancellationToken);
|
||||
// result.Updated: count of advisories updated with merge hashes
|
||||
```
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Concelier Migration 008: Sync Ledger for Federation
|
||||
-- Sprint: SPRINT_8200_0014_0001_DB_sync_ledger_schema
|
||||
-- Task: SYNC-8200-002
|
||||
-- Creates sync_ledger and site_policy tables for federation cursor tracking
|
||||
|
||||
-- Helper function for updated_at triggers
|
||||
CREATE OR REPLACE FUNCTION vuln.update_timestamp()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Track federation sync state per remote site
|
||||
CREATE TABLE IF NOT EXISTS vuln.sync_ledger (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id TEXT NOT NULL, -- Remote site identifier (e.g., "site-us-west", "airgap-dc2")
|
||||
cursor TEXT NOT NULL, -- Opaque cursor (usually ISO8601 timestamp#sequence)
|
||||
bundle_hash TEXT NOT NULL, -- SHA256 of imported bundle
|
||||
items_count INT NOT NULL DEFAULT 0, -- Number of items in bundle
|
||||
signed_at TIMESTAMPTZ NOT NULL, -- When bundle was signed by remote
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uq_sync_ledger_site_cursor UNIQUE (site_id, cursor),
|
||||
CONSTRAINT uq_sync_ledger_bundle UNIQUE (bundle_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_ledger_site ON vuln.sync_ledger(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_ledger_site_time ON vuln.sync_ledger(site_id, signed_at DESC);
|
||||
|
||||
COMMENT ON TABLE vuln.sync_ledger IS 'Federation sync cursor tracking per remote site';
|
||||
COMMENT ON COLUMN vuln.sync_ledger.cursor IS 'Position marker for incremental sync (monotonically increasing)';
|
||||
COMMENT ON COLUMN vuln.sync_ledger.site_id IS 'Remote site identifier for federation sync';
|
||||
COMMENT ON COLUMN vuln.sync_ledger.bundle_hash IS 'SHA256 hash of imported bundle for deduplication';
|
||||
|
||||
-- Site federation policies
|
||||
CREATE TABLE IF NOT EXISTS vuln.site_policy (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT,
|
||||
allowed_sources TEXT[] NOT NULL DEFAULT '{}', -- Empty = allow all
|
||||
denied_sources TEXT[] NOT NULL DEFAULT '{}',
|
||||
max_bundle_size_mb INT NOT NULL DEFAULT 100,
|
||||
max_items_per_bundle INT NOT NULL DEFAULT 10000,
|
||||
require_signature BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
allowed_signers TEXT[] NOT NULL DEFAULT '{}', -- Key IDs or issuers
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_site_policy_enabled ON vuln.site_policy(enabled) WHERE enabled = TRUE;
|
||||
|
||||
COMMENT ON TABLE vuln.site_policy IS 'Per-site federation governance policies';
|
||||
COMMENT ON COLUMN vuln.site_policy.allowed_sources IS 'Source keys to allow; empty array allows all sources';
|
||||
COMMENT ON COLUMN vuln.site_policy.denied_sources IS 'Source keys to deny; takes precedence over allowed';
|
||||
COMMENT ON COLUMN vuln.site_policy.allowed_signers IS 'Signing key IDs or issuer patterns allowed for bundle verification';
|
||||
|
||||
-- Trigger for automatic updated_at
|
||||
CREATE TRIGGER trg_site_policy_updated
|
||||
BEFORE UPDATE ON vuln.site_policy
|
||||
FOR EACH ROW EXECUTE FUNCTION vuln.update_timestamp();
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Concelier Migration 009: Advisory Canonical Table
|
||||
-- Sprint: SPRINT_8200_0012_0002_DB_canonical_source_edge_schema
|
||||
-- Task: SCHEMA-8200-003
|
||||
-- Creates deduplicated canonical advisories with merge_hash
|
||||
|
||||
-- Deduplicated canonical advisory records
|
||||
CREATE TABLE IF NOT EXISTS vuln.advisory_canonical (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Merge key components (used to compute merge_hash)
|
||||
cve TEXT NOT NULL,
|
||||
affects_key TEXT NOT NULL, -- normalized purl or cpe
|
||||
version_range JSONB, -- structured: { introduced, fixed, last_affected }
|
||||
weakness TEXT[] NOT NULL DEFAULT '{}', -- sorted CWE array
|
||||
|
||||
-- Computed identity
|
||||
merge_hash TEXT NOT NULL, -- SHA256 of normalized (cve|affects|range|weakness|lineage)
|
||||
|
||||
-- Metadata
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'stub', 'withdrawn')),
|
||||
severity TEXT CHECK (severity IN ('critical', 'high', 'medium', 'low', 'none', 'unknown')),
|
||||
epss_score NUMERIC(5,4), -- EPSS probability (0.0000-1.0000)
|
||||
exploit_known BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Content (for stub degradation)
|
||||
title TEXT,
|
||||
summary TEXT,
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT uq_advisory_canonical_merge_hash UNIQUE (merge_hash)
|
||||
);
|
||||
|
||||
-- Primary lookup indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_advisory_canonical_cve ON vuln.advisory_canonical(cve);
|
||||
CREATE INDEX IF NOT EXISTS idx_advisory_canonical_affects ON vuln.advisory_canonical(affects_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_advisory_canonical_merge_hash ON vuln.advisory_canonical(merge_hash);
|
||||
|
||||
-- Filtered indexes for common queries
|
||||
CREATE INDEX IF NOT EXISTS idx_advisory_canonical_status ON vuln.advisory_canonical(status) WHERE status = 'active';
|
||||
CREATE INDEX IF NOT EXISTS idx_advisory_canonical_severity ON vuln.advisory_canonical(severity) WHERE severity IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_advisory_canonical_exploit ON vuln.advisory_canonical(exploit_known) WHERE exploit_known = TRUE;
|
||||
|
||||
-- Time-based index for incremental queries
|
||||
CREATE INDEX IF NOT EXISTS idx_advisory_canonical_updated ON vuln.advisory_canonical(updated_at DESC);
|
||||
|
||||
-- Trigger for automatic updated_at
|
||||
CREATE TRIGGER trg_advisory_canonical_updated
|
||||
BEFORE UPDATE ON vuln.advisory_canonical
|
||||
FOR EACH ROW EXECUTE FUNCTION vuln.update_timestamp();
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE vuln.advisory_canonical IS 'Deduplicated canonical advisories with semantic merge_hash';
|
||||
COMMENT ON COLUMN vuln.advisory_canonical.merge_hash IS 'Deterministic hash of (cve, affects_key, version_range, weakness, patch_lineage)';
|
||||
COMMENT ON COLUMN vuln.advisory_canonical.affects_key IS 'Normalized PURL or CPE identifying the affected package';
|
||||
COMMENT ON COLUMN vuln.advisory_canonical.status IS 'active=full record, stub=minimal for low interest, withdrawn=no longer valid';
|
||||
COMMENT ON COLUMN vuln.advisory_canonical.epss_score IS 'EPSS exploit prediction probability (0.0000-1.0000)';
|
||||
@@ -0,0 +1,64 @@
|
||||
-- Concelier Migration 010: Advisory Source Edge Table
|
||||
-- Sprint: SPRINT_8200_0012_0002_DB_canonical_source_edge_schema
|
||||
-- Task: SCHEMA-8200-004
|
||||
-- Creates source edge linking canonical advisories to source documents
|
||||
|
||||
-- Source edge linking canonical advisory to source documents
|
||||
CREATE TABLE IF NOT EXISTS vuln.advisory_source_edge (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Relationships
|
||||
canonical_id UUID NOT NULL REFERENCES vuln.advisory_canonical(id) ON DELETE CASCADE,
|
||||
source_id UUID NOT NULL REFERENCES vuln.sources(id) ON DELETE RESTRICT,
|
||||
|
||||
-- Source document
|
||||
source_advisory_id TEXT NOT NULL, -- vendor's advisory ID (DSA-5678, RHSA-2024:1234)
|
||||
source_doc_hash TEXT NOT NULL, -- SHA256 of raw source document
|
||||
|
||||
-- VEX-style status
|
||||
vendor_status TEXT CHECK (vendor_status IN (
|
||||
'affected', 'not_affected', 'fixed', 'under_investigation'
|
||||
)),
|
||||
|
||||
-- Precedence (lower = higher priority)
|
||||
precedence_rank INT NOT NULL DEFAULT 100,
|
||||
|
||||
-- DSSE signature envelope
|
||||
dsse_envelope JSONB, -- { payloadType, payload, signatures[] }
|
||||
|
||||
-- Content snapshot
|
||||
raw_payload JSONB, -- original advisory document
|
||||
|
||||
-- Audit
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT uq_advisory_source_edge_unique
|
||||
UNIQUE (canonical_id, source_id, source_doc_hash)
|
||||
);
|
||||
|
||||
-- Primary lookup indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_source_edge_canonical ON vuln.advisory_source_edge(canonical_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_source_edge_source ON vuln.advisory_source_edge(source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_source_edge_advisory_id ON vuln.advisory_source_edge(source_advisory_id);
|
||||
|
||||
-- Join optimization index
|
||||
CREATE INDEX IF NOT EXISTS idx_source_edge_canonical_source ON vuln.advisory_source_edge(canonical_id, source_id);
|
||||
|
||||
-- Time-based index for incremental queries
|
||||
CREATE INDEX IF NOT EXISTS idx_source_edge_fetched ON vuln.advisory_source_edge(fetched_at DESC);
|
||||
|
||||
-- GIN index for JSONB queries on dsse_envelope
|
||||
CREATE INDEX IF NOT EXISTS idx_source_edge_dsse_gin ON vuln.advisory_source_edge
|
||||
USING GIN (dsse_envelope jsonb_path_ops);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE vuln.advisory_source_edge IS 'Links canonical advisories to source documents with signatures';
|
||||
COMMENT ON COLUMN vuln.advisory_source_edge.canonical_id IS 'Reference to deduplicated canonical advisory';
|
||||
COMMENT ON COLUMN vuln.advisory_source_edge.source_id IS 'Reference to feed source';
|
||||
COMMENT ON COLUMN vuln.advisory_source_edge.source_advisory_id IS 'Vendor advisory ID (e.g., DSA-5678, RHSA-2024:1234)';
|
||||
COMMENT ON COLUMN vuln.advisory_source_edge.precedence_rank IS 'Source priority: vendor=10, distro=20, osv=30, nvd=40';
|
||||
COMMENT ON COLUMN vuln.advisory_source_edge.dsse_envelope IS 'DSSE envelope with signature over raw_payload';
|
||||
COMMENT ON COLUMN vuln.advisory_source_edge.vendor_status IS 'VEX-style status from source';
|
||||
@@ -0,0 +1,116 @@
|
||||
-- Concelier Migration 011: Canonical Helper Functions
|
||||
-- Sprint: SPRINT_8200_0012_0002_DB_canonical_source_edge_schema
|
||||
-- Task: SCHEMA-8200-005
|
||||
-- Creates helper functions for canonical advisory operations
|
||||
|
||||
-- Function to get canonical by merge_hash (most common lookup)
|
||||
CREATE OR REPLACE FUNCTION vuln.get_canonical_by_hash(p_merge_hash TEXT)
|
||||
RETURNS vuln.advisory_canonical
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT * FROM vuln.advisory_canonical
|
||||
WHERE merge_hash = p_merge_hash;
|
||||
$$;
|
||||
|
||||
-- Function to get all source edges for a canonical
|
||||
CREATE OR REPLACE FUNCTION vuln.get_source_edges(p_canonical_id UUID)
|
||||
RETURNS SETOF vuln.advisory_source_edge
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT * FROM vuln.advisory_source_edge
|
||||
WHERE canonical_id = p_canonical_id
|
||||
ORDER BY precedence_rank ASC, fetched_at DESC;
|
||||
$$;
|
||||
|
||||
-- Function to upsert canonical with merge_hash dedup
|
||||
CREATE OR REPLACE FUNCTION vuln.upsert_canonical(
|
||||
p_cve TEXT,
|
||||
p_affects_key TEXT,
|
||||
p_version_range JSONB,
|
||||
p_weakness TEXT[],
|
||||
p_merge_hash TEXT,
|
||||
p_severity TEXT DEFAULT NULL,
|
||||
p_epss_score NUMERIC DEFAULT NULL,
|
||||
p_exploit_known BOOLEAN DEFAULT FALSE,
|
||||
p_title TEXT DEFAULT NULL,
|
||||
p_summary TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS UUID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO vuln.advisory_canonical (
|
||||
cve, affects_key, version_range, weakness, merge_hash,
|
||||
severity, epss_score, exploit_known, title, summary
|
||||
)
|
||||
VALUES (
|
||||
p_cve, p_affects_key, p_version_range, p_weakness, p_merge_hash,
|
||||
p_severity, p_epss_score, p_exploit_known, p_title, p_summary
|
||||
)
|
||||
ON CONFLICT (merge_hash) DO UPDATE SET
|
||||
severity = COALESCE(EXCLUDED.severity, vuln.advisory_canonical.severity),
|
||||
epss_score = COALESCE(EXCLUDED.epss_score, vuln.advisory_canonical.epss_score),
|
||||
exploit_known = EXCLUDED.exploit_known OR vuln.advisory_canonical.exploit_known,
|
||||
title = COALESCE(EXCLUDED.title, vuln.advisory_canonical.title),
|
||||
summary = COALESCE(EXCLUDED.summary, vuln.advisory_canonical.summary),
|
||||
updated_at = NOW()
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN v_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Function to add source edge with dedup
|
||||
CREATE OR REPLACE FUNCTION vuln.add_source_edge(
|
||||
p_canonical_id UUID,
|
||||
p_source_id UUID,
|
||||
p_source_advisory_id TEXT,
|
||||
p_source_doc_hash TEXT,
|
||||
p_vendor_status TEXT DEFAULT NULL,
|
||||
p_precedence_rank INT DEFAULT 100,
|
||||
p_dsse_envelope JSONB DEFAULT NULL,
|
||||
p_raw_payload JSONB DEFAULT NULL,
|
||||
p_fetched_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
RETURNS UUID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO vuln.advisory_source_edge (
|
||||
canonical_id, source_id, source_advisory_id, source_doc_hash,
|
||||
vendor_status, precedence_rank, dsse_envelope, raw_payload, fetched_at
|
||||
)
|
||||
VALUES (
|
||||
p_canonical_id, p_source_id, p_source_advisory_id, p_source_doc_hash,
|
||||
p_vendor_status, p_precedence_rank, p_dsse_envelope, p_raw_payload, p_fetched_at
|
||||
)
|
||||
ON CONFLICT (canonical_id, source_id, source_doc_hash) DO UPDATE SET
|
||||
vendor_status = COALESCE(EXCLUDED.vendor_status, vuln.advisory_source_edge.vendor_status),
|
||||
precedence_rank = LEAST(EXCLUDED.precedence_rank, vuln.advisory_source_edge.precedence_rank),
|
||||
dsse_envelope = COALESCE(EXCLUDED.dsse_envelope, vuln.advisory_source_edge.dsse_envelope),
|
||||
raw_payload = COALESCE(EXCLUDED.raw_payload, vuln.advisory_source_edge.raw_payload)
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN v_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Function to count active canonicals by CVE prefix
|
||||
CREATE OR REPLACE FUNCTION vuln.count_canonicals_by_cve_year(p_year INT)
|
||||
RETURNS BIGINT
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT COUNT(*) FROM vuln.advisory_canonical
|
||||
WHERE cve LIKE 'CVE-' || p_year::TEXT || '-%'
|
||||
AND status = 'active';
|
||||
$$;
|
||||
|
||||
-- Comments
|
||||
COMMENT ON FUNCTION vuln.get_canonical_by_hash(TEXT) IS 'Lookup canonical advisory by merge_hash';
|
||||
COMMENT ON FUNCTION vuln.get_source_edges(UUID) IS 'Get all source edges for a canonical, ordered by precedence';
|
||||
COMMENT ON FUNCTION vuln.upsert_canonical IS 'Insert or update canonical advisory with merge_hash deduplication';
|
||||
COMMENT ON FUNCTION vuln.add_source_edge IS 'Add source edge with deduplication by (canonical, source, doc_hash)';
|
||||
@@ -0,0 +1,144 @@
|
||||
-- Concelier Migration 012: Populate advisory_canonical table
|
||||
-- Sprint: SPRINT_8200_0012_0002_DB_canonical_source_edge_schema
|
||||
-- Task: SCHEMA-8200-012
|
||||
-- Populates advisory_canonical from existing advisories with placeholder merge_hash
|
||||
-- NOTE: merge_hash will be backfilled by application-side MergeHashBackfillService
|
||||
|
||||
-- Populate advisory_canonical from existing advisories
|
||||
-- Each advisory + affected package combination becomes a canonical record
|
||||
INSERT INTO vuln.advisory_canonical (
|
||||
id,
|
||||
cve,
|
||||
affects_key,
|
||||
version_range,
|
||||
weakness,
|
||||
merge_hash,
|
||||
status,
|
||||
severity,
|
||||
epss_score,
|
||||
exploit_known,
|
||||
title,
|
||||
summary,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid() AS id,
|
||||
COALESCE(
|
||||
-- Try to get CVE from aliases
|
||||
(SELECT alias_value FROM vuln.advisory_aliases
|
||||
WHERE advisory_id = a.id AND alias_type = 'CVE'
|
||||
ORDER BY is_primary DESC LIMIT 1),
|
||||
-- Fall back to primary_vuln_id
|
||||
a.primary_vuln_id
|
||||
) AS cve,
|
||||
COALESCE(
|
||||
-- Prefer PURL if available
|
||||
aa.purl,
|
||||
-- Otherwise construct from ecosystem/package
|
||||
CASE
|
||||
WHEN aa.ecosystem IS NOT NULL AND aa.package_name IS NOT NULL
|
||||
THEN 'pkg:' || lower(aa.ecosystem) || '/' || aa.package_name
|
||||
ELSE 'unknown:' || a.id::text
|
||||
END
|
||||
) AS affects_key,
|
||||
aa.version_range AS version_range,
|
||||
-- Aggregate CWE IDs into sorted array
|
||||
COALESCE(
|
||||
(SELECT array_agg(DISTINCT upper(w.cwe_id) ORDER BY upper(w.cwe_id))
|
||||
FROM vuln.advisory_weaknesses w
|
||||
WHERE w.advisory_id = a.id),
|
||||
'{}'::text[]
|
||||
) AS weakness,
|
||||
-- Placeholder merge_hash - will be backfilled by application
|
||||
'PLACEHOLDER_' || a.id::text || '_' || COALESCE(aa.id::text, 'noaffects') AS merge_hash,
|
||||
CASE
|
||||
WHEN a.withdrawn_at IS NOT NULL THEN 'withdrawn'
|
||||
ELSE 'active'
|
||||
END AS status,
|
||||
a.severity,
|
||||
-- EPSS score if available from KEV
|
||||
(SELECT CASE WHEN kf.known_ransomware_use THEN 0.95 ELSE NULL END
|
||||
FROM vuln.kev_flags kf
|
||||
WHERE kf.advisory_id = a.id
|
||||
LIMIT 1) AS epss_score,
|
||||
-- exploit_known from KEV flags
|
||||
EXISTS(SELECT 1 FROM vuln.kev_flags kf WHERE kf.advisory_id = a.id) AS exploit_known,
|
||||
a.title,
|
||||
a.summary,
|
||||
a.created_at,
|
||||
NOW() AS updated_at
|
||||
FROM vuln.advisories a
|
||||
LEFT JOIN vuln.advisory_affected aa ON aa.advisory_id = a.id
|
||||
WHERE NOT EXISTS (
|
||||
-- Skip if already migrated (idempotent)
|
||||
SELECT 1 FROM vuln.advisory_canonical c
|
||||
WHERE c.merge_hash LIKE 'PLACEHOLDER_' || a.id::text || '%'
|
||||
)
|
||||
ON CONFLICT (merge_hash) DO NOTHING;
|
||||
|
||||
-- Handle advisories without affected packages
|
||||
INSERT INTO vuln.advisory_canonical (
|
||||
id,
|
||||
cve,
|
||||
affects_key,
|
||||
version_range,
|
||||
weakness,
|
||||
merge_hash,
|
||||
status,
|
||||
severity,
|
||||
exploit_known,
|
||||
title,
|
||||
summary,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid() AS id,
|
||||
COALESCE(
|
||||
(SELECT alias_value FROM vuln.advisory_aliases
|
||||
WHERE advisory_id = a.id AND alias_type = 'CVE'
|
||||
ORDER BY is_primary DESC LIMIT 1),
|
||||
a.primary_vuln_id
|
||||
) AS cve,
|
||||
'unknown:' || a.primary_vuln_id AS affects_key,
|
||||
NULL AS version_range,
|
||||
COALESCE(
|
||||
(SELECT array_agg(DISTINCT upper(w.cwe_id) ORDER BY upper(w.cwe_id))
|
||||
FROM vuln.advisory_weaknesses w
|
||||
WHERE w.advisory_id = a.id),
|
||||
'{}'::text[]
|
||||
) AS weakness,
|
||||
'PLACEHOLDER_' || a.id::text || '_noaffects' AS merge_hash,
|
||||
CASE
|
||||
WHEN a.withdrawn_at IS NOT NULL THEN 'withdrawn'
|
||||
ELSE 'active'
|
||||
END AS status,
|
||||
a.severity,
|
||||
EXISTS(SELECT 1 FROM vuln.kev_flags kf WHERE kf.advisory_id = a.id) AS exploit_known,
|
||||
a.title,
|
||||
a.summary,
|
||||
a.created_at,
|
||||
NOW() AS updated_at
|
||||
FROM vuln.advisories a
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM vuln.advisory_affected aa WHERE aa.advisory_id = a.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM vuln.advisory_canonical c
|
||||
WHERE c.merge_hash LIKE 'PLACEHOLDER_' || a.id::text || '%'
|
||||
)
|
||||
ON CONFLICT (merge_hash) DO NOTHING;
|
||||
|
||||
-- Log migration progress
|
||||
DO $$
|
||||
DECLARE
|
||||
canonical_count BIGINT;
|
||||
placeholder_count BIGINT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO canonical_count FROM vuln.advisory_canonical;
|
||||
SELECT COUNT(*) INTO placeholder_count FROM vuln.advisory_canonical WHERE merge_hash LIKE 'PLACEHOLDER_%';
|
||||
|
||||
RAISE NOTICE 'Migration 012 complete: % canonical records, % with placeholder hash (need backfill)',
|
||||
canonical_count, placeholder_count;
|
||||
END $$;
|
||||
@@ -0,0 +1,129 @@
|
||||
-- Concelier Migration 013: Populate advisory_source_edge table
|
||||
-- Sprint: SPRINT_8200_0012_0002_DB_canonical_source_edge_schema
|
||||
-- Task: SCHEMA-8200-013
|
||||
-- Creates source edges from existing advisory snapshots and provenance data
|
||||
|
||||
-- Create source edges from advisory snapshots
|
||||
INSERT INTO vuln.advisory_source_edge (
|
||||
id,
|
||||
canonical_id,
|
||||
source_id,
|
||||
source_advisory_id,
|
||||
source_doc_hash,
|
||||
vendor_status,
|
||||
precedence_rank,
|
||||
dsse_envelope,
|
||||
raw_payload,
|
||||
fetched_at,
|
||||
created_at
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid() AS id,
|
||||
c.id AS canonical_id,
|
||||
a.source_id AS source_id,
|
||||
a.advisory_key AS source_advisory_id,
|
||||
snap.content_hash AS source_doc_hash,
|
||||
CASE
|
||||
WHEN a.withdrawn_at IS NOT NULL THEN 'not_affected'
|
||||
ELSE 'affected'
|
||||
END AS vendor_status,
|
||||
COALESCE(s.priority, 100) AS precedence_rank,
|
||||
NULL AS dsse_envelope, -- DSSE signatures added later
|
||||
a.raw_payload AS raw_payload,
|
||||
snap.created_at AS fetched_at,
|
||||
NOW() AS created_at
|
||||
FROM vuln.advisory_canonical c
|
||||
JOIN vuln.advisories a ON (
|
||||
-- Match by CVE
|
||||
c.cve = a.primary_vuln_id
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM vuln.advisory_aliases al
|
||||
WHERE al.advisory_id = a.id AND al.alias_value = c.cve
|
||||
)
|
||||
)
|
||||
JOIN vuln.advisory_snapshots snap ON snap.advisory_key = a.advisory_key
|
||||
JOIN vuln.feed_snapshots fs ON fs.id = snap.feed_snapshot_id
|
||||
LEFT JOIN vuln.sources s ON s.id = a.source_id
|
||||
WHERE a.source_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
-- Skip if already migrated (idempotent)
|
||||
SELECT 1 FROM vuln.advisory_source_edge e
|
||||
WHERE e.canonical_id = c.id
|
||||
AND e.source_id = a.source_id
|
||||
AND e.source_doc_hash = snap.content_hash
|
||||
)
|
||||
ON CONFLICT (canonical_id, source_id, source_doc_hash) DO NOTHING;
|
||||
|
||||
-- Create source edges directly from advisories (for those without snapshots)
|
||||
INSERT INTO vuln.advisory_source_edge (
|
||||
id,
|
||||
canonical_id,
|
||||
source_id,
|
||||
source_advisory_id,
|
||||
source_doc_hash,
|
||||
vendor_status,
|
||||
precedence_rank,
|
||||
dsse_envelope,
|
||||
raw_payload,
|
||||
fetched_at,
|
||||
created_at
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid() AS id,
|
||||
c.id AS canonical_id,
|
||||
a.source_id AS source_id,
|
||||
a.advisory_key AS source_advisory_id,
|
||||
-- Generate hash from raw_payload if available, otherwise use advisory_key
|
||||
COALESCE(
|
||||
encode(sha256(a.raw_payload::text::bytea), 'hex'),
|
||||
encode(sha256(a.advisory_key::bytea), 'hex')
|
||||
) AS source_doc_hash,
|
||||
CASE
|
||||
WHEN a.withdrawn_at IS NOT NULL THEN 'not_affected'
|
||||
ELSE 'affected'
|
||||
END AS vendor_status,
|
||||
COALESCE(s.priority, 100) AS precedence_rank,
|
||||
NULL AS dsse_envelope,
|
||||
a.raw_payload AS raw_payload,
|
||||
a.created_at AS fetched_at,
|
||||
NOW() AS created_at
|
||||
FROM vuln.advisory_canonical c
|
||||
JOIN vuln.advisories a ON (
|
||||
c.cve = a.primary_vuln_id
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM vuln.advisory_aliases al
|
||||
WHERE al.advisory_id = a.id AND al.alias_value = c.cve
|
||||
)
|
||||
)
|
||||
LEFT JOIN vuln.sources s ON s.id = a.source_id
|
||||
WHERE a.source_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
-- Only for advisories without snapshots
|
||||
SELECT 1 FROM vuln.advisory_snapshots snap
|
||||
WHERE snap.advisory_key = a.advisory_key
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM vuln.advisory_source_edge e
|
||||
WHERE e.canonical_id = c.id AND e.source_id = a.source_id
|
||||
)
|
||||
ON CONFLICT (canonical_id, source_id, source_doc_hash) DO NOTHING;
|
||||
|
||||
-- Log migration progress
|
||||
DO $$
|
||||
DECLARE
|
||||
edge_count BIGINT;
|
||||
canonical_with_edges BIGINT;
|
||||
avg_edges NUMERIC;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO edge_count FROM vuln.advisory_source_edge;
|
||||
SELECT COUNT(DISTINCT canonical_id) INTO canonical_with_edges FROM vuln.advisory_source_edge;
|
||||
|
||||
IF canonical_with_edges > 0 THEN
|
||||
avg_edges := edge_count::numeric / canonical_with_edges;
|
||||
ELSE
|
||||
avg_edges := 0;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Migration 013 complete: % source edges, % canonicals with edges, avg %.2f edges/canonical',
|
||||
edge_count, canonical_with_edges, avg_edges;
|
||||
END $$;
|
||||
@@ -0,0 +1,165 @@
|
||||
-- Concelier Migration 014: Verification queries for canonical migration
|
||||
-- Sprint: SPRINT_8200_0012_0002_DB_canonical_source_edge_schema
|
||||
-- Task: SCHEMA-8200-014
|
||||
-- Verification queries to compare record counts and data integrity
|
||||
|
||||
-- Verification Report
|
||||
DO $$
|
||||
DECLARE
|
||||
-- Source counts
|
||||
advisory_count BIGINT;
|
||||
affected_count BIGINT;
|
||||
alias_count BIGINT;
|
||||
weakness_count BIGINT;
|
||||
kev_count BIGINT;
|
||||
snapshot_count BIGINT;
|
||||
source_count BIGINT;
|
||||
|
||||
-- Target counts
|
||||
canonical_count BIGINT;
|
||||
canonical_active BIGINT;
|
||||
canonical_withdrawn BIGINT;
|
||||
canonical_placeholder BIGINT;
|
||||
edge_count BIGINT;
|
||||
edge_unique_sources BIGINT;
|
||||
edge_with_payload BIGINT;
|
||||
|
||||
-- Integrity checks
|
||||
orphan_edges BIGINT;
|
||||
missing_sources BIGINT;
|
||||
duplicate_hashes BIGINT;
|
||||
avg_edges_per_canonical NUMERIC;
|
||||
|
||||
BEGIN
|
||||
-- Source table counts
|
||||
SELECT COUNT(*) INTO advisory_count FROM vuln.advisories;
|
||||
SELECT COUNT(*) INTO affected_count FROM vuln.advisory_affected;
|
||||
SELECT COUNT(*) INTO alias_count FROM vuln.advisory_aliases;
|
||||
SELECT COUNT(*) INTO weakness_count FROM vuln.advisory_weaknesses;
|
||||
SELECT COUNT(*) INTO kev_count FROM vuln.kev_flags;
|
||||
SELECT COUNT(*) INTO snapshot_count FROM vuln.advisory_snapshots;
|
||||
SELECT COUNT(*) INTO source_count FROM vuln.sources WHERE enabled = true;
|
||||
|
||||
-- Target table counts
|
||||
SELECT COUNT(*) INTO canonical_count FROM vuln.advisory_canonical;
|
||||
SELECT COUNT(*) INTO canonical_active FROM vuln.advisory_canonical WHERE status = 'active';
|
||||
SELECT COUNT(*) INTO canonical_withdrawn FROM vuln.advisory_canonical WHERE status = 'withdrawn';
|
||||
SELECT COUNT(*) INTO canonical_placeholder FROM vuln.advisory_canonical WHERE merge_hash LIKE 'PLACEHOLDER_%';
|
||||
SELECT COUNT(*) INTO edge_count FROM vuln.advisory_source_edge;
|
||||
SELECT COUNT(DISTINCT source_id) INTO edge_unique_sources FROM vuln.advisory_source_edge;
|
||||
SELECT COUNT(*) INTO edge_with_payload FROM vuln.advisory_source_edge WHERE raw_payload IS NOT NULL;
|
||||
|
||||
-- Integrity checks
|
||||
SELECT COUNT(*) INTO orphan_edges
|
||||
FROM vuln.advisory_source_edge e
|
||||
WHERE NOT EXISTS (SELECT 1 FROM vuln.advisory_canonical c WHERE c.id = e.canonical_id);
|
||||
|
||||
SELECT COUNT(*) INTO missing_sources
|
||||
FROM vuln.advisory_source_edge e
|
||||
WHERE NOT EXISTS (SELECT 1 FROM vuln.sources s WHERE s.id = e.source_id);
|
||||
|
||||
SELECT COUNT(*) INTO duplicate_hashes
|
||||
FROM (
|
||||
SELECT merge_hash, COUNT(*) as cnt
|
||||
FROM vuln.advisory_canonical
|
||||
GROUP BY merge_hash
|
||||
HAVING COUNT(*) > 1
|
||||
) dups;
|
||||
|
||||
IF canonical_count > 0 THEN
|
||||
avg_edges_per_canonical := edge_count::numeric / canonical_count;
|
||||
ELSE
|
||||
avg_edges_per_canonical := 0;
|
||||
END IF;
|
||||
|
||||
-- Report
|
||||
RAISE NOTICE '============================================';
|
||||
RAISE NOTICE 'CANONICAL MIGRATION VERIFICATION REPORT';
|
||||
RAISE NOTICE '============================================';
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE 'SOURCE TABLE COUNTS:';
|
||||
RAISE NOTICE ' Advisories: %', advisory_count;
|
||||
RAISE NOTICE ' Affected packages: %', affected_count;
|
||||
RAISE NOTICE ' Aliases: %', alias_count;
|
||||
RAISE NOTICE ' Weaknesses (CWE): %', weakness_count;
|
||||
RAISE NOTICE ' KEV flags: %', kev_count;
|
||||
RAISE NOTICE ' Snapshots: %', snapshot_count;
|
||||
RAISE NOTICE ' Enabled sources: %', source_count;
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE 'TARGET TABLE COUNTS:';
|
||||
RAISE NOTICE ' Canonicals: % (active: %, withdrawn: %)', canonical_count, canonical_active, canonical_withdrawn;
|
||||
RAISE NOTICE ' Placeholder hashes:% (need backfill)', canonical_placeholder;
|
||||
RAISE NOTICE ' Source edges: %', edge_count;
|
||||
RAISE NOTICE ' Unique sources: %', edge_unique_sources;
|
||||
RAISE NOTICE ' Edges with payload:%', edge_with_payload;
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE 'METRICS:';
|
||||
RAISE NOTICE ' Avg edges/canonical: %.2f', avg_edges_per_canonical;
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE 'INTEGRITY CHECKS:';
|
||||
RAISE NOTICE ' Orphan edges: % %', orphan_edges, CASE WHEN orphan_edges = 0 THEN '(OK)' ELSE '(FAIL)' END;
|
||||
RAISE NOTICE ' Missing sources: % %', missing_sources, CASE WHEN missing_sources = 0 THEN '(OK)' ELSE '(FAIL)' END;
|
||||
RAISE NOTICE ' Duplicate hashes: % %', duplicate_hashes, CASE WHEN duplicate_hashes = 0 THEN '(OK)' ELSE '(FAIL)' END;
|
||||
RAISE NOTICE '';
|
||||
|
||||
-- Fail migration if integrity checks fail
|
||||
IF orphan_edges > 0 OR missing_sources > 0 OR duplicate_hashes > 0 THEN
|
||||
RAISE NOTICE 'VERIFICATION FAILED - Please investigate integrity issues';
|
||||
ELSE
|
||||
RAISE NOTICE 'VERIFICATION PASSED - Migration completed successfully';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '============================================';
|
||||
END $$;
|
||||
|
||||
-- Additional verification queries (run individually for debugging)
|
||||
|
||||
-- Find CVEs that weren't migrated
|
||||
-- SELECT a.primary_vuln_id, a.advisory_key, a.created_at
|
||||
-- FROM vuln.advisories a
|
||||
-- WHERE NOT EXISTS (
|
||||
-- SELECT 1 FROM vuln.advisory_canonical c WHERE c.cve = a.primary_vuln_id
|
||||
-- )
|
||||
-- LIMIT 20;
|
||||
|
||||
-- Find canonicals without source edges
|
||||
-- SELECT c.cve, c.affects_key, c.created_at
|
||||
-- FROM vuln.advisory_canonical c
|
||||
-- WHERE NOT EXISTS (
|
||||
-- SELECT 1 FROM vuln.advisory_source_edge e WHERE e.canonical_id = c.id
|
||||
-- )
|
||||
-- LIMIT 20;
|
||||
|
||||
-- Distribution of edges per canonical
|
||||
-- SELECT
|
||||
-- CASE
|
||||
-- WHEN edge_count = 0 THEN '0'
|
||||
-- WHEN edge_count = 1 THEN '1'
|
||||
-- WHEN edge_count BETWEEN 2 AND 5 THEN '2-5'
|
||||
-- WHEN edge_count BETWEEN 6 AND 10 THEN '6-10'
|
||||
-- ELSE '10+'
|
||||
-- END AS edge_range,
|
||||
-- COUNT(*) AS canonical_count
|
||||
-- FROM (
|
||||
-- SELECT c.id, COALESCE(e.edge_count, 0) AS edge_count
|
||||
-- FROM vuln.advisory_canonical c
|
||||
-- LEFT JOIN (
|
||||
-- SELECT canonical_id, COUNT(*) AS edge_count
|
||||
-- FROM vuln.advisory_source_edge
|
||||
-- GROUP BY canonical_id
|
||||
-- ) e ON e.canonical_id = c.id
|
||||
-- ) sub
|
||||
-- GROUP BY edge_range
|
||||
-- ORDER BY edge_range;
|
||||
|
||||
-- Top CVEs by source coverage
|
||||
-- SELECT
|
||||
-- c.cve,
|
||||
-- c.severity,
|
||||
-- c.exploit_known,
|
||||
-- COUNT(e.id) AS source_count
|
||||
-- FROM vuln.advisory_canonical c
|
||||
-- LEFT JOIN vuln.advisory_source_edge e ON e.canonical_id = c.id
|
||||
-- GROUP BY c.id, c.cve, c.severity, c.exploit_known
|
||||
-- ORDER BY source_count DESC
|
||||
-- LIMIT 20;
|
||||
@@ -0,0 +1,85 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AdvisoryCanonicalEntity.cs
|
||||
// Sprint: SPRINT_8200_0012_0002_DB_canonical_source_edge_schema
|
||||
// Task: SCHEMA-8200-007
|
||||
// Description: Entity for deduplicated canonical advisory records
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a deduplicated canonical advisory in the vuln schema.
|
||||
/// Canonical advisories are identified by their semantic merge_hash.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryCanonicalEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique canonical advisory identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier (e.g., "CVE-2024-1234").
|
||||
/// </summary>
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized PURL or CPE identifying the affected package.
|
||||
/// </summary>
|
||||
public required string AffectsKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Structured version range as JSON (introduced, fixed, last_affected).
|
||||
/// </summary>
|
||||
public string? VersionRange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sorted CWE array (e.g., ["CWE-79", "CWE-89"]).
|
||||
/// </summary>
|
||||
public string[] Weakness { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic SHA256 hash of (cve, affects_key, version_range, weakness, patch_lineage).
|
||||
/// </summary>
|
||||
public required string MergeHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Status: active, stub, or withdrawn.
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Normalized severity: critical, high, medium, low, none, unknown.
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EPSS exploit prediction probability (0.0000-1.0000).
|
||||
/// </summary>
|
||||
public decimal? EpssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether an exploit is known to exist.
|
||||
/// </summary>
|
||||
public bool ExploitKnown { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Advisory title (for stub degradation).
|
||||
/// </summary>
|
||||
public string? Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Advisory summary (for stub degradation).
|
||||
/// </summary>
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the canonical record was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the canonical record was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AdvisorySourceEdgeEntity.cs
|
||||
// Sprint: SPRINT_8200_0012_0002_DB_canonical_source_edge_schema
|
||||
// Task: SCHEMA-8200-008
|
||||
// Description: Entity linking canonical advisory to source documents with DSSE
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a link between a canonical advisory and its source document.
|
||||
/// Stores DSSE signature envelopes and raw payload for provenance.
|
||||
/// </summary>
|
||||
public sealed class AdvisorySourceEdgeEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique source edge identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the deduplicated canonical advisory.
|
||||
/// </summary>
|
||||
public required Guid CanonicalId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the feed source.
|
||||
/// </summary>
|
||||
public required Guid SourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vendor's advisory ID (e.g., "DSA-5678", "RHSA-2024:1234").
|
||||
/// </summary>
|
||||
public required string SourceAdvisoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 hash of the raw source document.
|
||||
/// </summary>
|
||||
public required string SourceDocHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX-style status: affected, not_affected, fixed, under_investigation.
|
||||
/// </summary>
|
||||
public string? VendorStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source priority: vendor=10, distro=20, osv=30, nvd=40, default=100.
|
||||
/// Lower value = higher priority.
|
||||
/// </summary>
|
||||
public int PrecedenceRank { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature envelope as JSON ({ payloadType, payload, signatures[] }).
|
||||
/// </summary>
|
||||
public string? DsseEnvelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original advisory document as JSON.
|
||||
/// </summary>
|
||||
public string? RawPayload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the source document was fetched.
|
||||
/// </summary>
|
||||
public DateTimeOffset FetchedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the edge record was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SitePolicyEntity.cs
|
||||
// Sprint: SPRINT_8200_0014_0001_DB_sync_ledger_schema
|
||||
// Task: SYNC-8200-005
|
||||
// Description: Entity for per-site federation governance policies
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a site federation policy for governance control.
|
||||
/// </summary>
|
||||
public sealed class SitePolicyEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique policy identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Remote site identifier this policy applies to.
|
||||
/// </summary>
|
||||
public required string SiteId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name for the site.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source keys to allow (empty allows all sources).
|
||||
/// </summary>
|
||||
public string[] AllowedSources { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Source keys to deny (takes precedence over allowed).
|
||||
/// </summary>
|
||||
public string[] DeniedSources { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bundle size in megabytes.
|
||||
/// </summary>
|
||||
public int MaxBundleSizeMb { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum items per bundle.
|
||||
/// </summary>
|
||||
public int MaxItemsPerBundle { get; init; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether bundles must be cryptographically signed.
|
||||
/// </summary>
|
||||
public bool RequireSignature { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Signing key IDs or issuer patterns allowed for bundle verification.
|
||||
/// </summary>
|
||||
public string[] AllowedSigners { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether this site policy is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When the policy was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the policy was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SyncLedgerEntity.cs
|
||||
// Sprint: SPRINT_8200_0014_0001_DB_sync_ledger_schema
|
||||
// Task: SYNC-8200-004
|
||||
// Description: Entity for tracking federation sync state per remote site
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a sync ledger entry for federation cursor tracking.
|
||||
/// </summary>
|
||||
public sealed class SyncLedgerEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique ledger entry identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Remote site identifier (e.g., "site-us-west", "airgap-dc2").
|
||||
/// </summary>
|
||||
public required string SiteId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Opaque cursor position (usually ISO8601 timestamp#sequence).
|
||||
/// </summary>
|
||||
public required string Cursor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 hash of the imported bundle for deduplication.
|
||||
/// </summary>
|
||||
public required string BundleHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of items in the imported bundle.
|
||||
/// </summary>
|
||||
public int ItemsCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle was signed by the remote site.
|
||||
/// </summary>
|
||||
public DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle was imported to this site.
|
||||
/// </summary>
|
||||
public DateTimeOffset ImportedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AdvisoryCanonicalRepository.cs
|
||||
// Sprint: SPRINT_8200_0012_0002_DB_canonical_source_edge_schema
|
||||
// Task: SCHEMA-8200-010
|
||||
// Description: PostgreSQL repository for canonical advisory and source edge operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for canonical advisory and source edge operations.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryCanonicalRepository : RepositoryBase<ConcelierDataSource>, IAdvisoryCanonicalRepository
|
||||
{
|
||||
private const string SystemTenantId = "_system";
|
||||
|
||||
public AdvisoryCanonicalRepository(ConcelierDataSource dataSource, ILogger<AdvisoryCanonicalRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
#region Canonical Advisory Operations
|
||||
|
||||
public Task<AdvisoryCanonicalEntity?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, cve, affects_key, version_range::text, weakness, merge_hash,
|
||||
status, severity, epss_score, exploit_known, title, summary,
|
||||
created_at, updated_at
|
||||
FROM vuln.advisory_canonical
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "id", id),
|
||||
MapCanonical,
|
||||
ct);
|
||||
}
|
||||
|
||||
public Task<AdvisoryCanonicalEntity?> GetByMergeHashAsync(string mergeHash, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, cve, affects_key, version_range::text, weakness, merge_hash,
|
||||
status, severity, epss_score, exploit_known, title, summary,
|
||||
created_at, updated_at
|
||||
FROM vuln.advisory_canonical
|
||||
WHERE merge_hash = @merge_hash
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "merge_hash", mergeHash),
|
||||
MapCanonical,
|
||||
ct);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdvisoryCanonicalEntity>> GetByCveAsync(string cve, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, cve, affects_key, version_range::text, weakness, merge_hash,
|
||||
status, severity, epss_score, exploit_known, title, summary,
|
||||
created_at, updated_at
|
||||
FROM vuln.advisory_canonical
|
||||
WHERE cve = @cve
|
||||
ORDER BY updated_at DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "cve", cve),
|
||||
MapCanonical,
|
||||
ct);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdvisoryCanonicalEntity>> GetByAffectsKeyAsync(string affectsKey, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, cve, affects_key, version_range::text, weakness, merge_hash,
|
||||
status, severity, epss_score, exploit_known, title, summary,
|
||||
created_at, updated_at
|
||||
FROM vuln.advisory_canonical
|
||||
WHERE affects_key = @affects_key
|
||||
ORDER BY updated_at DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "affects_key", affectsKey),
|
||||
MapCanonical,
|
||||
ct);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdvisoryCanonicalEntity>> GetUpdatedSinceAsync(
|
||||
DateTimeOffset since,
|
||||
int limit = 1000,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, cve, affects_key, version_range::text, weakness, merge_hash,
|
||||
status, severity, epss_score, exploit_known, title, summary,
|
||||
created_at, updated_at
|
||||
FROM vuln.advisory_canonical
|
||||
WHERE updated_at > @since
|
||||
ORDER BY updated_at ASC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "since", since);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapCanonical,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<Guid> UpsertAsync(AdvisoryCanonicalEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO vuln.advisory_canonical
|
||||
(id, cve, affects_key, version_range, weakness, merge_hash,
|
||||
status, severity, epss_score, exploit_known, title, summary)
|
||||
VALUES
|
||||
(@id, @cve, @affects_key, @version_range::jsonb, @weakness, @merge_hash,
|
||||
@status, @severity, @epss_score, @exploit_known, @title, @summary)
|
||||
ON CONFLICT (merge_hash) DO UPDATE SET
|
||||
severity = COALESCE(EXCLUDED.severity, vuln.advisory_canonical.severity),
|
||||
epss_score = COALESCE(EXCLUDED.epss_score, vuln.advisory_canonical.epss_score),
|
||||
exploit_known = EXCLUDED.exploit_known OR vuln.advisory_canonical.exploit_known,
|
||||
title = COALESCE(EXCLUDED.title, vuln.advisory_canonical.title),
|
||||
summary = COALESCE(EXCLUDED.summary, vuln.advisory_canonical.summary),
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
var id = entity.Id == Guid.Empty ? Guid.NewGuid() : entity.Id;
|
||||
|
||||
return await ExecuteScalarAsync<Guid>(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "cve", entity.Cve);
|
||||
AddParameter(cmd, "affects_key", entity.AffectsKey);
|
||||
AddJsonbParameter(cmd, "version_range", entity.VersionRange);
|
||||
AddTextArrayParameter(cmd, "weakness", entity.Weakness);
|
||||
AddParameter(cmd, "merge_hash", entity.MergeHash);
|
||||
AddParameter(cmd, "status", entity.Status);
|
||||
AddParameter(cmd, "severity", entity.Severity);
|
||||
AddParameter(cmd, "epss_score", entity.EpssScore);
|
||||
AddParameter(cmd, "exploit_known", entity.ExploitKnown);
|
||||
AddParameter(cmd, "title", entity.Title);
|
||||
AddParameter(cmd, "summary", entity.Summary);
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpdateStatusAsync(Guid id, string status, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE vuln.advisory_canonical
|
||||
SET status = @status, updated_at = NOW()
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "status", status);
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = "DELETE FROM vuln.advisory_canonical WHERE id = @id";
|
||||
|
||||
await ExecuteAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "id", id),
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<long> CountAsync(CancellationToken ct = default)
|
||||
{
|
||||
const string sql = "SELECT COUNT(*) FROM vuln.advisory_canonical WHERE status = 'active'";
|
||||
|
||||
return await ExecuteScalarAsync<long>(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
null,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AdvisoryCanonicalEntity> StreamActiveAsync(
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, cve, affects_key, version_range::text, weakness, merge_hash,
|
||||
status, severity, epss_score, exploit_known, title, summary,
|
||||
created_at, updated_at
|
||||
FROM vuln.advisory_canonical
|
||||
WHERE status = 'active'
|
||||
ORDER BY id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
yield return MapCanonical(reader);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Source Edge Operations
|
||||
|
||||
public Task<IReadOnlyList<AdvisorySourceEdgeEntity>> GetSourceEdgesAsync(Guid canonicalId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, canonical_id, source_id, source_advisory_id, source_doc_hash,
|
||||
vendor_status, precedence_rank, dsse_envelope::text, raw_payload::text,
|
||||
fetched_at, created_at
|
||||
FROM vuln.advisory_source_edge
|
||||
WHERE canonical_id = @canonical_id
|
||||
ORDER BY precedence_rank ASC, fetched_at DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "canonical_id", canonicalId),
|
||||
MapSourceEdge,
|
||||
ct);
|
||||
}
|
||||
|
||||
public Task<AdvisorySourceEdgeEntity?> GetSourceEdgeByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, canonical_id, source_id, source_advisory_id, source_doc_hash,
|
||||
vendor_status, precedence_rank, dsse_envelope::text, raw_payload::text,
|
||||
fetched_at, created_at
|
||||
FROM vuln.advisory_source_edge
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "id", id),
|
||||
MapSourceEdge,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<Guid> AddSourceEdgeAsync(AdvisorySourceEdgeEntity edge, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO vuln.advisory_source_edge
|
||||
(id, canonical_id, source_id, source_advisory_id, source_doc_hash,
|
||||
vendor_status, precedence_rank, dsse_envelope, raw_payload, fetched_at)
|
||||
VALUES
|
||||
(@id, @canonical_id, @source_id, @source_advisory_id, @source_doc_hash,
|
||||
@vendor_status, @precedence_rank, @dsse_envelope::jsonb, @raw_payload::jsonb, @fetched_at)
|
||||
ON CONFLICT (canonical_id, source_id, source_doc_hash) DO UPDATE SET
|
||||
vendor_status = COALESCE(EXCLUDED.vendor_status, vuln.advisory_source_edge.vendor_status),
|
||||
precedence_rank = LEAST(EXCLUDED.precedence_rank, vuln.advisory_source_edge.precedence_rank),
|
||||
dsse_envelope = COALESCE(EXCLUDED.dsse_envelope, vuln.advisory_source_edge.dsse_envelope),
|
||||
raw_payload = COALESCE(EXCLUDED.raw_payload, vuln.advisory_source_edge.raw_payload)
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
var id = edge.Id == Guid.Empty ? Guid.NewGuid() : edge.Id;
|
||||
|
||||
return await ExecuteScalarAsync<Guid>(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "canonical_id", edge.CanonicalId);
|
||||
AddParameter(cmd, "source_id", edge.SourceId);
|
||||
AddParameter(cmd, "source_advisory_id", edge.SourceAdvisoryId);
|
||||
AddParameter(cmd, "source_doc_hash", edge.SourceDocHash);
|
||||
AddParameter(cmd, "vendor_status", edge.VendorStatus);
|
||||
AddParameter(cmd, "precedence_rank", edge.PrecedenceRank);
|
||||
AddJsonbParameter(cmd, "dsse_envelope", edge.DsseEnvelope);
|
||||
AddJsonbParameter(cmd, "raw_payload", edge.RawPayload);
|
||||
AddParameter(cmd, "fetched_at", edge.FetchedAt == default ? DateTimeOffset.UtcNow : edge.FetchedAt);
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdvisorySourceEdgeEntity>> GetSourceEdgesByAdvisoryIdAsync(
|
||||
string sourceAdvisoryId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, canonical_id, source_id, source_advisory_id, source_doc_hash,
|
||||
vendor_status, precedence_rank, dsse_envelope::text, raw_payload::text,
|
||||
fetched_at, created_at
|
||||
FROM vuln.advisory_source_edge
|
||||
WHERE source_advisory_id = @source_advisory_id
|
||||
ORDER BY fetched_at DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "source_advisory_id", sourceAdvisoryId),
|
||||
MapSourceEdge,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<long> CountSourceEdgesAsync(CancellationToken ct = default)
|
||||
{
|
||||
const string sql = "SELECT COUNT(*) FROM vuln.advisory_source_edge";
|
||||
|
||||
return await ExecuteScalarAsync<long>(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
null,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Statistics
|
||||
|
||||
public async Task<CanonicalStatistics> GetStatisticsAsync(CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM vuln.advisory_canonical) AS total_canonicals,
|
||||
(SELECT COUNT(*) FROM vuln.advisory_canonical WHERE status = 'active') AS active_canonicals,
|
||||
(SELECT COUNT(*) FROM vuln.advisory_source_edge) AS total_edges,
|
||||
(SELECT MAX(updated_at) FROM vuln.advisory_canonical) AS last_updated
|
||||
""";
|
||||
|
||||
var stats = await QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
_ => { },
|
||||
reader => new
|
||||
{
|
||||
TotalCanonicals = reader.GetInt64(0),
|
||||
ActiveCanonicals = reader.GetInt64(1),
|
||||
TotalEdges = reader.GetInt64(2),
|
||||
LastUpdated = GetNullableDateTimeOffset(reader, 3)
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (stats is null)
|
||||
{
|
||||
return new CanonicalStatistics();
|
||||
}
|
||||
|
||||
return new CanonicalStatistics
|
||||
{
|
||||
TotalCanonicals = stats.TotalCanonicals,
|
||||
ActiveCanonicals = stats.ActiveCanonicals,
|
||||
TotalSourceEdges = stats.TotalEdges,
|
||||
AvgSourceEdgesPerCanonical = stats.TotalCanonicals > 0
|
||||
? (double)stats.TotalEdges / stats.TotalCanonicals
|
||||
: 0,
|
||||
LastUpdatedAt = stats.LastUpdated
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mappers
|
||||
|
||||
private static AdvisoryCanonicalEntity MapCanonical(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
Cve = reader.GetString(1),
|
||||
AffectsKey = reader.GetString(2),
|
||||
VersionRange = GetNullableString(reader, 3),
|
||||
Weakness = reader.IsDBNull(4) ? [] : reader.GetFieldValue<string[]>(4),
|
||||
MergeHash = reader.GetString(5),
|
||||
Status = reader.GetString(6),
|
||||
Severity = GetNullableString(reader, 7),
|
||||
EpssScore = reader.IsDBNull(8) ? null : reader.GetDecimal(8),
|
||||
ExploitKnown = reader.GetBoolean(9),
|
||||
Title = GetNullableString(reader, 10),
|
||||
Summary = GetNullableString(reader, 11),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(12),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(13)
|
||||
};
|
||||
|
||||
private static AdvisorySourceEdgeEntity MapSourceEdge(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
CanonicalId = reader.GetGuid(1),
|
||||
SourceId = reader.GetGuid(2),
|
||||
SourceAdvisoryId = reader.GetString(3),
|
||||
SourceDocHash = reader.GetString(4),
|
||||
VendorStatus = GetNullableString(reader, 5),
|
||||
PrecedenceRank = reader.GetInt32(6),
|
||||
DsseEnvelope = GetNullableString(reader, 7),
|
||||
RawPayload = GetNullableString(reader, 8),
|
||||
FetchedAt = reader.GetFieldValue<DateTimeOffset>(9),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10)
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IAdvisoryCanonicalRepository.cs
|
||||
// Sprint: SPRINT_8200_0012_0002_DB_canonical_source_edge_schema
|
||||
// Task: SCHEMA-8200-009
|
||||
// Description: Repository interface for canonical advisory operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for canonical advisory and source edge operations.
|
||||
/// </summary>
|
||||
public interface IAdvisoryCanonicalRepository
|
||||
{
|
||||
#region Canonical Advisory Operations
|
||||
|
||||
/// <summary>
|
||||
/// Gets a canonical advisory by ID.
|
||||
/// </summary>
|
||||
Task<AdvisoryCanonicalEntity?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a canonical advisory by merge hash.
|
||||
/// </summary>
|
||||
Task<AdvisoryCanonicalEntity?> GetByMergeHashAsync(string mergeHash, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all canonical advisories for a CVE.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AdvisoryCanonicalEntity>> GetByCveAsync(string cve, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all canonical advisories for an affects key (PURL or CPE).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AdvisoryCanonicalEntity>> GetByAffectsKeyAsync(string affectsKey, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets canonical advisories updated since a given time.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AdvisoryCanonicalEntity>> GetUpdatedSinceAsync(
|
||||
DateTimeOffset since,
|
||||
int limit = 1000,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Upserts a canonical advisory (insert or update by merge_hash).
|
||||
/// </summary>
|
||||
Task<Guid> UpsertAsync(AdvisoryCanonicalEntity entity, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status of a canonical advisory.
|
||||
/// </summary>
|
||||
Task UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a canonical advisory and all its source edges (cascade).
|
||||
/// </summary>
|
||||
Task DeleteAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts total active canonical advisories.
|
||||
/// </summary>
|
||||
Task<long> CountAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Streams all active canonical advisories for batch processing.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<AdvisoryCanonicalEntity> StreamActiveAsync(CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Source Edge Operations
|
||||
|
||||
/// <summary>
|
||||
/// Gets all source edges for a canonical advisory.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AdvisorySourceEdgeEntity>> GetSourceEdgesAsync(Guid canonicalId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a source edge by ID.
|
||||
/// </summary>
|
||||
Task<AdvisorySourceEdgeEntity?> GetSourceEdgeByIdAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a source edge to a canonical advisory.
|
||||
/// </summary>
|
||||
Task<Guid> AddSourceEdgeAsync(AdvisorySourceEdgeEntity edge, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets source edges by source advisory ID (vendor ID).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AdvisorySourceEdgeEntity>> GetSourceEdgesByAdvisoryIdAsync(
|
||||
string sourceAdvisoryId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts total source edges.
|
||||
/// </summary>
|
||||
Task<long> CountSourceEdgesAsync(CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Statistics
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics about canonical advisories.
|
||||
/// </summary>
|
||||
Task<CanonicalStatistics> GetStatisticsAsync(CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about canonical advisory records.
|
||||
/// </summary>
|
||||
public sealed record CanonicalStatistics
|
||||
{
|
||||
/// <summary>
|
||||
/// Total canonical advisory count.
|
||||
/// </summary>
|
||||
public long TotalCanonicals { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Active canonical advisory count.
|
||||
/// </summary>
|
||||
public long ActiveCanonicals { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total source edge count.
|
||||
/// </summary>
|
||||
public long TotalSourceEdges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Average source edges per canonical.
|
||||
/// </summary>
|
||||
public double AvgSourceEdgesPerCanonical { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Most recent canonical update time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastUpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISyncLedgerRepository.cs
|
||||
// Sprint: SPRINT_8200_0014_0001_DB_sync_ledger_schema
|
||||
// Task: SYNC-8200-006
|
||||
// Description: Repository interface for federation sync ledger operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for federation sync ledger and site policy operations.
|
||||
/// </summary>
|
||||
public interface ISyncLedgerRepository
|
||||
{
|
||||
#region Ledger Operations
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest sync ledger entry for a site.
|
||||
/// </summary>
|
||||
Task<SyncLedgerEntity?> GetLatestAsync(string siteId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets sync history for a site.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SyncLedgerEntity>> GetHistoryAsync(string siteId, int limit = 10, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a ledger entry by bundle hash (for deduplication).
|
||||
/// </summary>
|
||||
Task<SyncLedgerEntity?> GetByBundleHashAsync(string bundleHash, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a new ledger entry.
|
||||
/// </summary>
|
||||
Task<Guid> InsertAsync(SyncLedgerEntity entry, CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cursor Operations
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current cursor position for a site.
|
||||
/// </summary>
|
||||
Task<string?> GetCursorAsync(string siteId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Advances the cursor to a new position (inserts a new ledger entry).
|
||||
/// </summary>
|
||||
Task AdvanceCursorAsync(
|
||||
string siteId,
|
||||
string newCursor,
|
||||
string bundleHash,
|
||||
int itemsCount,
|
||||
DateTimeOffset signedAt,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if importing a bundle would conflict with existing cursor.
|
||||
/// Returns true if the cursor is older than the current position.
|
||||
/// </summary>
|
||||
Task<bool> IsCursorConflictAsync(string siteId, string cursor, CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Site Policy Operations
|
||||
|
||||
/// <summary>
|
||||
/// Gets the policy for a specific site.
|
||||
/// </summary>
|
||||
Task<SitePolicyEntity?> GetPolicyAsync(string siteId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a site policy.
|
||||
/// </summary>
|
||||
Task UpsertPolicyAsync(SitePolicyEntity policy, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all site policies.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SitePolicyEntity>> GetAllPoliciesAsync(bool enabledOnly = true, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a site policy.
|
||||
/// </summary>
|
||||
Task<bool> DeletePolicyAsync(string siteId, CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Statistics
|
||||
|
||||
/// <summary>
|
||||
/// Gets sync statistics across all sites.
|
||||
/// </summary>
|
||||
Task<SyncStatistics> GetStatisticsAsync(CancellationToken ct = default);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated sync statistics across all sites.
|
||||
/// </summary>
|
||||
public sealed record SyncStatistics
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of registered sites.
|
||||
/// </summary>
|
||||
public int TotalSites { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of enabled sites.
|
||||
/// </summary>
|
||||
public int EnabledSites { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total bundles imported across all sites.
|
||||
/// </summary>
|
||||
public long TotalBundlesImported { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total items imported across all sites.
|
||||
/// </summary>
|
||||
public long TotalItemsImported { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the most recent import.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastImportAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SyncLedgerRepository.cs
|
||||
// Sprint: SPRINT_8200_0014_0001_DB_sync_ledger_schema
|
||||
// Task: SYNC-8200-007
|
||||
// Description: PostgreSQL repository for federation sync ledger operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for federation sync ledger and site policy operations.
|
||||
/// </summary>
|
||||
public sealed class SyncLedgerRepository : RepositoryBase<ConcelierDataSource>, ISyncLedgerRepository
|
||||
{
|
||||
private const string SystemTenantId = "_system";
|
||||
|
||||
public SyncLedgerRepository(ConcelierDataSource dataSource, ILogger<SyncLedgerRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
#region Ledger Operations
|
||||
|
||||
public Task<SyncLedgerEntity?> GetLatestAsync(string siteId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, site_id, cursor, bundle_hash, items_count, signed_at, imported_at
|
||||
FROM vuln.sync_ledger
|
||||
WHERE site_id = @site_id
|
||||
ORDER BY signed_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "site_id", siteId),
|
||||
MapLedgerEntry,
|
||||
ct);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SyncLedgerEntity>> GetHistoryAsync(string siteId, int limit = 10, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, site_id, cursor, bundle_hash, items_count, signed_at, imported_at
|
||||
FROM vuln.sync_ledger
|
||||
WHERE site_id = @site_id
|
||||
ORDER BY signed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "site_id", siteId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapLedgerEntry,
|
||||
ct);
|
||||
}
|
||||
|
||||
public Task<SyncLedgerEntity?> GetByBundleHashAsync(string bundleHash, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, site_id, cursor, bundle_hash, items_count, signed_at, imported_at
|
||||
FROM vuln.sync_ledger
|
||||
WHERE bundle_hash = @bundle_hash
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "bundle_hash", bundleHash),
|
||||
MapLedgerEntry,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<Guid> InsertAsync(SyncLedgerEntity entry, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO vuln.sync_ledger
|
||||
(id, site_id, cursor, bundle_hash, items_count, signed_at, imported_at)
|
||||
VALUES
|
||||
(@id, @site_id, @cursor, @bundle_hash, @items_count, @signed_at, @imported_at)
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
var id = entry.Id == Guid.Empty ? Guid.NewGuid() : entry.Id;
|
||||
|
||||
await ExecuteAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "site_id", entry.SiteId);
|
||||
AddParameter(cmd, "cursor", entry.Cursor);
|
||||
AddParameter(cmd, "bundle_hash", entry.BundleHash);
|
||||
AddParameter(cmd, "items_count", entry.ItemsCount);
|
||||
AddParameter(cmd, "signed_at", entry.SignedAt);
|
||||
AddParameter(cmd, "imported_at", entry.ImportedAt == default ? DateTimeOffset.UtcNow : entry.ImportedAt);
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cursor Operations
|
||||
|
||||
public async Task<string?> GetCursorAsync(string siteId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT cursor
|
||||
FROM vuln.sync_ledger
|
||||
WHERE site_id = @site_id
|
||||
ORDER BY signed_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
return await ExecuteScalarAsync<string>(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "site_id", siteId),
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task AdvanceCursorAsync(
|
||||
string siteId,
|
||||
string newCursor,
|
||||
string bundleHash,
|
||||
int itemsCount,
|
||||
DateTimeOffset signedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entry = new SyncLedgerEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SiteId = siteId,
|
||||
Cursor = newCursor,
|
||||
BundleHash = bundleHash,
|
||||
ItemsCount = itemsCount,
|
||||
SignedAt = signedAt,
|
||||
ImportedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await InsertAsync(entry, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> IsCursorConflictAsync(string siteId, string cursor, CancellationToken ct = default)
|
||||
{
|
||||
var currentCursor = await GetCursorAsync(siteId, ct).ConfigureAwait(false);
|
||||
|
||||
if (currentCursor is null)
|
||||
{
|
||||
// No existing cursor, no conflict
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare cursors - the new cursor should be newer than the current
|
||||
return !CursorFormat.IsAfter(cursor, currentCursor);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Site Policy Operations
|
||||
|
||||
public Task<SitePolicyEntity?> GetPolicyAsync(string siteId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, site_id, display_name, allowed_sources, denied_sources,
|
||||
max_bundle_size_mb, max_items_per_bundle, require_signature,
|
||||
allowed_signers, enabled, created_at, updated_at
|
||||
FROM vuln.site_policy
|
||||
WHERE site_id = @site_id
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "site_id", siteId),
|
||||
MapPolicy,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task UpsertPolicyAsync(SitePolicyEntity policy, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO vuln.site_policy
|
||||
(id, site_id, display_name, allowed_sources, denied_sources,
|
||||
max_bundle_size_mb, max_items_per_bundle, require_signature,
|
||||
allowed_signers, enabled)
|
||||
VALUES
|
||||
(@id, @site_id, @display_name, @allowed_sources, @denied_sources,
|
||||
@max_bundle_size_mb, @max_items_per_bundle, @require_signature,
|
||||
@allowed_signers, @enabled)
|
||||
ON CONFLICT (site_id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
allowed_sources = EXCLUDED.allowed_sources,
|
||||
denied_sources = EXCLUDED.denied_sources,
|
||||
max_bundle_size_mb = EXCLUDED.max_bundle_size_mb,
|
||||
max_items_per_bundle = EXCLUDED.max_items_per_bundle,
|
||||
require_signature = EXCLUDED.require_signature,
|
||||
allowed_signers = EXCLUDED.allowed_signers,
|
||||
enabled = EXCLUDED.enabled,
|
||||
updated_at = NOW()
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", policy.Id == Guid.Empty ? Guid.NewGuid() : policy.Id);
|
||||
AddParameter(cmd, "site_id", policy.SiteId);
|
||||
AddParameter(cmd, "display_name", policy.DisplayName);
|
||||
AddTextArrayParameter(cmd, "allowed_sources", policy.AllowedSources);
|
||||
AddTextArrayParameter(cmd, "denied_sources", policy.DeniedSources);
|
||||
AddParameter(cmd, "max_bundle_size_mb", policy.MaxBundleSizeMb);
|
||||
AddParameter(cmd, "max_items_per_bundle", policy.MaxItemsPerBundle);
|
||||
AddParameter(cmd, "require_signature", policy.RequireSignature);
|
||||
AddTextArrayParameter(cmd, "allowed_signers", policy.AllowedSigners);
|
||||
AddParameter(cmd, "enabled", policy.Enabled);
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SitePolicyEntity>> GetAllPoliciesAsync(bool enabledOnly = true, CancellationToken ct = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT id, site_id, display_name, allowed_sources, denied_sources,
|
||||
max_bundle_size_mb, max_items_per_bundle, require_signature,
|
||||
allowed_signers, enabled, created_at, updated_at
|
||||
FROM vuln.site_policy
|
||||
""";
|
||||
|
||||
if (enabledOnly)
|
||||
{
|
||||
sql += " WHERE enabled = TRUE";
|
||||
}
|
||||
|
||||
sql += " ORDER BY site_id";
|
||||
|
||||
return QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
_ => { },
|
||||
MapPolicy,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<bool> DeletePolicyAsync(string siteId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM vuln.site_policy
|
||||
WHERE site_id = @site_id
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "site_id", siteId),
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Statistics
|
||||
|
||||
public async Task<SyncStatistics> GetStatisticsAsync(CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
(SELECT COUNT(DISTINCT site_id) FROM vuln.site_policy) AS total_sites,
|
||||
(SELECT COUNT(DISTINCT site_id) FROM vuln.site_policy WHERE enabled = TRUE) AS enabled_sites,
|
||||
(SELECT COUNT(*) FROM vuln.sync_ledger) AS total_bundles,
|
||||
(SELECT COALESCE(SUM(items_count), 0) FROM vuln.sync_ledger) AS total_items,
|
||||
(SELECT MAX(imported_at) FROM vuln.sync_ledger) AS last_import
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
_ => { },
|
||||
reader => new SyncStatistics
|
||||
{
|
||||
TotalSites = reader.GetInt32(0),
|
||||
EnabledSites = reader.GetInt32(1),
|
||||
TotalBundlesImported = reader.GetInt64(2),
|
||||
TotalItemsImported = reader.GetInt64(3),
|
||||
LastImportAt = GetNullableDateTimeOffset(reader, 4)
|
||||
},
|
||||
ct).ConfigureAwait(false) ?? new SyncStatistics();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mappers
|
||||
|
||||
private static SyncLedgerEntity MapLedgerEntry(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
SiteId = reader.GetString(1),
|
||||
Cursor = reader.GetString(2),
|
||||
BundleHash = reader.GetString(3),
|
||||
ItemsCount = reader.GetInt32(4),
|
||||
SignedAt = reader.GetFieldValue<DateTimeOffset>(5),
|
||||
ImportedAt = reader.GetFieldValue<DateTimeOffset>(6)
|
||||
};
|
||||
|
||||
private static SitePolicyEntity MapPolicy(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
SiteId = reader.GetString(1),
|
||||
DisplayName = GetNullableString(reader, 2),
|
||||
AllowedSources = reader.GetFieldValue<string[]>(3),
|
||||
DeniedSources = reader.GetFieldValue<string[]>(4),
|
||||
MaxBundleSizeMb = reader.GetInt32(5),
|
||||
MaxItemsPerBundle = reader.GetInt32(6),
|
||||
RequireSignature = reader.GetBoolean(7),
|
||||
AllowedSigners = reader.GetFieldValue<string[]>(8),
|
||||
Enabled = reader.GetBoolean(9),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(11)
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cursor format utilities for federation sync.
|
||||
/// </summary>
|
||||
public static class CursorFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a cursor from timestamp and sequence.
|
||||
/// Format: "2025-01-15T10:30:00.000Z#0042"
|
||||
/// </summary>
|
||||
public static string Create(DateTimeOffset timestamp, int sequence = 0)
|
||||
{
|
||||
return $"{timestamp:O}#{sequence:D4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a cursor into timestamp and sequence.
|
||||
/// </summary>
|
||||
public static (DateTimeOffset Timestamp, int Sequence) Parse(string cursor)
|
||||
{
|
||||
var parts = cursor.Split('#');
|
||||
var timestamp = DateTimeOffset.Parse(parts[0]);
|
||||
var sequence = parts.Length > 1 ? int.Parse(parts[1]) : 0;
|
||||
return (timestamp, sequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two cursors. Returns true if cursor1 is after cursor2.
|
||||
/// </summary>
|
||||
public static bool IsAfter(string cursor1, string cursor2)
|
||||
{
|
||||
var (ts1, seq1) = Parse(cursor1);
|
||||
var (ts2, seq2) = Parse(cursor2);
|
||||
|
||||
if (ts1 != ts2) return ts1 > ts2;
|
||||
return seq1 > seq2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SitePolicyEnforcementService.cs
|
||||
// Sprint: SPRINT_8200_0014_0001_DB_sync_ledger_schema
|
||||
// Task: SYNC-8200-014
|
||||
// Description: Enforces site federation policies including source allow/deny lists
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces site federation policies for bundle imports.
|
||||
/// </summary>
|
||||
public sealed class SitePolicyEnforcementService
|
||||
{
|
||||
private readonly ISyncLedgerRepository _repository;
|
||||
private readonly ILogger<SitePolicyEnforcementService> _logger;
|
||||
|
||||
public SitePolicyEnforcementService(
|
||||
ISyncLedgerRepository repository,
|
||||
ILogger<SitePolicyEnforcementService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates whether a source is allowed for a given site.
|
||||
/// </summary>
|
||||
/// <param name="siteId">The site identifier.</param>
|
||||
/// <param name="sourceKey">The source key to validate.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Validation result indicating if the source is allowed.</returns>
|
||||
public async Task<SourceValidationResult> ValidateSourceAsync(
|
||||
string siteId,
|
||||
string sourceKey,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(siteId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceKey);
|
||||
|
||||
var policy = await _repository.GetPolicyAsync(siteId, ct).ConfigureAwait(false);
|
||||
|
||||
if (policy is null)
|
||||
{
|
||||
_logger.LogDebug("No policy found for site {SiteId}, allowing source {SourceKey} by default", siteId, sourceKey);
|
||||
return SourceValidationResult.Allowed("No policy configured");
|
||||
}
|
||||
|
||||
if (!policy.Enabled)
|
||||
{
|
||||
_logger.LogWarning("Site {SiteId} policy is disabled, rejecting source {SourceKey}", siteId, sourceKey);
|
||||
return SourceValidationResult.Denied("Site policy is disabled");
|
||||
}
|
||||
|
||||
return ValidateSourceAgainstPolicy(policy, sourceKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a source against a specific policy without fetching from repository.
|
||||
/// </summary>
|
||||
public SourceValidationResult ValidateSourceAgainstPolicy(SitePolicyEntity policy, string sourceKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceKey);
|
||||
|
||||
// Denied list takes precedence
|
||||
if (IsSourceInList(policy.DeniedSources, sourceKey))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Source {SourceKey} is explicitly denied for site {SiteId}",
|
||||
sourceKey, policy.SiteId);
|
||||
return SourceValidationResult.Denied($"Source '{sourceKey}' is in deny list");
|
||||
}
|
||||
|
||||
// If allowed list is empty, all non-denied sources are allowed
|
||||
if (policy.AllowedSources.Length == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Source {SourceKey} allowed for site {SiteId} (no allow list restrictions)",
|
||||
sourceKey, policy.SiteId);
|
||||
return SourceValidationResult.Allowed("No allow list restrictions");
|
||||
}
|
||||
|
||||
// Check if source is in allowed list
|
||||
if (IsSourceInList(policy.AllowedSources, sourceKey))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Source {SourceKey} is explicitly allowed for site {SiteId}",
|
||||
sourceKey, policy.SiteId);
|
||||
return SourceValidationResult.Allowed("Source is in allow list");
|
||||
}
|
||||
|
||||
// Source not in allowed list
|
||||
_logger.LogInformation(
|
||||
"Source {SourceKey} not in allow list for site {SiteId}",
|
||||
sourceKey, policy.SiteId);
|
||||
return SourceValidationResult.Denied($"Source '{sourceKey}' is not in allow list");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates multiple sources and returns results for each.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<string, SourceValidationResult>> ValidateSourcesAsync(
|
||||
string siteId,
|
||||
IEnumerable<string> sourceKeys,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(siteId);
|
||||
ArgumentNullException.ThrowIfNull(sourceKeys);
|
||||
|
||||
var policy = await _repository.GetPolicyAsync(siteId, ct).ConfigureAwait(false);
|
||||
var results = new Dictionary<string, SourceValidationResult>();
|
||||
|
||||
foreach (var sourceKey in sourceKeys)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (policy is null)
|
||||
{
|
||||
results[sourceKey] = SourceValidationResult.Allowed("No policy configured");
|
||||
}
|
||||
else if (!policy.Enabled)
|
||||
{
|
||||
results[sourceKey] = SourceValidationResult.Denied("Site policy is disabled");
|
||||
}
|
||||
else
|
||||
{
|
||||
results[sourceKey] = ValidateSourceAgainstPolicy(policy, sourceKey);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters a collection of source keys to only those allowed by the site policy.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> FilterAllowedSourcesAsync(
|
||||
string siteId,
|
||||
IEnumerable<string> sourceKeys,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = await ValidateSourcesAsync(siteId, sourceKeys, ct).ConfigureAwait(false);
|
||||
return results
|
||||
.Where(kvp => kvp.Value.IsAllowed)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool IsSourceInList(string[] sourceList, string sourceKey)
|
||||
{
|
||||
if (sourceList.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var source in sourceList)
|
||||
{
|
||||
// Exact match (case-insensitive)
|
||||
if (string.Equals(source, sourceKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard pattern match (e.g., "nvd-*" matches "nvd-cve", "nvd-cpe")
|
||||
if (source.EndsWith('*') && sourceKey.StartsWith(
|
||||
source[..^1], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#region Size Budget Tracking (SYNC-8200-015)
|
||||
|
||||
/// <summary>
|
||||
/// Validates bundle size against site policy limits.
|
||||
/// </summary>
|
||||
/// <param name="siteId">The site identifier.</param>
|
||||
/// <param name="bundleSizeMb">Bundle size in megabytes.</param>
|
||||
/// <param name="itemsCount">Number of items in the bundle.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Validation result indicating if the bundle is within limits.</returns>
|
||||
public async Task<BundleSizeValidationResult> ValidateBundleSizeAsync(
|
||||
string siteId,
|
||||
decimal bundleSizeMb,
|
||||
int itemsCount,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(siteId);
|
||||
|
||||
var policy = await _repository.GetPolicyAsync(siteId, ct).ConfigureAwait(false);
|
||||
|
||||
if (policy is null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No policy found for site {SiteId}, allowing bundle (size={SizeMb}MB, items={Items})",
|
||||
siteId, bundleSizeMb, itemsCount);
|
||||
return BundleSizeValidationResult.Allowed("No policy configured", bundleSizeMb, itemsCount);
|
||||
}
|
||||
|
||||
if (!policy.Enabled)
|
||||
{
|
||||
_logger.LogWarning("Site {SiteId} policy is disabled, rejecting bundle", siteId);
|
||||
return BundleSizeValidationResult.Denied(
|
||||
"Site policy is disabled",
|
||||
bundleSizeMb,
|
||||
itemsCount,
|
||||
policy.MaxBundleSizeMb,
|
||||
policy.MaxItemsPerBundle);
|
||||
}
|
||||
|
||||
return ValidateBundleSizeAgainstPolicy(policy, bundleSizeMb, itemsCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates bundle size against a specific policy without fetching from repository.
|
||||
/// </summary>
|
||||
public BundleSizeValidationResult ValidateBundleSizeAgainstPolicy(
|
||||
SitePolicyEntity policy,
|
||||
decimal bundleSizeMb,
|
||||
int itemsCount)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var violations = new List<string>();
|
||||
|
||||
// Check size limit
|
||||
if (bundleSizeMb > policy.MaxBundleSizeMb)
|
||||
{
|
||||
violations.Add($"Bundle size ({bundleSizeMb:F2}MB) exceeds limit ({policy.MaxBundleSizeMb}MB)");
|
||||
}
|
||||
|
||||
// Check items limit
|
||||
if (itemsCount > policy.MaxItemsPerBundle)
|
||||
{
|
||||
violations.Add($"Item count ({itemsCount}) exceeds limit ({policy.MaxItemsPerBundle})");
|
||||
}
|
||||
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
var reason = string.Join("; ", violations);
|
||||
_logger.LogWarning(
|
||||
"Bundle rejected for site {SiteId}: {Reason}",
|
||||
policy.SiteId, reason);
|
||||
return BundleSizeValidationResult.Denied(
|
||||
reason,
|
||||
bundleSizeMb,
|
||||
itemsCount,
|
||||
policy.MaxBundleSizeMb,
|
||||
policy.MaxItemsPerBundle);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Bundle accepted for site {SiteId}: size={SizeMb}MB (limit={MaxSize}MB), items={Items} (limit={MaxItems})",
|
||||
policy.SiteId, bundleSizeMb, policy.MaxBundleSizeMb, itemsCount, policy.MaxItemsPerBundle);
|
||||
|
||||
return BundleSizeValidationResult.Allowed(
|
||||
"Within size limits",
|
||||
bundleSizeMb,
|
||||
itemsCount,
|
||||
policy.MaxBundleSizeMb,
|
||||
policy.MaxItemsPerBundle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the remaining budget for a site based on recent imports.
|
||||
/// </summary>
|
||||
/// <param name="siteId">The site identifier.</param>
|
||||
/// <param name="windowHours">Time window in hours to consider for recent imports.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Remaining budget information.</returns>
|
||||
public async Task<SiteBudgetInfo> GetRemainingBudgetAsync(
|
||||
string siteId,
|
||||
int windowHours = 24,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(siteId);
|
||||
|
||||
var policy = await _repository.GetPolicyAsync(siteId, ct).ConfigureAwait(false);
|
||||
var history = await _repository.GetHistoryAsync(siteId, limit: 100, ct).ConfigureAwait(false);
|
||||
|
||||
if (policy is null)
|
||||
{
|
||||
return new SiteBudgetInfo(
|
||||
SiteId: siteId,
|
||||
HasPolicy: false,
|
||||
MaxBundleSizeMb: int.MaxValue,
|
||||
MaxItemsPerBundle: int.MaxValue,
|
||||
RecentImportsCount: history.Count,
|
||||
RecentItemsImported: history.Sum(h => h.ItemsCount),
|
||||
WindowHours: windowHours);
|
||||
}
|
||||
|
||||
var windowStart = DateTimeOffset.UtcNow.AddHours(-windowHours);
|
||||
var recentHistory = history.Where(h => h.ImportedAt >= windowStart).ToList();
|
||||
|
||||
return new SiteBudgetInfo(
|
||||
SiteId: siteId,
|
||||
HasPolicy: true,
|
||||
MaxBundleSizeMb: policy.MaxBundleSizeMb,
|
||||
MaxItemsPerBundle: policy.MaxItemsPerBundle,
|
||||
RecentImportsCount: recentHistory.Count,
|
||||
RecentItemsImported: recentHistory.Sum(h => h.ItemsCount),
|
||||
WindowHours: windowHours);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of source validation against site policy.
|
||||
/// </summary>
|
||||
public sealed record SourceValidationResult
|
||||
{
|
||||
private SourceValidationResult(bool isAllowed, string reason)
|
||||
{
|
||||
IsAllowed = isAllowed;
|
||||
Reason = reason;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the source is allowed.
|
||||
/// </summary>
|
||||
public bool IsAllowed { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the decision.
|
||||
/// </summary>
|
||||
public string Reason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an allowed result.
|
||||
/// </summary>
|
||||
public static SourceValidationResult Allowed(string reason) => new(true, reason);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a denied result.
|
||||
/// </summary>
|
||||
public static SourceValidationResult Denied(string reason) => new(false, reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle size validation against site policy.
|
||||
/// </summary>
|
||||
public sealed record BundleSizeValidationResult
|
||||
{
|
||||
private BundleSizeValidationResult(
|
||||
bool isAllowed,
|
||||
string reason,
|
||||
decimal actualSizeMb,
|
||||
int actualItemCount,
|
||||
int? maxSizeMb,
|
||||
int? maxItems)
|
||||
{
|
||||
IsAllowed = isAllowed;
|
||||
Reason = reason;
|
||||
ActualSizeMb = actualSizeMb;
|
||||
ActualItemCount = actualItemCount;
|
||||
MaxSizeMb = maxSizeMb;
|
||||
MaxItems = maxItems;
|
||||
}
|
||||
|
||||
public bool IsAllowed { get; }
|
||||
public string Reason { get; }
|
||||
public decimal ActualSizeMb { get; }
|
||||
public int ActualItemCount { get; }
|
||||
public int? MaxSizeMb { get; }
|
||||
public int? MaxItems { get; }
|
||||
|
||||
public static BundleSizeValidationResult Allowed(
|
||||
string reason,
|
||||
decimal actualSizeMb,
|
||||
int actualItemCount,
|
||||
int? maxSizeMb = null,
|
||||
int? maxItems = null)
|
||||
=> new(true, reason, actualSizeMb, actualItemCount, maxSizeMb, maxItems);
|
||||
|
||||
public static BundleSizeValidationResult Denied(
|
||||
string reason,
|
||||
decimal actualSizeMb,
|
||||
int actualItemCount,
|
||||
int? maxSizeMb = null,
|
||||
int? maxItems = null)
|
||||
=> new(false, reason, actualSizeMb, actualItemCount, maxSizeMb, maxItems);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a site's remaining import budget.
|
||||
/// </summary>
|
||||
public sealed record SiteBudgetInfo(
|
||||
string SiteId,
|
||||
bool HasPolicy,
|
||||
int MaxBundleSizeMb,
|
||||
int MaxItemsPerBundle,
|
||||
int RecentImportsCount,
|
||||
int RecentItemsImported,
|
||||
int WindowHours);
|
||||
@@ -0,0 +1,435 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CachingCanonicalAdvisoryServiceTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Task: CANSVC-8200-015
|
||||
// Description: Unit tests for caching canonical advisory service decorator
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Canonical;
|
||||
|
||||
public sealed class CachingCanonicalAdvisoryServiceTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ICanonicalAdvisoryService> _innerMock;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<CachingCanonicalAdvisoryService> _logger;
|
||||
private readonly CanonicalCacheOptions _options;
|
||||
|
||||
private static readonly Guid TestCanonicalId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private const string TestMergeHash = "sha256:abc123def456";
|
||||
private const string TestCve = "CVE-2025-0001";
|
||||
|
||||
public CachingCanonicalAdvisoryServiceTests()
|
||||
{
|
||||
_innerMock = new Mock<ICanonicalAdvisoryService>();
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_logger = NullLogger<CachingCanonicalAdvisoryService>.Instance;
|
||||
_options = new CanonicalCacheOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultTtl = TimeSpan.FromMinutes(5),
|
||||
CveTtl = TimeSpan.FromMinutes(2),
|
||||
ArtifactTtl = TimeSpan.FromMinutes(2)
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
}
|
||||
|
||||
#region GetByIdAsync - Caching
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsCachedResult_OnSecondCall()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
|
||||
_innerMock
|
||||
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonical);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act - first call hits inner service
|
||||
var result1 = await service.GetByIdAsync(TestCanonicalId);
|
||||
// Second call should hit cache
|
||||
var result2 = await service.GetByIdAsync(TestCanonicalId);
|
||||
|
||||
// Assert
|
||||
result1.Should().Be(canonical);
|
||||
result2.Should().Be(canonical);
|
||||
|
||||
// Inner service called only once
|
||||
_innerMock.Verify(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsNull_WhenNotFound()
|
||||
{
|
||||
// Arrange
|
||||
_innerMock
|
||||
.Setup(x => x.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetByIdAsync(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_CachesNullResult_DoesNotCallInnerTwice()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
_innerMock
|
||||
.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await service.GetByIdAsync(id);
|
||||
var result = await service.GetByIdAsync(id);
|
||||
|
||||
// Assert - null is not cached, so inner is called twice
|
||||
result.Should().BeNull();
|
||||
_innerMock.Verify(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetByMergeHashAsync - Caching
|
||||
|
||||
[Fact]
|
||||
public async Task GetByMergeHashAsync_ReturnsCachedResult_OnSecondCall()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
|
||||
_innerMock
|
||||
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonical);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result1 = await service.GetByMergeHashAsync(TestMergeHash);
|
||||
var result2 = await service.GetByMergeHashAsync(TestMergeHash);
|
||||
|
||||
// Assert
|
||||
result1.Should().Be(canonical);
|
||||
result2.Should().Be(canonical);
|
||||
_innerMock.Verify(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByMergeHashAsync_CachesByIdToo_AllowsCrossLookup()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
|
||||
_innerMock
|
||||
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonical);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act - fetch by hash first
|
||||
await service.GetByMergeHashAsync(TestMergeHash);
|
||||
// Then fetch by ID - should hit cache
|
||||
var result = await service.GetByIdAsync(TestCanonicalId);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(canonical);
|
||||
_innerMock.Verify(x => x.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetByCveAsync - Caching
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCveAsync_ReturnsCachedResult_OnSecondCall()
|
||||
{
|
||||
// Arrange
|
||||
var canonicals = new List<CanonicalAdvisory>
|
||||
{
|
||||
CreateCanonicalAdvisory(TestCanonicalId),
|
||||
CreateCanonicalAdvisory(Guid.NewGuid())
|
||||
};
|
||||
_innerMock
|
||||
.Setup(x => x.GetByCveAsync(TestCve, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonicals);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result1 = await service.GetByCveAsync(TestCve);
|
||||
var result2 = await service.GetByCveAsync(TestCve);
|
||||
|
||||
// Assert
|
||||
result1.Should().HaveCount(2);
|
||||
result2.Should().HaveCount(2);
|
||||
_innerMock.Verify(x => x.GetByCveAsync(TestCve, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCveAsync_NormalizesToUpperCase()
|
||||
{
|
||||
// Arrange
|
||||
var canonicals = new List<CanonicalAdvisory> { CreateCanonicalAdvisory(TestCanonicalId) };
|
||||
_innerMock
|
||||
.Setup(x => x.GetByCveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonicals);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act - lowercase
|
||||
await service.GetByCveAsync("cve-2025-0001");
|
||||
// uppercase should hit cache
|
||||
await service.GetByCveAsync("CVE-2025-0001");
|
||||
|
||||
// Assert
|
||||
_innerMock.Verify(x => x.GetByCveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCveAsync_ReturnsEmptyList_WhenNoResults()
|
||||
{
|
||||
// Arrange
|
||||
_innerMock
|
||||
.Setup(x => x.GetByCveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory>());
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetByCveAsync("CVE-2025-9999");
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetByArtifactAsync - Caching
|
||||
|
||||
[Fact]
|
||||
public async Task GetByArtifactAsync_ReturnsCachedResult_OnSecondCall()
|
||||
{
|
||||
// Arrange
|
||||
const string artifactKey = "pkg:npm/lodash@1";
|
||||
var canonicals = new List<CanonicalAdvisory> { CreateCanonicalAdvisory(TestCanonicalId) };
|
||||
_innerMock
|
||||
.Setup(x => x.GetByArtifactAsync(artifactKey, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonicals);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result1 = await service.GetByArtifactAsync(artifactKey);
|
||||
var result2 = await service.GetByArtifactAsync(artifactKey);
|
||||
|
||||
// Assert
|
||||
result1.Should().HaveCount(1);
|
||||
result2.Should().HaveCount(1);
|
||||
_innerMock.Verify(x => x.GetByArtifactAsync(artifactKey, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByArtifactAsync_NormalizesToLowerCase()
|
||||
{
|
||||
// Arrange
|
||||
var canonicals = new List<CanonicalAdvisory> { CreateCanonicalAdvisory(TestCanonicalId) };
|
||||
_innerMock
|
||||
.Setup(x => x.GetByArtifactAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonicals);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await service.GetByArtifactAsync("PKG:NPM/LODASH@1");
|
||||
await service.GetByArtifactAsync("pkg:npm/lodash@1");
|
||||
|
||||
// Assert - both should hit cache
|
||||
_innerMock.Verify(x => x.GetByArtifactAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region QueryAsync - Pass-through
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_DoesNotCache_PassesThroughToInner()
|
||||
{
|
||||
// Arrange
|
||||
var options = new CanonicalQueryOptions();
|
||||
var result = new PagedResult<CanonicalAdvisory> { Items = [], TotalCount = 0, Offset = 0, Limit = 10 };
|
||||
_innerMock
|
||||
.Setup(x => x.QueryAsync(options, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await service.QueryAsync(options);
|
||||
await service.QueryAsync(options);
|
||||
|
||||
// Assert - called twice (no caching)
|
||||
_innerMock.Verify(x => x.QueryAsync(options, It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IngestAsync - Cache Invalidation
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_InvalidatesCache_WhenNotDuplicate()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
|
||||
_innerMock
|
||||
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonical);
|
||||
|
||||
_innerMock
|
||||
.Setup(x => x.IngestAsync(It.IsAny<string>(), It.IsAny<RawAdvisory>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(IngestResult.Created(TestCanonicalId, TestMergeHash, Guid.NewGuid(), "nvd", "NVD-001"));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Prime the cache
|
||||
await service.GetByIdAsync(TestCanonicalId);
|
||||
|
||||
// Act - ingest that modifies the canonical
|
||||
await service.IngestAsync("nvd", CreateRawAdvisory(TestCve));
|
||||
|
||||
// Now fetch again - should call inner again
|
||||
await service.GetByIdAsync(TestCanonicalId);
|
||||
|
||||
// Assert - inner called twice (before and after ingest)
|
||||
_innerMock.Verify(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_DoesNotInvalidateCache_WhenDuplicate()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
|
||||
_innerMock
|
||||
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonical);
|
||||
|
||||
_innerMock
|
||||
.Setup(x => x.IngestAsync(It.IsAny<string>(), It.IsAny<RawAdvisory>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(IngestResult.Duplicate(TestCanonicalId, TestMergeHash, "nvd", "NVD-001"));
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Prime the cache
|
||||
await service.GetByIdAsync(TestCanonicalId);
|
||||
|
||||
// Act - duplicate ingest (no changes)
|
||||
await service.IngestAsync("nvd", CreateRawAdvisory(TestCve));
|
||||
|
||||
// Now fetch again - should hit cache
|
||||
await service.GetByIdAsync(TestCanonicalId);
|
||||
|
||||
// Assert - inner called only once
|
||||
_innerMock.Verify(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateStatusAsync - Cache Invalidation
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_InvalidatesCache()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
|
||||
_innerMock
|
||||
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonical);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Prime the cache
|
||||
await service.GetByIdAsync(TestCanonicalId);
|
||||
|
||||
// Act - update status
|
||||
await service.UpdateStatusAsync(TestCanonicalId, CanonicalStatus.Withdrawn);
|
||||
|
||||
// Now fetch again - should call inner again
|
||||
await service.GetByIdAsync(TestCanonicalId);
|
||||
|
||||
// Assert
|
||||
_innerMock.Verify(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Disabled Caching
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_DoesNotCache_WhenCachingDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var disabledOptions = new CanonicalCacheOptions { Enabled = false };
|
||||
var canonical = CreateCanonicalAdvisory(TestCanonicalId);
|
||||
_innerMock
|
||||
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonical);
|
||||
|
||||
var service = CreateService(disabledOptions);
|
||||
|
||||
// Act
|
||||
await service.GetByIdAsync(TestCanonicalId);
|
||||
await service.GetByIdAsync(TestCanonicalId);
|
||||
|
||||
// Assert - called twice when caching disabled
|
||||
_innerMock.Verify(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private CachingCanonicalAdvisoryService CreateService() =>
|
||||
CreateService(_options);
|
||||
|
||||
private CachingCanonicalAdvisoryService CreateService(CanonicalCacheOptions options) =>
|
||||
new(_innerMock.Object, _cache, Options.Create(options), _logger);
|
||||
|
||||
private static CanonicalAdvisory CreateCanonicalAdvisory(Guid id) => new()
|
||||
{
|
||||
Id = id,
|
||||
Cve = TestCve,
|
||||
AffectsKey = "pkg:npm/example@1",
|
||||
MergeHash = TestMergeHash,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private static RawAdvisory CreateRawAdvisory(string cve) => new()
|
||||
{
|
||||
SourceAdvisoryId = $"ADV-{cve}",
|
||||
Cve = cve,
|
||||
AffectsKey = "pkg:npm/example@1",
|
||||
VersionRangeJson = "{}",
|
||||
Weaknesses = [],
|
||||
FetchedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CanonicalAdvisoryServiceTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Task: CANSVC-8200-009
|
||||
// Description: Unit tests for canonical advisory service ingest pipeline
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Canonical;
|
||||
|
||||
public sealed class CanonicalAdvisoryServiceTests
|
||||
{
|
||||
private readonly Mock<ICanonicalAdvisoryStore> _storeMock;
|
||||
private readonly Mock<IMergeHashCalculator> _hashCalculatorMock;
|
||||
private readonly Mock<ISourceEdgeSigner> _signerMock;
|
||||
private readonly ILogger<CanonicalAdvisoryService> _logger;
|
||||
|
||||
private const string TestSource = "nvd";
|
||||
private const string TestMergeHash = "sha256:abc123def456";
|
||||
private static readonly Guid TestCanonicalId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly Guid TestSourceId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
private static readonly Guid TestEdgeId = Guid.Parse("33333333-3333-3333-3333-333333333333");
|
||||
|
||||
public CanonicalAdvisoryServiceTests()
|
||||
{
|
||||
_storeMock = new Mock<ICanonicalAdvisoryStore>();
|
||||
_hashCalculatorMock = new Mock<IMergeHashCalculator>();
|
||||
_signerMock = new Mock<ISourceEdgeSigner>();
|
||||
_logger = NullLogger<CanonicalAdvisoryService>.Instance;
|
||||
|
||||
// Default merge hash calculation
|
||||
_hashCalculatorMock
|
||||
.Setup(x => x.ComputeMergeHash(It.IsAny<MergeHashInput>()))
|
||||
.Returns(TestMergeHash);
|
||||
|
||||
// Default source resolution
|
||||
_storeMock
|
||||
.Setup(x => x.ResolveSourceIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TestSourceId);
|
||||
|
||||
// Default source edge creation
|
||||
_storeMock
|
||||
.Setup(x => x.AddSourceEdgeAsync(It.IsAny<AddSourceEdgeRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SourceEdgeResult.Created(TestEdgeId));
|
||||
}
|
||||
|
||||
#region IngestAsync - New Canonical
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_CreatesNewCanonical_WhenNoExistingMergeHash()
|
||||
{
|
||||
// Arrange
|
||||
_storeMock
|
||||
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TestCanonicalId);
|
||||
|
||||
var service = CreateService();
|
||||
var advisory = CreateRawAdvisory("CVE-2025-0001");
|
||||
|
||||
// Act
|
||||
var result = await service.IngestAsync(TestSource, advisory);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(MergeDecision.Created);
|
||||
result.CanonicalId.Should().Be(TestCanonicalId);
|
||||
result.MergeHash.Should().Be(TestMergeHash);
|
||||
result.SourceEdgeId.Should().Be(TestEdgeId);
|
||||
|
||||
_storeMock.Verify(x => x.UpsertCanonicalAsync(
|
||||
It.Is<UpsertCanonicalRequest>(r =>
|
||||
r.Cve == "CVE-2025-0001" &&
|
||||
r.MergeHash == TestMergeHash),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_ComputesMergeHash_FromAdvisoryFields()
|
||||
{
|
||||
// Arrange
|
||||
var advisory = CreateRawAdvisory(
|
||||
cve: "CVE-2025-0002",
|
||||
affectsKey: "pkg:npm/lodash@1",
|
||||
weaknesses: ["CWE-79", "CWE-89"]);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TestCanonicalId);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await service.IngestAsync(TestSource, advisory);
|
||||
|
||||
// Assert
|
||||
_hashCalculatorMock.Verify(x => x.ComputeMergeHash(
|
||||
It.Is<MergeHashInput>(input =>
|
||||
input.Cve == "CVE-2025-0002" &&
|
||||
input.AffectsKey == "pkg:npm/lodash@1" &&
|
||||
input.Weaknesses != null &&
|
||||
input.Weaknesses.Contains("CWE-79") &&
|
||||
input.Weaknesses.Contains("CWE-89"))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IngestAsync - Merge Existing
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_MergesIntoExisting_WhenMergeHashExists()
|
||||
{
|
||||
// Arrange - include source edge with high precedence so metadata update is skipped
|
||||
var existingCanonical = CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0003", withSourceEdge: true);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existingCanonical);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.SourceEdgeExistsAsync(TestCanonicalId, TestSourceId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var service = CreateService();
|
||||
var advisory = CreateRawAdvisory("CVE-2025-0003");
|
||||
|
||||
// Act
|
||||
var result = await service.IngestAsync(TestSource, advisory);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(MergeDecision.Merged);
|
||||
result.CanonicalId.Should().Be(TestCanonicalId);
|
||||
result.SourceEdgeId.Should().Be(TestEdgeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_AddsSourceEdge_ForMergedAdvisory()
|
||||
{
|
||||
// Arrange
|
||||
var existingCanonical = CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0004");
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existingCanonical);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.SourceEdgeExistsAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var service = CreateService();
|
||||
var advisory = CreateRawAdvisory("CVE-2025-0004", sourceAdvisoryId: "NVD-2025-0004");
|
||||
|
||||
// Act
|
||||
await service.IngestAsync(TestSource, advisory);
|
||||
|
||||
// Assert
|
||||
_storeMock.Verify(x => x.AddSourceEdgeAsync(
|
||||
It.Is<AddSourceEdgeRequest>(r =>
|
||||
r.CanonicalId == TestCanonicalId &&
|
||||
r.SourceId == TestSourceId &&
|
||||
r.SourceAdvisoryId == "NVD-2025-0004"),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IngestAsync - Duplicate Detection
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_ReturnsDuplicate_WhenSourceEdgeExists()
|
||||
{
|
||||
// Arrange
|
||||
var existingCanonical = CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0005");
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existingCanonical);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.SourceEdgeExistsAsync(TestCanonicalId, TestSourceId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var service = CreateService();
|
||||
var advisory = CreateRawAdvisory("CVE-2025-0005");
|
||||
|
||||
// Act
|
||||
var result = await service.IngestAsync(TestSource, advisory);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(MergeDecision.Duplicate);
|
||||
result.CanonicalId.Should().Be(TestCanonicalId);
|
||||
result.SourceEdgeId.Should().BeNull();
|
||||
|
||||
// Should not add source edge
|
||||
_storeMock.Verify(x => x.AddSourceEdgeAsync(
|
||||
It.IsAny<AddSourceEdgeRequest>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IngestAsync - DSSE Signing
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_SignsSourceEdge_WhenSignerAvailable()
|
||||
{
|
||||
// Arrange
|
||||
var signatureRef = Guid.NewGuid();
|
||||
var envelope = new DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.stellaops.advisory.v1+json",
|
||||
Payload = "eyJhZHZpc29yeSI6InRlc3QifQ==",
|
||||
Signatures = [new DsseSignature { KeyId = "test-key", Sig = "abc123" }]
|
||||
};
|
||||
|
||||
_signerMock
|
||||
.Setup(x => x.SignAsync(It.IsAny<SourceEdgeSigningRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SourceEdgeSigningResult.Signed(envelope, signatureRef));
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TestCanonicalId);
|
||||
|
||||
var service = CreateServiceWithSigner();
|
||||
var advisory = CreateRawAdvisory("CVE-2025-0006", rawPayloadJson: "{\"cve\":\"CVE-2025-0006\"}");
|
||||
|
||||
// Act
|
||||
var result = await service.IngestAsync(TestSource, advisory);
|
||||
|
||||
// Assert
|
||||
result.SignatureRef.Should().Be(signatureRef);
|
||||
|
||||
_storeMock.Verify(x => x.AddSourceEdgeAsync(
|
||||
It.Is<AddSourceEdgeRequest>(r =>
|
||||
r.DsseEnvelopeJson != null &&
|
||||
r.DsseEnvelopeJson.Contains("PayloadType")),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_ContinuesWithoutSignature_WhenSignerFails()
|
||||
{
|
||||
// Arrange
|
||||
_signerMock
|
||||
.Setup(x => x.SignAsync(It.IsAny<SourceEdgeSigningRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SourceEdgeSigningResult.Failed("Signing service unavailable"));
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TestCanonicalId);
|
||||
|
||||
var service = CreateServiceWithSigner();
|
||||
var advisory = CreateRawAdvisory("CVE-2025-0007", rawPayloadJson: "{\"cve\":\"CVE-2025-0007\"}");
|
||||
|
||||
// Act
|
||||
var result = await service.IngestAsync(TestSource, advisory);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(MergeDecision.Created);
|
||||
result.SignatureRef.Should().BeNull();
|
||||
|
||||
// Should still add source edge without DSSE
|
||||
_storeMock.Verify(x => x.AddSourceEdgeAsync(
|
||||
It.Is<AddSourceEdgeRequest>(r => r.DsseEnvelopeJson == null),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_SkipsSigning_WhenNoRawPayload()
|
||||
{
|
||||
// Arrange
|
||||
_storeMock
|
||||
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TestCanonicalId);
|
||||
|
||||
var service = CreateServiceWithSigner();
|
||||
var advisory = CreateRawAdvisory("CVE-2025-0008", rawPayloadJson: null);
|
||||
|
||||
// Act
|
||||
await service.IngestAsync(TestSource, advisory);
|
||||
|
||||
// Assert - signer should not be called
|
||||
_signerMock.Verify(x => x.SignAsync(
|
||||
It.IsAny<SourceEdgeSigningRequest>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_WorksWithoutSigner()
|
||||
{
|
||||
// Arrange - service without signer
|
||||
_storeMock
|
||||
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TestCanonicalId);
|
||||
|
||||
var service = CreateService(); // No signer
|
||||
var advisory = CreateRawAdvisory("CVE-2025-0009", rawPayloadJson: "{\"cve\":\"CVE-2025-0009\"}");
|
||||
|
||||
// Act
|
||||
var result = await service.IngestAsync(TestSource, advisory);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(MergeDecision.Created);
|
||||
result.SignatureRef.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IngestAsync - Source Precedence
|
||||
|
||||
[Theory]
|
||||
[InlineData("vendor", 10)]
|
||||
[InlineData("redhat", 20)]
|
||||
[InlineData("debian", 20)]
|
||||
[InlineData("osv", 30)]
|
||||
[InlineData("ghsa", 35)]
|
||||
[InlineData("nvd", 40)]
|
||||
[InlineData("unknown", 100)]
|
||||
public async Task IngestAsync_AssignsCorrectPrecedence_BySource(string source, int expectedRank)
|
||||
{
|
||||
// Arrange
|
||||
_storeMock
|
||||
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TestCanonicalId);
|
||||
|
||||
var service = CreateService();
|
||||
var advisory = CreateRawAdvisory("CVE-2025-0010");
|
||||
|
||||
// Act
|
||||
await service.IngestAsync(source, advisory);
|
||||
|
||||
// Assert
|
||||
_storeMock.Verify(x => x.AddSourceEdgeAsync(
|
||||
It.Is<AddSourceEdgeRequest>(r => r.PrecedenceRank == expectedRank),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IngestBatchAsync
|
||||
|
||||
[Fact]
|
||||
public async Task IngestBatchAsync_ProcessesAllAdvisories()
|
||||
{
|
||||
// Arrange
|
||||
_storeMock
|
||||
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TestCanonicalId);
|
||||
|
||||
var service = CreateService();
|
||||
var advisories = new[]
|
||||
{
|
||||
CreateRawAdvisory("CVE-2025-0011"),
|
||||
CreateRawAdvisory("CVE-2025-0012"),
|
||||
CreateRawAdvisory("CVE-2025-0013")
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = await service.IngestBatchAsync(TestSource, advisories);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(3);
|
||||
results.Should().OnlyContain(r => r.Decision == MergeDecision.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestBatchAsync_ContinuesOnError_ReturnsConflictForFailed()
|
||||
{
|
||||
// Arrange
|
||||
var callCount = 0;
|
||||
_storeMock
|
||||
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
_storeMock
|
||||
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 2)
|
||||
throw new InvalidOperationException("Simulated failure");
|
||||
return Task.FromResult(TestCanonicalId);
|
||||
});
|
||||
|
||||
var service = CreateService();
|
||||
var advisories = new[]
|
||||
{
|
||||
CreateRawAdvisory("CVE-2025-0014"),
|
||||
CreateRawAdvisory("CVE-2025-0015"),
|
||||
CreateRawAdvisory("CVE-2025-0016")
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = await service.IngestBatchAsync(TestSource, advisories);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(3);
|
||||
results[0].Decision.Should().Be(MergeDecision.Created);
|
||||
results[1].Decision.Should().Be(MergeDecision.Conflict);
|
||||
results[1].ConflictReason.Should().Contain("Simulated failure");
|
||||
results[2].Decision.Should().Be(MergeDecision.Created);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query Operations - GetByIdAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_DelegatesToStore()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0018");
|
||||
_storeMock
|
||||
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonical);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetByIdAsync(TestCanonicalId);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(canonical);
|
||||
_storeMock.Verify(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsNull_WhenNotFound()
|
||||
{
|
||||
// Arrange
|
||||
_storeMock
|
||||
.Setup(x => x.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetByIdAsync(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query Operations - GetByMergeHashAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetByMergeHashAsync_DelegatesToStore()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0019");
|
||||
_storeMock
|
||||
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonical);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetByMergeHashAsync(TestMergeHash);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(canonical);
|
||||
_storeMock.Verify(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByMergeHashAsync_ThrowsArgumentException_WhenHashIsNullOrEmpty()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetByMergeHashAsync(null!));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.GetByMergeHashAsync(""));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.GetByMergeHashAsync(" "));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query Operations - GetByCveAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCveAsync_DelegatesToStore()
|
||||
{
|
||||
// Arrange
|
||||
var canonicals = new List<CanonicalAdvisory>
|
||||
{
|
||||
CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0020"),
|
||||
CreateCanonicalAdvisory(Guid.NewGuid(), "CVE-2025-0020")
|
||||
};
|
||||
_storeMock
|
||||
.Setup(x => x.GetByCveAsync("CVE-2025-0020", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonicals);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetByCveAsync("CVE-2025-0020");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
_storeMock.Verify(x => x.GetByCveAsync("CVE-2025-0020", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCveAsync_ReturnsEmptyList_WhenNoResults()
|
||||
{
|
||||
// Arrange
|
||||
_storeMock
|
||||
.Setup(x => x.GetByCveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory>());
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetByCveAsync("CVE-2025-9999");
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCveAsync_ThrowsArgumentException_WhenCveIsNullOrEmpty()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetByCveAsync(null!));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.GetByCveAsync(""));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.GetByCveAsync(" "));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query Operations - GetByArtifactAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetByArtifactAsync_DelegatesToStore()
|
||||
{
|
||||
// Arrange
|
||||
const string artifactKey = "pkg:npm/lodash@4";
|
||||
var canonicals = new List<CanonicalAdvisory>
|
||||
{
|
||||
CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0021")
|
||||
};
|
||||
_storeMock
|
||||
.Setup(x => x.GetByArtifactAsync(artifactKey, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonicals);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetByArtifactAsync(artifactKey);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
_storeMock.Verify(x => x.GetByArtifactAsync(artifactKey, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByArtifactAsync_ThrowsArgumentException_WhenArtifactKeyIsNullOrEmpty()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetByArtifactAsync(null!));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.GetByArtifactAsync(""));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.GetByArtifactAsync(" "));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query Operations - QueryAsync
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_DelegatesToStore()
|
||||
{
|
||||
// Arrange
|
||||
var options = new CanonicalQueryOptions { Severity = "critical", Limit = 10 };
|
||||
var pagedResult = new PagedResult<CanonicalAdvisory>
|
||||
{
|
||||
Items = new List<CanonicalAdvisory> { CreateCanonicalAdvisory(TestCanonicalId, "CVE-2025-0022") },
|
||||
TotalCount = 1,
|
||||
Offset = 0,
|
||||
Limit = 10
|
||||
};
|
||||
_storeMock
|
||||
.Setup(x => x.QueryAsync(options, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pagedResult);
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.QueryAsync(options);
|
||||
|
||||
// Assert
|
||||
result.Items.Should().HaveCount(1);
|
||||
result.TotalCount.Should().Be(1);
|
||||
_storeMock.Verify(x => x.QueryAsync(options, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ThrowsArgumentNullException_WhenOptionsIsNull()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.QueryAsync(null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Status Operations - UpdateStatusAsync
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_DelegatesToStore()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await service.UpdateStatusAsync(TestCanonicalId, CanonicalStatus.Withdrawn);
|
||||
|
||||
// Assert
|
||||
_storeMock.Verify(x => x.UpdateStatusAsync(
|
||||
TestCanonicalId,
|
||||
CanonicalStatus.Withdrawn,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CanonicalStatus.Active)]
|
||||
[InlineData(CanonicalStatus.Stub)]
|
||||
[InlineData(CanonicalStatus.Withdrawn)]
|
||||
public async Task UpdateStatusAsync_AcceptsAllStatusValues(CanonicalStatus status)
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
await service.UpdateStatusAsync(TestCanonicalId, status);
|
||||
|
||||
// Assert
|
||||
_storeMock.Verify(x => x.UpdateStatusAsync(
|
||||
TestCanonicalId,
|
||||
status,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Status Operations - DegradeToStubsAsync
|
||||
|
||||
[Fact]
|
||||
public async Task DegradeToStubsAsync_ReturnsZero_NotYetImplemented()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.DegradeToStubsAsync(0.001);
|
||||
|
||||
// Assert - currently returns 0 as not implemented
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validation
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_ThrowsArgumentException_WhenSourceIsNullOrEmpty()
|
||||
{
|
||||
var service = CreateService();
|
||||
var advisory = CreateRawAdvisory("CVE-2025-0017");
|
||||
|
||||
// ArgumentNullException is thrown for null
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
service.IngestAsync(null!, advisory));
|
||||
|
||||
// ArgumentException is thrown for empty/whitespace
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
service.IngestAsync("", advisory));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
service.IngestAsync(" ", advisory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_ThrowsArgumentNullException_WhenAdvisoryIsNull()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
service.IngestAsync(TestSource, null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private CanonicalAdvisoryService CreateService() =>
|
||||
new(_storeMock.Object, _hashCalculatorMock.Object, _logger);
|
||||
|
||||
private CanonicalAdvisoryService CreateServiceWithSigner() =>
|
||||
new(_storeMock.Object, _hashCalculatorMock.Object, _logger, _signerMock.Object);
|
||||
|
||||
private static RawAdvisory CreateRawAdvisory(
|
||||
string cve,
|
||||
string? sourceAdvisoryId = null,
|
||||
string? affectsKey = null,
|
||||
IReadOnlyList<string>? weaknesses = null,
|
||||
string? rawPayloadJson = null)
|
||||
{
|
||||
return new RawAdvisory
|
||||
{
|
||||
SourceAdvisoryId = sourceAdvisoryId ?? $"ADV-{cve}",
|
||||
Cve = cve,
|
||||
AffectsKey = affectsKey ?? "pkg:npm/example@1",
|
||||
VersionRangeJson = "{\"introduced\":\"1.0.0\",\"fixed\":\"1.2.3\"}",
|
||||
Weaknesses = weaknesses ?? [],
|
||||
Severity = "high",
|
||||
Title = $"Test Advisory for {cve}",
|
||||
Summary = "Test summary",
|
||||
RawPayloadJson = rawPayloadJson,
|
||||
FetchedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static CanonicalAdvisory CreateCanonicalAdvisory(Guid id, string cve, bool withSourceEdge = false)
|
||||
{
|
||||
var sourceEdges = withSourceEdge
|
||||
? new List<SourceEdge>
|
||||
{
|
||||
new SourceEdge
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SourceName = "vendor",
|
||||
SourceAdvisoryId = $"VENDOR-{cve}",
|
||||
SourceDocHash = "sha256:existing",
|
||||
PrecedenceRank = 10, // High precedence
|
||||
FetchedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
}
|
||||
: new List<SourceEdge>();
|
||||
|
||||
return new CanonicalAdvisory
|
||||
{
|
||||
Id = id,
|
||||
Cve = cve,
|
||||
AffectsKey = "pkg:npm/example@1",
|
||||
MergeHash = TestMergeHash,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
SourceEdges = sourceEdges
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -16,5 +16,6 @@
|
||||
<!-- Test packages inherited from Directory.Build.props -->
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"corpus": "dedup-alias-collision",
|
||||
"version": "1.0.0",
|
||||
"description": "Test corpus for GHSA to CVE alias mapping edge cases",
|
||||
"items": [
|
||||
{
|
||||
"id": "GHSA-CVE-same-package",
|
||||
"description": "GHSA and CVE for same package should have same hash",
|
||||
"sources": [
|
||||
{
|
||||
"source": "github",
|
||||
"advisory_id": "GHSA-abc1-def2-ghi3",
|
||||
"cve": "CVE-2024-1001",
|
||||
"affects_key": "pkg:npm/express@4.18.0",
|
||||
"version_range": "<4.18.2",
|
||||
"weaknesses": ["CWE-400"]
|
||||
},
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-1001",
|
||||
"cve": "cve-2024-1001",
|
||||
"affects_key": "pkg:NPM/express@4.18.0",
|
||||
"version_range": "<4.18.2",
|
||||
"weaknesses": ["cwe-400"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Case normalization produces identical identity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "GHSA-CVE-different-package",
|
||||
"description": "GHSA and CVE for different packages should differ",
|
||||
"sources": [
|
||||
{
|
||||
"source": "github",
|
||||
"advisory_id": "GHSA-xyz1-uvw2-rst3",
|
||||
"cve": "CVE-2024-1002",
|
||||
"affects_key": "pkg:npm/lodash@4.17.0",
|
||||
"version_range": "<4.17.21",
|
||||
"weaknesses": ["CWE-1321"]
|
||||
},
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-1002",
|
||||
"cve": "CVE-2024-1002",
|
||||
"affects_key": "pkg:npm/underscore@1.13.0",
|
||||
"version_range": "<1.13.6",
|
||||
"weaknesses": ["CWE-1321"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": false,
|
||||
"rationale": "Different packages produce different hashes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PYSEC-CVE-mapping",
|
||||
"description": "PyPI security advisory with CVE mapping",
|
||||
"sources": [
|
||||
{
|
||||
"source": "osv",
|
||||
"advisory_id": "PYSEC-2024-001",
|
||||
"cve": "CVE-2024-1003",
|
||||
"affects_key": "pkg:pypi/django@4.2.0",
|
||||
"version_range": "<4.2.7",
|
||||
"weaknesses": ["CWE-79"]
|
||||
},
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-1003",
|
||||
"cve": "CVE-2024-1003",
|
||||
"affects_key": "pkg:PYPI/Django@4.2.0",
|
||||
"version_range": "<4.2.7",
|
||||
"weaknesses": ["CWE-79"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Case normalization for PyPI package names"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "RUSTSEC-CVE-mapping",
|
||||
"description": "Rust security advisory with CVE mapping",
|
||||
"sources": [
|
||||
{
|
||||
"source": "osv",
|
||||
"advisory_id": "RUSTSEC-2024-0001",
|
||||
"cve": "CVE-2024-1004",
|
||||
"affects_key": "pkg:cargo/tokio@1.28.0",
|
||||
"version_range": "<1.28.2",
|
||||
"weaknesses": ["CWE-416"]
|
||||
},
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-1004",
|
||||
"cve": "cve-2024-1004",
|
||||
"affects_key": "pkg:CARGO/Tokio@1.28.0",
|
||||
"version_range": "< 1.28.2",
|
||||
"weaknesses": ["cwe-416"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Case normalization for CVE, PURL, and CWE produces same identity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "GO-CVE-scoped-package",
|
||||
"description": "Go advisory with module path normalization",
|
||||
"sources": [
|
||||
{
|
||||
"source": "osv",
|
||||
"advisory_id": "GO-2024-0001",
|
||||
"cve": "CVE-2024-1005",
|
||||
"affects_key": "pkg:golang/github.com/example/module@v1.0.0",
|
||||
"version_range": "<v1.2.0",
|
||||
"weaknesses": ["CWE-94"]
|
||||
},
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-1005",
|
||||
"cve": "CVE-2024-1005",
|
||||
"affects_key": "pkg:golang/github.com/Example/Module@v1.0.0",
|
||||
"version_range": "<v1.2.0",
|
||||
"weaknesses": ["CWE-94"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Go module paths are normalized to lowercase"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-reserved-no-data",
|
||||
"description": "CVE reserved but no vulnerability data yet",
|
||||
"sources": [
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-1006",
|
||||
"cve": "CVE-2024-1006",
|
||||
"affects_key": "pkg:npm/test@1.0.0",
|
||||
"version_range": "*",
|
||||
"weaknesses": []
|
||||
},
|
||||
{
|
||||
"source": "github",
|
||||
"advisory_id": "GHSA-test-test-test",
|
||||
"cve": "CVE-2024-1006",
|
||||
"affects_key": "pkg:npm/test@1.0.0",
|
||||
"version_range": "all",
|
||||
"weaknesses": []
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Wildcard version ranges normalize to same value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "OSV-multi-ecosystem",
|
||||
"description": "OSV advisory affecting multiple ecosystems",
|
||||
"sources": [
|
||||
{
|
||||
"source": "osv",
|
||||
"advisory_id": "OSV-2024-001-npm",
|
||||
"cve": "CVE-2024-1007",
|
||||
"affects_key": "pkg:npm/shared-lib@1.0.0",
|
||||
"version_range": "<1.5.0",
|
||||
"weaknesses": ["CWE-20"]
|
||||
},
|
||||
{
|
||||
"source": "osv",
|
||||
"advisory_id": "OSV-2024-001-pypi",
|
||||
"cve": "CVE-2024-1007",
|
||||
"affects_key": "pkg:pypi/shared-lib@1.0.0",
|
||||
"version_range": "<1.5.0",
|
||||
"weaknesses": ["CWE-20"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": false,
|
||||
"rationale": "Different ecosystems (npm vs pypi) produce different hashes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "GHSA-CVE-partial-cwe",
|
||||
"description": "GHSA has more CWEs than CVE",
|
||||
"sources": [
|
||||
{
|
||||
"source": "github",
|
||||
"advisory_id": "GHSA-full-cwe-list",
|
||||
"cve": "CVE-2024-1008",
|
||||
"affects_key": "pkg:npm/vuln-pkg@1.0.0",
|
||||
"version_range": "<1.1.0",
|
||||
"weaknesses": ["CWE-79", "CWE-89", "CWE-94"]
|
||||
},
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-1008",
|
||||
"cve": "CVE-2024-1008",
|
||||
"affects_key": "pkg:npm/vuln-pkg@1.0.0",
|
||||
"version_range": "<1.1.0",
|
||||
"weaknesses": ["CWE-79"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": false,
|
||||
"rationale": "Different CWE sets produce different hashes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "GHSA-no-CVE-yet",
|
||||
"description": "GHSA published before CVE assignment",
|
||||
"sources": [
|
||||
{
|
||||
"source": "github",
|
||||
"advisory_id": "GHSA-pend-cve-asn",
|
||||
"cve": "CVE-2024-1009",
|
||||
"affects_key": "pkg:npm/new-vuln@2.0.0",
|
||||
"version_range": "<2.0.5",
|
||||
"weaknesses": ["CWE-352"]
|
||||
},
|
||||
{
|
||||
"source": "github",
|
||||
"advisory_id": "GHSA-pend-cve-asn",
|
||||
"cve": "cve-2024-1009",
|
||||
"affects_key": "pkg:NPM/new-vuln@2.0.0",
|
||||
"version_range": "<2.0.5",
|
||||
"weaknesses": ["cwe-352"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Same GHSA with case variations produces same hash"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "NuGet-GHSA-CVE",
|
||||
"description": "NuGet package with GHSA and CVE",
|
||||
"sources": [
|
||||
{
|
||||
"source": "github",
|
||||
"advisory_id": "GHSA-nuget-test-001",
|
||||
"cve": "CVE-2024-1010",
|
||||
"affects_key": "pkg:nuget/Newtonsoft.Json@13.0.0",
|
||||
"version_range": "<13.0.3",
|
||||
"weaknesses": ["CWE-502"]
|
||||
},
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-1010",
|
||||
"cve": "CVE-2024-1010",
|
||||
"affects_key": "pkg:NUGET/newtonsoft.json@13.0.0",
|
||||
"version_range": "<13.0.3",
|
||||
"weaknesses": ["CWE-502"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "NuGet package names are case-insensitive"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
{
|
||||
"corpus": "dedup-backport-variants",
|
||||
"version": "1.0.0",
|
||||
"description": "Test corpus for merge hash deduplication with Alpine/SUSE backport variants",
|
||||
"items": [
|
||||
{
|
||||
"id": "CVE-2024-0001-openssl-alpine-backport",
|
||||
"description": "Alpine backport with upstream commit reference",
|
||||
"sources": [
|
||||
{
|
||||
"source": "alpine",
|
||||
"advisory_id": "ALPINE-2024-001",
|
||||
"cve": "CVE-2024-0001",
|
||||
"affects_key": "pkg:apk/alpine/openssl@1.1.1w",
|
||||
"version_range": "<1.1.1w-r1",
|
||||
"weaknesses": ["CWE-476"],
|
||||
"patch_lineage": "https://github.com/openssl/openssl/commit/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
},
|
||||
{
|
||||
"source": "alpine",
|
||||
"advisory_id": "ALPINE-2024-001",
|
||||
"cve": "CVE-2024-0001",
|
||||
"affects_key": "pkg:apk/alpine/openssl@1.1.1w",
|
||||
"version_range": "<1.1.1w-r1",
|
||||
"weaknesses": ["CWE-476"],
|
||||
"patch_lineage": "backport of a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Same SHA extracted from both URL and backport reference"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-0002-curl-suse-backport",
|
||||
"description": "SUSE backport with PATCH-ID format",
|
||||
"sources": [
|
||||
{
|
||||
"source": "suse",
|
||||
"advisory_id": "SUSE-SU-2024:0001-1",
|
||||
"cve": "CVE-2024-0002",
|
||||
"affects_key": "pkg:rpm/suse/curl@7.79.1",
|
||||
"version_range": "<7.79.1-150400.5.36.1",
|
||||
"weaknesses": ["CWE-120"],
|
||||
"patch_lineage": "PATCH-12345"
|
||||
},
|
||||
{
|
||||
"source": "suse",
|
||||
"advisory_id": "SUSE-SU-2024:0001-1",
|
||||
"cve": "CVE-2024-0002",
|
||||
"affects_key": "pkg:rpm/suse/curl@7.79.1",
|
||||
"version_range": "<7.79.1-150400.5.36.1",
|
||||
"weaknesses": ["CWE-120"],
|
||||
"patch_lineage": "patch-12345"
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "PATCH-ID is case-normalized to uppercase"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-0003-nginx-different-backports",
|
||||
"description": "Same CVE with different backport lineages should differ",
|
||||
"sources": [
|
||||
{
|
||||
"source": "alpine",
|
||||
"advisory_id": "ALPINE-2024-002",
|
||||
"cve": "CVE-2024-0003",
|
||||
"affects_key": "pkg:apk/alpine/nginx@1.24.0",
|
||||
"version_range": "<1.24.0-r7",
|
||||
"weaknesses": ["CWE-400"],
|
||||
"patch_lineage": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
},
|
||||
{
|
||||
"source": "suse",
|
||||
"advisory_id": "SUSE-SU-2024:0002-1",
|
||||
"cve": "CVE-2024-0003",
|
||||
"affects_key": "pkg:rpm/suse/nginx@1.24.0",
|
||||
"version_range": "<1.24.0-150400.3.7.1",
|
||||
"weaknesses": ["CWE-400"],
|
||||
"patch_lineage": "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5"
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": false,
|
||||
"rationale": "Different package ecosystems and different patch lineages"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-0004-busybox-no-lineage",
|
||||
"description": "Backport without lineage info should still match on case normalization",
|
||||
"sources": [
|
||||
{
|
||||
"source": "alpine",
|
||||
"advisory_id": "ALPINE-2024-003",
|
||||
"cve": "CVE-2024-0004",
|
||||
"affects_key": "pkg:apk/alpine/busybox@1.36.1",
|
||||
"version_range": "<1.36.1-r6",
|
||||
"weaknesses": ["CWE-78"]
|
||||
},
|
||||
{
|
||||
"source": "alpine",
|
||||
"advisory_id": "ALPINE-2024-003",
|
||||
"cve": "cve-2024-0004",
|
||||
"affects_key": "pkg:APK/alpine/busybox@1.36.1",
|
||||
"version_range": "<1.36.1-r6",
|
||||
"weaknesses": ["cwe-78"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Case normalization produces identical identity when no patch lineage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-0005-musl-abbreviated-sha",
|
||||
"description": "Abbreviated vs full SHA should normalize differently",
|
||||
"sources": [
|
||||
{
|
||||
"source": "alpine",
|
||||
"advisory_id": "ALPINE-2024-004",
|
||||
"cve": "CVE-2024-0005",
|
||||
"affects_key": "pkg:apk/alpine/musl@1.2.4",
|
||||
"version_range": "<1.2.4-r2",
|
||||
"weaknesses": ["CWE-119"],
|
||||
"patch_lineage": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
},
|
||||
{
|
||||
"source": "alpine",
|
||||
"advisory_id": "ALPINE-2024-004",
|
||||
"cve": "CVE-2024-0005",
|
||||
"affects_key": "pkg:apk/alpine/musl@1.2.4",
|
||||
"version_range": "<1.2.4-r2",
|
||||
"weaknesses": ["CWE-119"],
|
||||
"patch_lineage": "commit a1b2c3d"
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": false,
|
||||
"rationale": "Full SHA vs abbreviated SHA produce different normalized lineages"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-0006-zlib-multiple-shas",
|
||||
"description": "Multiple SHAs in lineage - should extract first full SHA",
|
||||
"sources": [
|
||||
{
|
||||
"source": "suse",
|
||||
"advisory_id": "SUSE-SU-2024:0003-1",
|
||||
"cve": "CVE-2024-0006",
|
||||
"affects_key": "pkg:rpm/suse/zlib@1.2.13",
|
||||
"version_range": "<1.2.13-150500.4.3.1",
|
||||
"weaknesses": ["CWE-787"],
|
||||
"patch_lineage": "f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2"
|
||||
},
|
||||
{
|
||||
"source": "suse",
|
||||
"advisory_id": "SUSE-SU-2024:0003-1",
|
||||
"cve": "CVE-2024-0006",
|
||||
"affects_key": "pkg:rpm/suse/zlib@1.2.13",
|
||||
"version_range": "<1.2.13-150500.4.3.1",
|
||||
"weaknesses": ["CWE-787"],
|
||||
"patch_lineage": "fixes include f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2 and abc1234"
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Full SHA is extracted and normalized from both lineage descriptions"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-0007-libpng-distro-versions",
|
||||
"description": "Same upstream fix with different notation but same semantic meaning",
|
||||
"sources": [
|
||||
{
|
||||
"source": "alpine",
|
||||
"advisory_id": "ALPINE-2024-005",
|
||||
"cve": "CVE-2024-0007",
|
||||
"affects_key": "pkg:apk/alpine/libpng@1.6.40",
|
||||
"version_range": "<1.6.40-r0",
|
||||
"weaknesses": ["CWE-125"]
|
||||
},
|
||||
{
|
||||
"source": "alpine",
|
||||
"advisory_id": "ALPINE-2024-005",
|
||||
"cve": "cve-2024-0007",
|
||||
"affects_key": "pkg:APK/alpine/libpng@1.6.40",
|
||||
"version_range": "< 1.6.40-r0",
|
||||
"weaknesses": ["cwe-125"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Case normalization and whitespace trimming produce identical identity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-0008-git-github-url",
|
||||
"description": "GitHub vs GitLab commit URL extraction",
|
||||
"sources": [
|
||||
{
|
||||
"source": "suse",
|
||||
"advisory_id": "SUSE-SU-2024:0004-1",
|
||||
"cve": "CVE-2024-0008",
|
||||
"affects_key": "pkg:rpm/suse/git@2.42.0",
|
||||
"version_range": "<2.42.0-150500.3.6.1",
|
||||
"weaknesses": ["CWE-78"],
|
||||
"patch_lineage": "https://github.com/git/git/commit/abc123def456abc123def456abc123def456abc1"
|
||||
},
|
||||
{
|
||||
"source": "suse",
|
||||
"advisory_id": "SUSE-SU-2024:0004-1",
|
||||
"cve": "CVE-2024-0008",
|
||||
"affects_key": "pkg:rpm/suse/git@2.42.0",
|
||||
"version_range": "<2.42.0-150500.3.6.1",
|
||||
"weaknesses": ["CWE-78"],
|
||||
"patch_lineage": "https://gitlab.com/git/git/commit/abc123def456abc123def456abc123def456abc1"
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Both GitHub and GitLab URL patterns extract same commit SHA"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-0009-expat-unrecognized-lineage",
|
||||
"description": "Unrecognized patch lineage format returns null",
|
||||
"sources": [
|
||||
{
|
||||
"source": "alpine",
|
||||
"advisory_id": "ALPINE-2024-006",
|
||||
"cve": "CVE-2024-0009",
|
||||
"affects_key": "pkg:apk/alpine/expat@2.5.0",
|
||||
"version_range": "<2.5.0-r1",
|
||||
"weaknesses": ["CWE-611"],
|
||||
"patch_lineage": "some random text without sha"
|
||||
},
|
||||
{
|
||||
"source": "alpine",
|
||||
"advisory_id": "ALPINE-2024-006",
|
||||
"cve": "CVE-2024-0009",
|
||||
"affects_key": "pkg:apk/alpine/expat@2.5.0",
|
||||
"version_range": "<2.5.0-r1",
|
||||
"weaknesses": ["CWE-611"],
|
||||
"patch_lineage": "another unrecognized format"
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Both unrecognized lineages normalize to null, producing same hash"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-0010-sqlite-fixed-notation",
|
||||
"description": "Fixed version notation normalization",
|
||||
"sources": [
|
||||
{
|
||||
"source": "suse",
|
||||
"advisory_id": "SUSE-SU-2024:0005-1",
|
||||
"cve": "CVE-2024-0010",
|
||||
"affects_key": "pkg:rpm/suse/sqlite3@3.43.0",
|
||||
"version_range": "fixed: 3.43.2",
|
||||
"weaknesses": ["CWE-476"]
|
||||
},
|
||||
{
|
||||
"source": "suse",
|
||||
"advisory_id": "SUSE-SU-2024:0005-1",
|
||||
"cve": "CVE-2024-0010",
|
||||
"affects_key": "pkg:rpm/suse/sqlite3@3.43.0",
|
||||
"version_range": ">=3.43.2",
|
||||
"weaknesses": ["CWE-476"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "fixed: notation normalizes to >= comparison"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"corpus": "dedup-debian-rhel-cve-2024",
|
||||
"version": "1.0.0",
|
||||
"description": "Test corpus for merge hash deduplication across Debian and RHEL sources",
|
||||
"items": [
|
||||
{
|
||||
"id": "CVE-2024-1234-curl",
|
||||
"description": "Same curl CVE from Debian and RHEL - should produce same identity hash for same package",
|
||||
"sources": [
|
||||
{
|
||||
"source": "debian",
|
||||
"advisory_id": "DSA-5678-1",
|
||||
"cve": "CVE-2024-1234",
|
||||
"affects_key": "pkg:deb/debian/curl@7.68.0",
|
||||
"version_range": "<7.68.0-1+deb10u2",
|
||||
"weaknesses": ["CWE-120"]
|
||||
},
|
||||
{
|
||||
"source": "redhat",
|
||||
"advisory_id": "RHSA-2024:1234",
|
||||
"cve": "CVE-2024-1234",
|
||||
"affects_key": "pkg:deb/debian/curl@7.68.0",
|
||||
"version_range": "<7.68.0-1+deb10u2",
|
||||
"weaknesses": ["cwe-120"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Same CVE, same package identity, same version range, same CWE (case-insensitive)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-2345-openssl",
|
||||
"description": "Same OpenSSL CVE from Debian and RHEL with different package identifiers",
|
||||
"sources": [
|
||||
{
|
||||
"source": "debian",
|
||||
"advisory_id": "DSA-5680-1",
|
||||
"cve": "CVE-2024-2345",
|
||||
"affects_key": "pkg:deb/debian/openssl@1.1.1n",
|
||||
"version_range": "<1.1.1n-0+deb11u5",
|
||||
"weaknesses": ["CWE-200", "CWE-326"]
|
||||
},
|
||||
{
|
||||
"source": "redhat",
|
||||
"advisory_id": "RHSA-2024:2345",
|
||||
"cve": "cve-2024-2345",
|
||||
"affects_key": "pkg:rpm/redhat/openssl@1.1.1k",
|
||||
"version_range": "<1.1.1k-12.el8_9",
|
||||
"weaknesses": ["CWE-326", "CWE-200"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": false,
|
||||
"rationale": "Different package identifiers (deb vs rpm), so different merge hash despite same CVE"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-3456-nginx",
|
||||
"description": "Same nginx CVE with normalized version ranges",
|
||||
"sources": [
|
||||
{
|
||||
"source": "debian",
|
||||
"advisory_id": "DSA-5681-1",
|
||||
"cve": "CVE-2024-3456",
|
||||
"affects_key": "pkg:deb/debian/nginx@1.22.0",
|
||||
"version_range": "[1.0.0, 1.22.1)",
|
||||
"weaknesses": ["CWE-79"]
|
||||
},
|
||||
{
|
||||
"source": "debian_tracker",
|
||||
"advisory_id": "CVE-2024-3456",
|
||||
"cve": "CVE-2024-3456",
|
||||
"affects_key": "pkg:deb/debian/nginx@1.22.0",
|
||||
"version_range": ">=1.0.0,<1.22.1",
|
||||
"weaknesses": ["CWE-79"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Same CVE, same package, version ranges normalize to same format"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-4567-log4j",
|
||||
"description": "Different CVEs for same package should have different hash",
|
||||
"sources": [
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-4567",
|
||||
"cve": "CVE-2024-4567",
|
||||
"affects_key": "pkg:maven/org.apache.logging.log4j/log4j-core@2.17.0",
|
||||
"version_range": "<2.17.1",
|
||||
"weaknesses": ["CWE-502"]
|
||||
},
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-4568",
|
||||
"cve": "CVE-2024-4568",
|
||||
"affects_key": "pkg:maven/org.apache.logging.log4j/log4j-core@2.17.0",
|
||||
"version_range": "<2.17.1",
|
||||
"weaknesses": ["CWE-502"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": false,
|
||||
"rationale": "Different CVEs, even with same package and version range"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-5678-postgres",
|
||||
"description": "Same CVE with different CWEs should have different hash",
|
||||
"sources": [
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-5678",
|
||||
"cve": "CVE-2024-5678",
|
||||
"affects_key": "pkg:generic/postgresql@15.0",
|
||||
"version_range": "<15.4",
|
||||
"weaknesses": ["CWE-89"]
|
||||
},
|
||||
{
|
||||
"source": "vendor",
|
||||
"advisory_id": "CVE-2024-5678",
|
||||
"cve": "CVE-2024-5678",
|
||||
"affects_key": "pkg:generic/postgresql@15.0",
|
||||
"version_range": "<15.4",
|
||||
"weaknesses": ["CWE-89", "CWE-94"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": false,
|
||||
"rationale": "Different CWE sets change the identity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-6789-python",
|
||||
"description": "Same CVE with PURL qualifier stripping",
|
||||
"sources": [
|
||||
{
|
||||
"source": "pypi",
|
||||
"advisory_id": "PYSEC-2024-001",
|
||||
"cve": "CVE-2024-6789",
|
||||
"affects_key": "pkg:pypi/requests@2.28.0?arch=x86_64",
|
||||
"version_range": "<2.28.2",
|
||||
"weaknesses": ["CWE-400"]
|
||||
},
|
||||
{
|
||||
"source": "osv",
|
||||
"advisory_id": "CVE-2024-6789",
|
||||
"cve": "CVE-2024-6789",
|
||||
"affects_key": "pkg:pypi/requests@2.28.0",
|
||||
"version_range": "<2.28.2",
|
||||
"weaknesses": ["CWE-400"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "arch qualifier is stripped during normalization, so packages are identical"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-7890-npm",
|
||||
"description": "Same CVE with scoped npm package - case normalization",
|
||||
"sources": [
|
||||
{
|
||||
"source": "npm",
|
||||
"advisory_id": "GHSA-abc1-def2-ghi3",
|
||||
"cve": "CVE-2024-7890",
|
||||
"affects_key": "pkg:npm/@angular/core@14.0.0",
|
||||
"version_range": "<14.2.0",
|
||||
"weaknesses": ["CWE-79"]
|
||||
},
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-7890",
|
||||
"cve": "cve-2024-7890",
|
||||
"affects_key": "pkg:NPM/@Angular/CORE@14.0.0",
|
||||
"version_range": "<14.2.0",
|
||||
"weaknesses": ["cwe-79"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "PURL type/namespace/name case normalization produces same identity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-8901-redis",
|
||||
"description": "Same CVE with CPE identifier",
|
||||
"sources": [
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-8901",
|
||||
"cve": "CVE-2024-8901",
|
||||
"affects_key": "cpe:2.3:a:redis:redis:7.0.0:*:*:*:*:*:*:*",
|
||||
"version_range": "<7.0.12",
|
||||
"weaknesses": ["CWE-416"]
|
||||
},
|
||||
{
|
||||
"source": "vendor",
|
||||
"advisory_id": "CVE-2024-8901",
|
||||
"cve": "CVE-2024-8901",
|
||||
"affects_key": "CPE:2.3:A:Redis:REDIS:7.0.0:*:*:*:*:*:*:*",
|
||||
"version_range": "<7.0.12",
|
||||
"weaknesses": ["CWE-416"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "CPE normalization lowercases all components"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-9012-kernel",
|
||||
"description": "Same CVE with CPE 2.2 vs 2.3 format",
|
||||
"sources": [
|
||||
{
|
||||
"source": "nvd",
|
||||
"advisory_id": "CVE-2024-9012",
|
||||
"cve": "CVE-2024-9012",
|
||||
"affects_key": "cpe:/o:linux:linux_kernel:5.15",
|
||||
"version_range": "<5.15.120",
|
||||
"weaknesses": ["CWE-416"]
|
||||
},
|
||||
{
|
||||
"source": "vendor",
|
||||
"advisory_id": "CVE-2024-9012",
|
||||
"cve": "CVE-2024-9012",
|
||||
"affects_key": "cpe:2.3:o:linux:linux_kernel:5.15:*:*:*:*:*:*:*",
|
||||
"version_range": "<5.15.120",
|
||||
"weaknesses": ["CWE-416"]
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "CPE 2.2 is converted to CPE 2.3 format during normalization"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2024-1357-glibc",
|
||||
"description": "Same CVE with patch lineage differentiation",
|
||||
"sources": [
|
||||
{
|
||||
"source": "debian",
|
||||
"advisory_id": "DSA-5690-1",
|
||||
"cve": "CVE-2024-1357",
|
||||
"affects_key": "pkg:deb/debian/glibc@2.31",
|
||||
"version_range": "<2.31-13+deb11u7",
|
||||
"weaknesses": ["CWE-787"],
|
||||
"patch_lineage": "https://github.com/glibc/glibc/commit/abc123def456abc123def456abc123def456abc1"
|
||||
},
|
||||
{
|
||||
"source": "debian",
|
||||
"advisory_id": "DSA-5690-1",
|
||||
"cve": "CVE-2024-1357",
|
||||
"affects_key": "pkg:deb/debian/glibc@2.31",
|
||||
"version_range": "<2.31-13+deb11u7",
|
||||
"weaknesses": ["CWE-787"],
|
||||
"patch_lineage": "commit abc123def456abc123def456abc123def456abc1"
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"same_merge_hash": true,
|
||||
"rationale": "Patch lineage normalization extracts SHA from both URL and plain commit reference"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CpeNormalizerTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-008
|
||||
// Description: Unit tests for CpeNormalizer
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests.Identity;
|
||||
|
||||
public sealed class CpeNormalizerTests
|
||||
{
|
||||
private readonly CpeNormalizer _normalizer = CpeNormalizer.Instance;
|
||||
|
||||
#region CPE 2.3 Normalization
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ValidCpe23_ReturnsLowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*");
|
||||
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Cpe23Uppercase_ReturnsLowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("CPE:2.3:A:VENDOR:PRODUCT:1.0:*:*:*:*:*:*:*");
|
||||
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Cpe23MixedCase_ReturnsLowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("cpe:2.3:a:Apache:Log4j:2.14.0:*:*:*:*:*:*:*");
|
||||
Assert.Equal("cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Cpe23WithAny_ReturnsWildcard()
|
||||
{
|
||||
var result = _normalizer.Normalize("cpe:2.3:a:vendor:product:ANY:ANY:ANY:ANY:ANY:ANY:ANY:ANY");
|
||||
Assert.Equal("cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Cpe23WithNa_ReturnsDash()
|
||||
{
|
||||
var result = _normalizer.Normalize("cpe:2.3:a:vendor:product:1.0:NA:*:*:*:*:*:*");
|
||||
Assert.Equal("cpe:2.3:a:vendor:product:1.0:-:*:*:*:*:*:*", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CPE 2.2 to 2.3 Conversion
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Cpe22Simple_ConvertsToCpe23()
|
||||
{
|
||||
var result = _normalizer.Normalize("cpe:/a:vendor:product:1.0");
|
||||
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Cpe22NoVersion_ConvertsToCpe23()
|
||||
{
|
||||
var result = _normalizer.Normalize("cpe:/a:vendor:product");
|
||||
Assert.StartsWith("cpe:2.3:a:vendor:product:", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Cpe22WithUpdate_ConvertsToCpe23()
|
||||
{
|
||||
var result = _normalizer.Normalize("cpe:/a:vendor:product:1.0:update1");
|
||||
Assert.Equal("cpe:2.3:a:vendor:product:1.0:update1:*:*:*:*:*:*", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Cpe22Uppercase_ConvertsToCpe23Lowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("CPE:/A:VENDOR:PRODUCT:1.0");
|
||||
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Part Types
|
||||
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", "a")] // Application
|
||||
[InlineData("cpe:2.3:o:vendor:product:1.0:*:*:*:*:*:*:*", "o")] // Operating System
|
||||
[InlineData("cpe:2.3:h:vendor:product:1.0:*:*:*:*:*:*:*", "h")] // Hardware
|
||||
public void Normalize_DifferentPartTypes_PreservesPartType(string input, string expectedPart)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.StartsWith($"cpe:2.3:{expectedPart}:", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Empty and Null
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Null_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(null!);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_EmptyString_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(string.Empty);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WhitespaceOnly_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(" ");
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithWhitespace_ReturnsTrimmed()
|
||||
{
|
||||
var result = _normalizer.Normalize(" cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:* ");
|
||||
Assert.Equal("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Malformed Input
|
||||
|
||||
[Fact]
|
||||
public void Normalize_InvalidCpeFormat_ReturnsLowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("cpe:invalid:format");
|
||||
Assert.Equal("cpe:invalid:format", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NotCpe_ReturnsLowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("not-a-cpe");
|
||||
Assert.Equal("not-a-cpe", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_TooFewComponents_ReturnsLowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("cpe:2.3:a:vendor");
|
||||
Assert.Equal("cpe:2.3:a:vendor", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Empty Components
|
||||
|
||||
[Fact]
|
||||
public void Normalize_EmptyVersion_ReturnsWildcard()
|
||||
{
|
||||
var result = _normalizer.Normalize("cpe:2.3:a:vendor:product::*:*:*:*:*:*:*");
|
||||
Assert.Contains(":*:", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_EmptyVendor_ReturnsWildcard()
|
||||
{
|
||||
var result = _normalizer.Normalize("cpe:2.3:a::product:1.0:*:*:*:*:*:*:*");
|
||||
Assert.Contains(":*:", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism
|
||||
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*")]
|
||||
[InlineData("CPE:2.3:A:VENDOR:PRODUCT:1.0:*:*:*:*:*:*:*")]
|
||||
[InlineData("cpe:/a:vendor:product:1.0")]
|
||||
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
|
||||
{
|
||||
var first = _normalizer.Normalize(input);
|
||||
var second = _normalizer.Normalize(input);
|
||||
var third = _normalizer.Normalize(input);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.Equal(second, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Determinism_100Runs()
|
||||
{
|
||||
const string input = "CPE:2.3:A:Apache:LOG4J:2.14.0:*:*:*:*:*:*:*";
|
||||
var expected = _normalizer.Normalize(input);
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Cpe22And23_ProduceSameOutput()
|
||||
{
|
||||
var cpe22 = "cpe:/a:apache:log4j:2.14.0";
|
||||
var cpe23 = "cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*";
|
||||
|
||||
var result22 = _normalizer.Normalize(cpe22);
|
||||
var result23 = _normalizer.Normalize(cpe23);
|
||||
|
||||
Assert.Equal(result22, result23);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Real-World CPE Formats
|
||||
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*", "cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*")]
|
||||
[InlineData("cpe:2.3:a:openssl:openssl:1.1.1:*:*:*:*:*:*:*", "cpe:2.3:a:openssl:openssl:1.1.1:*:*:*:*:*:*:*")]
|
||||
[InlineData("cpe:2.3:o:linux:linux_kernel:5.10:*:*:*:*:*:*:*", "cpe:2.3:o:linux:linux_kernel:5.10:*:*:*:*:*:*:*")]
|
||||
public void Normalize_RealWorldCpes_ReturnsExpected(string input, string expected)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Singleton Instance
|
||||
|
||||
[Fact]
|
||||
public void Instance_ReturnsSameInstance()
|
||||
{
|
||||
var instance1 = CpeNormalizer.Instance;
|
||||
var instance2 = CpeNormalizer.Instance;
|
||||
Assert.Same(instance1, instance2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CveNormalizerTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-008
|
||||
// Description: Unit tests for CveNormalizer
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests.Identity;
|
||||
|
||||
public sealed class CveNormalizerTests
|
||||
{
|
||||
private readonly CveNormalizer _normalizer = CveNormalizer.Instance;
|
||||
|
||||
#region Basic Normalization
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ValidUppercase_ReturnsUnchanged()
|
||||
{
|
||||
var result = _normalizer.Normalize("CVE-2024-12345");
|
||||
Assert.Equal("CVE-2024-12345", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ValidLowercase_ReturnsUppercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("cve-2024-12345");
|
||||
Assert.Equal("CVE-2024-12345", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_MixedCase_ReturnsUppercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("Cve-2024-12345");
|
||||
Assert.Equal("CVE-2024-12345", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithWhitespace_ReturnsTrimmed()
|
||||
{
|
||||
var result = _normalizer.Normalize(" CVE-2024-12345 ");
|
||||
Assert.Equal("CVE-2024-12345", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_JustNumberPart_AddsCvePrefix()
|
||||
{
|
||||
var result = _normalizer.Normalize("2024-12345");
|
||||
Assert.Equal("CVE-2024-12345", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Empty and Null
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Null_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(null);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_EmptyString_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(string.Empty);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WhitespaceOnly_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(" ");
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Malformed Input
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ShortYear_ReturnsAsIs()
|
||||
{
|
||||
// Invalid year format (3 digits) - should return uppercase
|
||||
var result = _normalizer.Normalize("CVE-202-12345");
|
||||
Assert.Equal("CVE-202-12345", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ShortSequence_ReturnsAsIs()
|
||||
{
|
||||
// Invalid sequence (3 digits, min is 4) - should return uppercase
|
||||
var result = _normalizer.Normalize("CVE-2024-123");
|
||||
Assert.Equal("CVE-2024-123", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NonNumericYear_ReturnsUppercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("CVE-XXXX-12345");
|
||||
Assert.Equal("CVE-XXXX-12345", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NonNumericSequence_ReturnsUppercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("CVE-2024-ABCDE");
|
||||
Assert.Equal("CVE-2024-ABCDE", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ArbitraryText_ReturnsUppercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("some-random-text");
|
||||
Assert.Equal("SOME-RANDOM-TEXT", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Unicode and Special Characters
|
||||
|
||||
[Fact]
|
||||
public void Normalize_UnicodeWhitespace_ReturnsTrimmed()
|
||||
{
|
||||
// Non-breaking space and other unicode whitespace
|
||||
var result = _normalizer.Normalize("\u00A0CVE-2024-12345\u2003");
|
||||
Assert.Equal("CVE-2024-12345", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithNewlines_ReturnsTrimmed()
|
||||
{
|
||||
var result = _normalizer.Normalize("\nCVE-2024-12345\r\n");
|
||||
Assert.Equal("CVE-2024-12345", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithTabs_ReturnsTrimmed()
|
||||
{
|
||||
var result = _normalizer.Normalize("\tCVE-2024-12345\t");
|
||||
Assert.Equal("CVE-2024-12345", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism
|
||||
|
||||
[Theory]
|
||||
[InlineData("CVE-2024-12345")]
|
||||
[InlineData("cve-2024-12345")]
|
||||
[InlineData("2024-12345")]
|
||||
[InlineData(" CVE-2024-12345 ")]
|
||||
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
|
||||
{
|
||||
var first = _normalizer.Normalize(input);
|
||||
var second = _normalizer.Normalize(input);
|
||||
var third = _normalizer.Normalize(input);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.Equal(second, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Determinism_100Runs()
|
||||
{
|
||||
const string input = "cve-2024-99999";
|
||||
var expected = _normalizer.Normalize(input);
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Real-World CVE Formats
|
||||
|
||||
[Theory]
|
||||
[InlineData("CVE-2024-1234", "CVE-2024-1234")]
|
||||
[InlineData("CVE-2024-12345", "CVE-2024-12345")]
|
||||
[InlineData("CVE-2024-123456", "CVE-2024-123456")]
|
||||
[InlineData("CVE-2021-44228", "CVE-2021-44228")] // Log4Shell
|
||||
[InlineData("CVE-2017-5754", "CVE-2017-5754")] // Meltdown
|
||||
[InlineData("CVE-2014-0160", "CVE-2014-0160")] // Heartbleed
|
||||
public void Normalize_RealWorldCves_ReturnsExpected(string input, string expected)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Singleton Instance
|
||||
|
||||
[Fact]
|
||||
public void Instance_ReturnsSameInstance()
|
||||
{
|
||||
var instance1 = CveNormalizer.Instance;
|
||||
var instance2 = CveNormalizer.Instance;
|
||||
Assert.Same(instance1, instance2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CweNormalizerTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-008
|
||||
// Description: Unit tests for CweNormalizer
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests.Identity;
|
||||
|
||||
public sealed class CweNormalizerTests
|
||||
{
|
||||
private readonly CweNormalizer _normalizer = CweNormalizer.Instance;
|
||||
|
||||
#region Basic Normalization
|
||||
|
||||
[Fact]
|
||||
public void Normalize_SingleCwe_ReturnsUppercase()
|
||||
{
|
||||
var result = _normalizer.Normalize(["cwe-79"]);
|
||||
Assert.Equal("CWE-79", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_MultipleCwes_ReturnsSortedCommaJoined()
|
||||
{
|
||||
var result = _normalizer.Normalize(["CWE-89", "CWE-79"]);
|
||||
Assert.Equal("CWE-79,CWE-89", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_MixedCase_ReturnsUppercase()
|
||||
{
|
||||
var result = _normalizer.Normalize(["Cwe-79", "cwe-89", "CWE-120"]);
|
||||
Assert.Equal("CWE-79,CWE-89,CWE-120", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithoutPrefix_AddsPrefix()
|
||||
{
|
||||
var result = _normalizer.Normalize(["79", "89"]);
|
||||
Assert.Equal("CWE-79,CWE-89", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_MixedPrefixFormats_NormalizesAll()
|
||||
{
|
||||
var result = _normalizer.Normalize(["CWE-79", "89", "cwe-120"]);
|
||||
Assert.Equal("CWE-79,CWE-89,CWE-120", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Deduplication
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Duplicates_ReturnsUnique()
|
||||
{
|
||||
var result = _normalizer.Normalize(["CWE-79", "CWE-79", "cwe-79"]);
|
||||
Assert.Equal("CWE-79", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_DuplicatesWithDifferentCase_ReturnsUnique()
|
||||
{
|
||||
var result = _normalizer.Normalize(["CWE-89", "cwe-89", "Cwe-89"]);
|
||||
Assert.Equal("CWE-89", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_DuplicatesWithMixedFormats_ReturnsUnique()
|
||||
{
|
||||
var result = _normalizer.Normalize(["CWE-79", "79", "cwe-79"]);
|
||||
Assert.Equal("CWE-79", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Sorting
|
||||
|
||||
[Fact]
|
||||
public void Normalize_UnsortedNumbers_ReturnsSortedNumerically()
|
||||
{
|
||||
var result = _normalizer.Normalize(["CWE-200", "CWE-79", "CWE-120", "CWE-1"]);
|
||||
Assert.Equal("CWE-1,CWE-79,CWE-120,CWE-200", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_LargeNumbers_ReturnsSortedNumerically()
|
||||
{
|
||||
var result = _normalizer.Normalize(["CWE-1000", "CWE-100", "CWE-10"]);
|
||||
Assert.Equal("CWE-10,CWE-100,CWE-1000", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Empty and Null
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Null_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(null);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_EmptyArray_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize([]);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ArrayWithNulls_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize([null!, null!]);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ArrayWithEmptyStrings_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(["", " ", string.Empty]);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_MixedValidAndEmpty_ReturnsValidOnly()
|
||||
{
|
||||
var result = _normalizer.Normalize(["CWE-79", "", null!, "CWE-89", " "]);
|
||||
Assert.Equal("CWE-79,CWE-89", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Malformed Input
|
||||
|
||||
[Fact]
|
||||
public void Normalize_InvalidFormat_FiltersOut()
|
||||
{
|
||||
var result = _normalizer.Normalize(["CWE-79", "not-a-cwe", "CWE-89"]);
|
||||
Assert.Equal("CWE-79,CWE-89", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_AllInvalid_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(["invalid", "not-cwe", "random"]);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NonNumericSuffix_FiltersOut()
|
||||
{
|
||||
var result = _normalizer.Normalize(["CWE-ABC", "CWE-79"]);
|
||||
Assert.Equal("CWE-79", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithWhitespace_ReturnsTrimmed()
|
||||
{
|
||||
var result = _normalizer.Normalize([" CWE-79 ", " CWE-89 "]);
|
||||
Assert.Equal("CWE-79,CWE-89", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Unicode
|
||||
|
||||
[Fact]
|
||||
public void Normalize_UnicodeWhitespace_ReturnsTrimmed()
|
||||
{
|
||||
var result = _normalizer.Normalize(["\u00A0CWE-79\u00A0"]);
|
||||
Assert.Equal("CWE-79", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism
|
||||
|
||||
[Fact]
|
||||
public void Normalize_MultipleRuns_ReturnsSameResult()
|
||||
{
|
||||
var input = new[] { "cwe-89", "CWE-79", "120" };
|
||||
var first = _normalizer.Normalize(input);
|
||||
var second = _normalizer.Normalize(input);
|
||||
var third = _normalizer.Normalize(input);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.Equal(second, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Determinism_100Runs()
|
||||
{
|
||||
var input = new[] { "CWE-200", "cwe-79", "120", "CWE-89" };
|
||||
var expected = _normalizer.Normalize(input);
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_DifferentOrdering_ReturnsSameResult()
|
||||
{
|
||||
var input1 = new[] { "CWE-79", "CWE-89", "CWE-120" };
|
||||
var input2 = new[] { "CWE-120", "CWE-79", "CWE-89" };
|
||||
var input3 = new[] { "CWE-89", "CWE-120", "CWE-79" };
|
||||
|
||||
var result1 = _normalizer.Normalize(input1);
|
||||
var result2 = _normalizer.Normalize(input2);
|
||||
var result3 = _normalizer.Normalize(input3);
|
||||
|
||||
Assert.Equal(result1, result2);
|
||||
Assert.Equal(result2, result3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Real-World CWE Formats
|
||||
|
||||
[Theory]
|
||||
[InlineData("CWE-79", "CWE-79")] // XSS
|
||||
[InlineData("CWE-89", "CWE-89")] // SQL Injection
|
||||
[InlineData("CWE-120", "CWE-120")] // Buffer Overflow
|
||||
[InlineData("CWE-200", "CWE-200")] // Information Exposure
|
||||
[InlineData("CWE-22", "CWE-22")] // Path Traversal
|
||||
public void Normalize_RealWorldCwes_ReturnsExpected(string input, string expected)
|
||||
{
|
||||
var result = _normalizer.Normalize([input]);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Singleton Instance
|
||||
|
||||
[Fact]
|
||||
public void Instance_ReturnsSameInstance()
|
||||
{
|
||||
var instance1 = CweNormalizer.Instance;
|
||||
var instance2 = CweNormalizer.Instance;
|
||||
Assert.Same(instance1, instance2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MergeHashCalculatorTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-012
|
||||
// Description: Unit tests for MergeHashCalculator - determinism and correctness
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.Merge.Identity;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests.Identity;
|
||||
|
||||
public sealed class MergeHashCalculatorTests
|
||||
{
|
||||
private readonly MergeHashCalculator _calculator = new();
|
||||
|
||||
#region Basic Hash Computation
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_ValidInput_ReturnsHashWithPrefix()
|
||||
{
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/lodash@4.17.21"
|
||||
};
|
||||
|
||||
var result = _calculator.ComputeMergeHash(input);
|
||||
|
||||
Assert.StartsWith("sha256:", result);
|
||||
Assert.Equal(71, result.Length); // "sha256:" (7) + 64 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_WithAllFields_ReturnsHash()
|
||||
{
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/lodash@4.17.21",
|
||||
VersionRange = "[1.0.0, 2.0.0)",
|
||||
Weaknesses = ["CWE-79", "CWE-89"],
|
||||
PatchLineage = "https://github.com/lodash/lodash/commit/abc1234"
|
||||
};
|
||||
|
||||
var result = _calculator.ComputeMergeHash(input);
|
||||
|
||||
Assert.StartsWith("sha256:", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_NullInput_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => _calculator.ComputeMergeHash((MergeHashInput)null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism - Same Input = Same Output
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_SameInput_ReturnsSameHash()
|
||||
{
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/lodash@4.17.21",
|
||||
Weaknesses = ["CWE-79"]
|
||||
};
|
||||
|
||||
var first = _calculator.ComputeMergeHash(input);
|
||||
var second = _calculator.ComputeMergeHash(input);
|
||||
var third = _calculator.ComputeMergeHash(input);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.Equal(second, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_Determinism_100Runs()
|
||||
{
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-99999",
|
||||
AffectsKey = "pkg:maven/org.apache/commons-lang3@3.12.0",
|
||||
VersionRange = ">=1.0.0,<2.0.0",
|
||||
Weaknesses = ["CWE-120", "CWE-200", "CWE-79"],
|
||||
PatchLineage = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
};
|
||||
|
||||
var expected = _calculator.ComputeMergeHash(input);
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var result = _calculator.ComputeMergeHash(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_NewInstancesProduceSameHash()
|
||||
{
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/lodash@4.17.21"
|
||||
};
|
||||
|
||||
var calc1 = new MergeHashCalculator();
|
||||
var calc2 = new MergeHashCalculator();
|
||||
var calc3 = new MergeHashCalculator();
|
||||
|
||||
var hash1 = calc1.ComputeMergeHash(input);
|
||||
var hash2 = calc2.ComputeMergeHash(input);
|
||||
var hash3 = calc3.ComputeMergeHash(input);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.Equal(hash2, hash3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Normalization Integration
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_CveNormalization_CaseInsensitive()
|
||||
{
|
||||
var input1 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/test@1.0" };
|
||||
var input2 = new MergeHashInput { Cve = "cve-2024-1234", AffectsKey = "pkg:npm/test@1.0" };
|
||||
var input3 = new MergeHashInput { Cve = "Cve-2024-1234", AffectsKey = "pkg:npm/test@1.0" };
|
||||
|
||||
var hash1 = _calculator.ComputeMergeHash(input1);
|
||||
var hash2 = _calculator.ComputeMergeHash(input2);
|
||||
var hash3 = _calculator.ComputeMergeHash(input3);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.Equal(hash2, hash3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_PurlNormalization_TypeCaseInsensitive()
|
||||
{
|
||||
var input1 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/lodash@1.0" };
|
||||
var input2 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:NPM/lodash@1.0" };
|
||||
|
||||
var hash1 = _calculator.ComputeMergeHash(input1);
|
||||
var hash2 = _calculator.ComputeMergeHash(input2);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_CweNormalization_OrderIndependent()
|
||||
{
|
||||
var input1 = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
Weaknesses = ["CWE-79", "CWE-89", "CWE-120"]
|
||||
};
|
||||
var input2 = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
Weaknesses = ["CWE-120", "CWE-79", "CWE-89"]
|
||||
};
|
||||
var input3 = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
Weaknesses = ["cwe-89", "CWE-120", "cwe-79"]
|
||||
};
|
||||
|
||||
var hash1 = _calculator.ComputeMergeHash(input1);
|
||||
var hash2 = _calculator.ComputeMergeHash(input2);
|
||||
var hash3 = _calculator.ComputeMergeHash(input3);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.Equal(hash2, hash3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_VersionRangeNormalization_EquivalentFormats()
|
||||
{
|
||||
var input1 = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
VersionRange = "[1.0.0, 2.0.0)"
|
||||
};
|
||||
var input2 = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
VersionRange = ">=1.0.0,<2.0.0"
|
||||
};
|
||||
|
||||
var hash1 = _calculator.ComputeMergeHash(input1);
|
||||
var hash2 = _calculator.ComputeMergeHash(input2);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_PatchLineageNormalization_ShaExtraction()
|
||||
{
|
||||
// Both inputs contain the same SHA in different formats
|
||||
// The normalizer extracts "abc1234567" from both
|
||||
var input1 = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
PatchLineage = "commit abc1234567"
|
||||
};
|
||||
var input2 = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
PatchLineage = "fix abc1234567 applied"
|
||||
};
|
||||
|
||||
var hash1 = _calculator.ComputeMergeHash(input1);
|
||||
var hash2 = _calculator.ComputeMergeHash(input2);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Different Inputs = Different Hashes
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_DifferentCve_DifferentHash()
|
||||
{
|
||||
var input1 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/test@1.0" };
|
||||
var input2 = new MergeHashInput { Cve = "CVE-2024-5678", AffectsKey = "pkg:npm/test@1.0" };
|
||||
|
||||
var hash1 = _calculator.ComputeMergeHash(input1);
|
||||
var hash2 = _calculator.ComputeMergeHash(input2);
|
||||
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_DifferentPackage_DifferentHash()
|
||||
{
|
||||
var input1 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/lodash@1.0" };
|
||||
var input2 = new MergeHashInput { Cve = "CVE-2024-1234", AffectsKey = "pkg:npm/underscore@1.0" };
|
||||
|
||||
var hash1 = _calculator.ComputeMergeHash(input1);
|
||||
var hash2 = _calculator.ComputeMergeHash(input2);
|
||||
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_DifferentVersion_DifferentHash()
|
||||
{
|
||||
var input1 = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
VersionRange = "<1.0.0"
|
||||
};
|
||||
var input2 = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
VersionRange = "<2.0.0"
|
||||
};
|
||||
|
||||
var hash1 = _calculator.ComputeMergeHash(input1);
|
||||
var hash2 = _calculator.ComputeMergeHash(input2);
|
||||
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_DifferentWeaknesses_DifferentHash()
|
||||
{
|
||||
var input1 = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
Weaknesses = ["CWE-79"]
|
||||
};
|
||||
var input2 = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
Weaknesses = ["CWE-89"]
|
||||
};
|
||||
|
||||
var hash1 = _calculator.ComputeMergeHash(input1);
|
||||
var hash2 = _calculator.ComputeMergeHash(input2);
|
||||
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_DifferentPatchLineage_DifferentHash()
|
||||
{
|
||||
// Use full SHA hashes (40 chars) that will be recognized
|
||||
var input1 = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
PatchLineage = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
};
|
||||
var input2 = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
PatchLineage = "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5"
|
||||
};
|
||||
|
||||
var hash1 = _calculator.ComputeMergeHash(input1);
|
||||
var hash2 = _calculator.ComputeMergeHash(input2);
|
||||
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Optional Fields
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_NoVersionRange_ReturnsHash()
|
||||
{
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
VersionRange = null
|
||||
};
|
||||
|
||||
var result = _calculator.ComputeMergeHash(input);
|
||||
Assert.StartsWith("sha256:", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_EmptyWeaknesses_ReturnsHash()
|
||||
{
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
Weaknesses = []
|
||||
};
|
||||
|
||||
var result = _calculator.ComputeMergeHash(input);
|
||||
Assert.StartsWith("sha256:", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_NoPatchLineage_ReturnsHash()
|
||||
{
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0",
|
||||
PatchLineage = null
|
||||
};
|
||||
|
||||
var result = _calculator.ComputeMergeHash(input);
|
||||
Assert.StartsWith("sha256:", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_MinimalInput_ReturnsHash()
|
||||
{
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0"
|
||||
};
|
||||
|
||||
var result = _calculator.ComputeMergeHash(input);
|
||||
Assert.StartsWith("sha256:", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cross-Source Deduplication Scenarios
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_SameCveDifferentDistros_SameHash()
|
||||
{
|
||||
// Same CVE from Debian and RHEL should have same merge hash
|
||||
// when identity components match
|
||||
var debianInput = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:deb/debian/curl@7.68.0",
|
||||
VersionRange = "<7.68.0-1",
|
||||
Weaknesses = ["CWE-120"]
|
||||
};
|
||||
|
||||
var rhelInput = new MergeHashInput
|
||||
{
|
||||
Cve = "cve-2024-1234", // Different case
|
||||
AffectsKey = "pkg:deb/debian/curl@7.68.0", // Same package identity
|
||||
VersionRange = "[,7.68.0-1)", // Equivalent interval
|
||||
Weaknesses = ["cwe-120"] // Different case
|
||||
};
|
||||
|
||||
var debianHash = _calculator.ComputeMergeHash(debianInput);
|
||||
var rhelHash = _calculator.ComputeMergeHash(rhelInput);
|
||||
|
||||
// These should produce the same hash after normalization
|
||||
Assert.Equal(debianHash, rhelHash);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Hash Format Validation
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_ValidHashFormat()
|
||||
{
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0"
|
||||
};
|
||||
|
||||
var result = _calculator.ComputeMergeHash(input);
|
||||
|
||||
// Should be "sha256:" followed by 64 lowercase hex chars
|
||||
Assert.Matches(@"^sha256:[0-9a-f]{64}$", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMergeHash_HashIsLowercase()
|
||||
{
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
AffectsKey = "pkg:npm/test@1.0"
|
||||
};
|
||||
|
||||
var result = _calculator.ComputeMergeHash(input);
|
||||
var hashPart = result["sha256:".Length..];
|
||||
|
||||
Assert.Equal(hashPart.ToLowerInvariant(), hashPart);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MergeHashDeduplicationIntegrationTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-021
|
||||
// Description: Integration tests validating same CVE from different connectors
|
||||
// produces identical merge hash when semantically equivalent
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.Merge.Identity;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that verify merge hash deduplication behavior
|
||||
/// when the same CVE is ingested from multiple source connectors.
|
||||
/// </summary>
|
||||
public sealed class MergeHashDeduplicationIntegrationTests
|
||||
{
|
||||
private readonly MergeHashCalculator _calculator = new();
|
||||
|
||||
[Fact]
|
||||
public void SameCve_FromDebianAndRhel_WithSamePackage_ProducesSameMergeHash()
|
||||
{
|
||||
// Arrange - Debian advisory for curl vulnerability
|
||||
var debianProvenance = new AdvisoryProvenance(
|
||||
"debian", "dsa", "DSA-5678-1", DateTimeOffset.Parse("2024-02-15T00:00:00Z"));
|
||||
var debianAdvisory = new Advisory(
|
||||
"CVE-2024-1234",
|
||||
"curl - security update",
|
||||
"Buffer overflow in curl HTTP library",
|
||||
"en",
|
||||
DateTimeOffset.Parse("2024-02-10T00:00:00Z"),
|
||||
DateTimeOffset.Parse("2024-02-15T12:00:00Z"),
|
||||
"high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2024-1234", "DSA-5678-1" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference("https://security-tracker.debian.org/tracker/CVE-2024-1234", "advisory", "debian", "Debian tracker", debianProvenance)
|
||||
},
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.Deb,
|
||||
"pkg:deb/debian/curl@7.68.0",
|
||||
"linux",
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange("semver", null, "7.68.0-1+deb10u2", null, "<7.68.0-1+deb10u2", debianProvenance)
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { debianProvenance })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { debianProvenance },
|
||||
cwes: new[]
|
||||
{
|
||||
new AdvisoryWeakness("cwe", "CWE-120", null, null, ImmutableArray.Create(debianProvenance))
|
||||
});
|
||||
|
||||
// Arrange - RHEL advisory for the same curl vulnerability
|
||||
var rhelProvenance = new AdvisoryProvenance(
|
||||
"redhat", "rhsa", "RHSA-2024:1234", DateTimeOffset.Parse("2024-02-16T00:00:00Z"));
|
||||
var rhelAdvisory = new Advisory(
|
||||
"CVE-2024-1234",
|
||||
"Moderate: curl security update",
|
||||
"curl: buffer overflow vulnerability",
|
||||
"en",
|
||||
DateTimeOffset.Parse("2024-02-12T00:00:00Z"),
|
||||
DateTimeOffset.Parse("2024-02-16T08:00:00Z"),
|
||||
"moderate",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2024-1234", "RHSA-2024:1234" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference("https://access.redhat.com/errata/RHSA-2024:1234", "advisory", "redhat", "Red Hat errata", rhelProvenance)
|
||||
},
|
||||
affectedPackages: new[]
|
||||
{
|
||||
// Same logical package, just different distro versioning
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.Deb,
|
||||
"pkg:deb/debian/curl@7.68.0",
|
||||
"linux",
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange("semver", null, "7.68.0-1+deb10u2", null, "<7.68.0-1+deb10u2", rhelProvenance)
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { rhelProvenance })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { rhelProvenance },
|
||||
cwes: new[]
|
||||
{
|
||||
// Same CWE but lowercase - should normalize
|
||||
new AdvisoryWeakness("cwe", "cwe-120", null, null, ImmutableArray.Create(rhelProvenance))
|
||||
});
|
||||
|
||||
// Act
|
||||
var debianHash = _calculator.ComputeMergeHash(debianAdvisory);
|
||||
var rhelHash = _calculator.ComputeMergeHash(rhelAdvisory);
|
||||
|
||||
// Assert - Same CVE, same package, same version range, same CWE => same hash
|
||||
Assert.Equal(debianHash, rhelHash);
|
||||
Assert.StartsWith("sha256:", debianHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameCve_FromNvdAndGhsa_WithDifferentPackages_ProducesDifferentMergeHash()
|
||||
{
|
||||
// Arrange - NVD advisory affecting lodash
|
||||
var nvdProvenance = new AdvisoryProvenance(
|
||||
"nvd", "cve", "CVE-2024-5678", DateTimeOffset.Parse("2024-03-01T00:00:00Z"));
|
||||
var nvdAdvisory = new Advisory(
|
||||
"CVE-2024-5678",
|
||||
"Prototype pollution in lodash",
|
||||
"lodash before 4.17.21 is vulnerable to prototype pollution",
|
||||
"en",
|
||||
DateTimeOffset.Parse("2024-02-28T00:00:00Z"),
|
||||
DateTimeOffset.Parse("2024-03-01T00:00:00Z"),
|
||||
"high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2024-5678" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:npm/lodash@4.17.0",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange("semver", "0", "4.17.21", null, "<4.17.21", nvdProvenance)
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { nvdProvenance })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { nvdProvenance },
|
||||
cwes: new[]
|
||||
{
|
||||
new AdvisoryWeakness("cwe", "CWE-1321", null, null, ImmutableArray.Create(nvdProvenance))
|
||||
});
|
||||
|
||||
// Arrange - Same CVE but for underscore (related but different package)
|
||||
var ghsaProvenance = new AdvisoryProvenance(
|
||||
"ghsa", "advisory", "GHSA-xyz-abc-123", DateTimeOffset.Parse("2024-03-02T00:00:00Z"));
|
||||
var ghsaAdvisory = new Advisory(
|
||||
"CVE-2024-5678",
|
||||
"Prototype pollution in underscore",
|
||||
"underscore before 1.13.6 is vulnerable",
|
||||
"en",
|
||||
DateTimeOffset.Parse("2024-03-01T00:00:00Z"),
|
||||
DateTimeOffset.Parse("2024-03-02T00:00:00Z"),
|
||||
"high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2024-5678", "GHSA-xyz-abc-123" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:npm/underscore@1.13.0",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange("semver", "0", "1.13.6", null, "<1.13.6", ghsaProvenance)
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { ghsaProvenance })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { ghsaProvenance },
|
||||
cwes: new[]
|
||||
{
|
||||
new AdvisoryWeakness("cwe", "CWE-1321", null, null, ImmutableArray.Create(ghsaProvenance))
|
||||
});
|
||||
|
||||
// Act
|
||||
var nvdHash = _calculator.ComputeMergeHash(nvdAdvisory);
|
||||
var ghsaHash = _calculator.ComputeMergeHash(ghsaAdvisory);
|
||||
|
||||
// Assert - Same CVE but different packages => different hash
|
||||
Assert.NotEqual(nvdHash, ghsaHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameCve_WithCaseVariations_ProducesSameMergeHash()
|
||||
{
|
||||
// Arrange - Advisory with uppercase identifiers
|
||||
var upperProvenance = new AdvisoryProvenance(
|
||||
"nvd", "cve", "CVE-2024-9999", DateTimeOffset.UtcNow);
|
||||
var upperAdvisory = new Advisory(
|
||||
"CVE-2024-9999",
|
||||
"Test vulnerability",
|
||||
null,
|
||||
"en",
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow,
|
||||
"high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2024-9999" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:NPM/@angular/CORE@14.0.0",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange("semver", null, "14.2.0", null, "<14.2.0", upperProvenance)
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { upperProvenance })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { upperProvenance },
|
||||
cwes: new[]
|
||||
{
|
||||
new AdvisoryWeakness("cwe", "CWE-79", null, null, ImmutableArray.Create(upperProvenance))
|
||||
});
|
||||
|
||||
// Arrange - Same advisory with lowercase identifiers
|
||||
var lowerProvenance = new AdvisoryProvenance(
|
||||
"osv", "advisory", "cve-2024-9999", DateTimeOffset.UtcNow);
|
||||
var lowerAdvisory = new Advisory(
|
||||
"cve-2024-9999",
|
||||
"Test vulnerability",
|
||||
null,
|
||||
"en",
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow,
|
||||
"high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "cve-2024-9999" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:npm/@angular/core@14.0.0",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange("semver", null, "14.2.0", null, "<14.2.0", lowerProvenance)
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { lowerProvenance })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { lowerProvenance },
|
||||
cwes: new[]
|
||||
{
|
||||
new AdvisoryWeakness("cwe", "cwe-79", null, null, ImmutableArray.Create(lowerProvenance))
|
||||
});
|
||||
|
||||
// Act
|
||||
var upperHash = _calculator.ComputeMergeHash(upperAdvisory);
|
||||
var lowerHash = _calculator.ComputeMergeHash(lowerAdvisory);
|
||||
|
||||
// Assert - Case normalization produces identical hash
|
||||
Assert.Equal(upperHash, lowerHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameCve_WithDifferentCweSet_ProducesDifferentMergeHash()
|
||||
{
|
||||
// Arrange - Advisory with one CWE
|
||||
var prov1 = new AdvisoryProvenance("nvd", "cve", "CVE-2024-1111", DateTimeOffset.UtcNow);
|
||||
var advisory1 = new Advisory(
|
||||
"CVE-2024-1111",
|
||||
"Test vulnerability",
|
||||
null,
|
||||
"en",
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow,
|
||||
"high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2024-1111" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:npm/test@1.0.0",
|
||||
null,
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { prov1 })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { prov1 },
|
||||
cwes: new[]
|
||||
{
|
||||
new AdvisoryWeakness("cwe", "CWE-79", null, null, ImmutableArray.Create(prov1))
|
||||
});
|
||||
|
||||
// Arrange - Same CVE but with additional CWEs
|
||||
var prov2 = new AdvisoryProvenance("ghsa", "advisory", "CVE-2024-1111", DateTimeOffset.UtcNow);
|
||||
var advisory2 = new Advisory(
|
||||
"CVE-2024-1111",
|
||||
"Test vulnerability",
|
||||
null,
|
||||
"en",
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow,
|
||||
"high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2024-1111" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:npm/test@1.0.0",
|
||||
null,
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { prov2 })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { prov2 },
|
||||
cwes: new[]
|
||||
{
|
||||
new AdvisoryWeakness("cwe", "CWE-79", null, null, ImmutableArray.Create(prov2)),
|
||||
new AdvisoryWeakness("cwe", "CWE-89", null, null, ImmutableArray.Create(prov2))
|
||||
});
|
||||
|
||||
// Act
|
||||
var hash1 = _calculator.ComputeMergeHash(advisory1);
|
||||
var hash2 = _calculator.ComputeMergeHash(advisory2);
|
||||
|
||||
// Assert - Different CWE sets produce different hashes
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiplePackageAdvisory_ComputesHashFromFirstPackage()
|
||||
{
|
||||
// Arrange - Advisory affecting multiple packages
|
||||
var provenance = new AdvisoryProvenance(
|
||||
"osv", "advisory", "CVE-2024-MULTI", DateTimeOffset.UtcNow);
|
||||
var multiPackageAdvisory = new Advisory(
|
||||
"CVE-2024-MULTI",
|
||||
"Multi-package vulnerability",
|
||||
null,
|
||||
"en",
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow,
|
||||
"critical",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2024-MULTI" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:npm/first-package@1.0.0",
|
||||
null,
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { provenance }),
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:npm/second-package@2.0.0",
|
||||
null,
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { provenance })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { provenance });
|
||||
|
||||
// Arrange - Advisory with only the first package
|
||||
var singlePackageAdvisory = new Advisory(
|
||||
"CVE-2024-MULTI",
|
||||
"Single package vulnerability",
|
||||
null,
|
||||
"en",
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow,
|
||||
"critical",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2024-MULTI" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:npm/first-package@1.0.0",
|
||||
null,
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { provenance })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { provenance });
|
||||
|
||||
// Act
|
||||
var multiHash = _calculator.ComputeMergeHash(multiPackageAdvisory);
|
||||
var singleHash = _calculator.ComputeMergeHash(singlePackageAdvisory);
|
||||
|
||||
// Assert - Both use first package, so hashes should match
|
||||
Assert.Equal(multiHash, singleHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeHash_SpecificPackage_ComputesDifferentHashPerPackage()
|
||||
{
|
||||
// Arrange
|
||||
var provenance = new AdvisoryProvenance(
|
||||
"osv", "advisory", "CVE-2024-PERPACK", DateTimeOffset.UtcNow);
|
||||
var multiPackageAdvisory = new Advisory(
|
||||
"CVE-2024-PERPACK",
|
||||
"Multi-package vulnerability",
|
||||
null,
|
||||
"en",
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow,
|
||||
"critical",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2024-PERPACK" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:npm/package-a@1.0.0",
|
||||
null,
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { provenance }),
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.SemVer,
|
||||
"pkg:npm/package-b@2.0.0",
|
||||
null,
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { provenance })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { provenance });
|
||||
|
||||
// Act - Compute hash for each affected package
|
||||
var hashA = _calculator.ComputeMergeHash(multiPackageAdvisory, multiPackageAdvisory.AffectedPackages[0]);
|
||||
var hashB = _calculator.ComputeMergeHash(multiPackageAdvisory, multiPackageAdvisory.AffectedPackages[1]);
|
||||
|
||||
// Assert - Different packages produce different hashes
|
||||
Assert.NotEqual(hashA, hashB);
|
||||
Assert.StartsWith("sha256:", hashA);
|
||||
Assert.StartsWith("sha256:", hashB);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MergeHashFuzzingTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-017
|
||||
// Description: Fuzzing tests for malformed version ranges and unusual PURLs
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.Merge.Identity;
|
||||
using StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests.Identity;
|
||||
|
||||
public sealed class MergeHashFuzzingTests
|
||||
{
|
||||
private readonly MergeHashCalculator _calculator = new();
|
||||
private readonly Random _random = new(42); // Fixed seed for reproducibility
|
||||
|
||||
private const int FuzzIterations = 1000;
|
||||
|
||||
#region PURL Fuzzing
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Fuzzing")]
|
||||
public void PurlNormalizer_RandomInputs_DoesNotThrow()
|
||||
{
|
||||
var normalizer = PurlNormalizer.Instance;
|
||||
|
||||
for (var i = 0; i < FuzzIterations; i++)
|
||||
{
|
||||
var input = GenerateRandomPurl();
|
||||
var exception = Record.Exception(() => normalizer.Normalize(input));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", "Fuzzing")]
|
||||
[InlineData("pkg:")]
|
||||
[InlineData("pkg:npm")]
|
||||
[InlineData("pkg:npm/")]
|
||||
[InlineData("pkg:npm//")]
|
||||
[InlineData("pkg:npm/@/")]
|
||||
[InlineData("pkg:npm/@scope/")]
|
||||
[InlineData("pkg:npm/pkg@")]
|
||||
[InlineData("pkg:npm/pkg@version?")]
|
||||
[InlineData("pkg:npm/pkg@version?qualifier")]
|
||||
[InlineData("pkg:npm/pkg@version?key=")]
|
||||
[InlineData("pkg:npm/pkg@version?=value")]
|
||||
[InlineData("pkg:npm/pkg#")]
|
||||
[InlineData("pkg:npm/pkg#/")]
|
||||
[InlineData("pkg:///")]
|
||||
[InlineData("pkg:type/ns/name@v?q=v#sp")]
|
||||
[InlineData("pkg:UNKNOWN/package@1.0.0")]
|
||||
public void PurlNormalizer_MalformedInputs_DoesNotThrow(string input)
|
||||
{
|
||||
var normalizer = PurlNormalizer.Instance;
|
||||
var exception = Record.Exception(() => normalizer.Normalize(input));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", "Fuzzing")]
|
||||
[InlineData("pkg:npm/\0package@1.0.0")]
|
||||
[InlineData("pkg:npm/package\u0000@1.0.0")]
|
||||
[InlineData("pkg:npm/package@1.0.0\t")]
|
||||
[InlineData("pkg:npm/package@1.0.0\n")]
|
||||
[InlineData("pkg:npm/package@1.0.0\r")]
|
||||
[InlineData("pkg:npm/päckage@1.0.0")]
|
||||
[InlineData("pkg:npm/包裹@1.0.0")]
|
||||
[InlineData("pkg:npm/📦@1.0.0")]
|
||||
public void PurlNormalizer_SpecialCharacters_DoesNotThrow(string input)
|
||||
{
|
||||
var normalizer = PurlNormalizer.Instance;
|
||||
var exception = Record.Exception(() => normalizer.Normalize(input));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Version Range Fuzzing
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Fuzzing")]
|
||||
public void VersionRangeNormalizer_RandomInputs_DoesNotThrow()
|
||||
{
|
||||
var normalizer = VersionRangeNormalizer.Instance;
|
||||
|
||||
for (var i = 0; i < FuzzIterations; i++)
|
||||
{
|
||||
var input = GenerateRandomVersionRange();
|
||||
var exception = Record.Exception(() => normalizer.Normalize(input));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", "Fuzzing")]
|
||||
[InlineData("[")]
|
||||
[InlineData("(")]
|
||||
[InlineData("]")]
|
||||
[InlineData(")")]
|
||||
[InlineData("[,")]
|
||||
[InlineData(",]")]
|
||||
[InlineData("[,]")]
|
||||
[InlineData("(,)")]
|
||||
[InlineData("[1.0")]
|
||||
[InlineData("1.0]")]
|
||||
[InlineData("[1.0,")]
|
||||
[InlineData(",1.0]")]
|
||||
[InlineData(">=")]
|
||||
[InlineData("<=")]
|
||||
[InlineData(">")]
|
||||
[InlineData("<")]
|
||||
[InlineData("=")]
|
||||
[InlineData("!=")]
|
||||
[InlineData("~")]
|
||||
[InlineData("^")]
|
||||
[InlineData(">=<")]
|
||||
[InlineData("<=>")]
|
||||
[InlineData(">=1.0<2.0")]
|
||||
[InlineData("1.0-2.0")]
|
||||
[InlineData("1.0..2.0")]
|
||||
[InlineData("v1.0.0")]
|
||||
[InlineData("version1")]
|
||||
public void VersionRangeNormalizer_MalformedInputs_DoesNotThrow(string input)
|
||||
{
|
||||
var normalizer = VersionRangeNormalizer.Instance;
|
||||
var exception = Record.Exception(() => normalizer.Normalize(input));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CPE Fuzzing
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Fuzzing")]
|
||||
public void CpeNormalizer_RandomInputs_DoesNotThrow()
|
||||
{
|
||||
var normalizer = CpeNormalizer.Instance;
|
||||
|
||||
for (var i = 0; i < FuzzIterations; i++)
|
||||
{
|
||||
var input = GenerateRandomCpe();
|
||||
var exception = Record.Exception(() => normalizer.Normalize(input));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", "Fuzzing")]
|
||||
[InlineData("cpe:")]
|
||||
[InlineData("cpe:/")]
|
||||
[InlineData("cpe://")]
|
||||
[InlineData("cpe:2.3")]
|
||||
[InlineData("cpe:2.3:")]
|
||||
[InlineData("cpe:2.3:a")]
|
||||
[InlineData("cpe:2.3:a:")]
|
||||
[InlineData("cpe:2.3:x:vendor:product:1.0:*:*:*:*:*:*:*")]
|
||||
[InlineData("cpe:1.0:a:vendor:product:1.0")]
|
||||
[InlineData("cpe:3.0:a:vendor:product:1.0")]
|
||||
[InlineData("cpe:2.3:a:::::::::")]
|
||||
[InlineData("cpe:2.3:a:vendor:product:::::::::")]
|
||||
public void CpeNormalizer_MalformedInputs_DoesNotThrow(string input)
|
||||
{
|
||||
var normalizer = CpeNormalizer.Instance;
|
||||
var exception = Record.Exception(() => normalizer.Normalize(input));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CVE Fuzzing
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", "Fuzzing")]
|
||||
[InlineData("CVE")]
|
||||
[InlineData("CVE-")]
|
||||
[InlineData("CVE-2024")]
|
||||
[InlineData("CVE-2024-")]
|
||||
[InlineData("CVE-2024-1")]
|
||||
[InlineData("CVE-2024-12")]
|
||||
[InlineData("CVE-2024-123")]
|
||||
[InlineData("CVE-24-1234")]
|
||||
[InlineData("CVE-202-1234")]
|
||||
[InlineData("CVE-20245-1234")]
|
||||
[InlineData("CVE2024-1234")]
|
||||
[InlineData("CVE_2024_1234")]
|
||||
[InlineData("cve:2024:1234")]
|
||||
public void CveNormalizer_MalformedInputs_DoesNotThrow(string input)
|
||||
{
|
||||
var normalizer = CveNormalizer.Instance;
|
||||
var exception = Record.Exception(() => normalizer.Normalize(input));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CWE Fuzzing
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", "Fuzzing")]
|
||||
[InlineData("CWE")]
|
||||
[InlineData("CWE-")]
|
||||
[InlineData("CWE-abc")]
|
||||
[InlineData("CWE--79")]
|
||||
[InlineData("CWE79")]
|
||||
[InlineData("cwe79")]
|
||||
[InlineData("79CWE")]
|
||||
[InlineData("-79")]
|
||||
public void CweNormalizer_MalformedInputs_DoesNotThrow(string input)
|
||||
{
|
||||
var normalizer = CweNormalizer.Instance;
|
||||
var exception = Record.Exception(() => normalizer.Normalize([input]));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Fuzzing")]
|
||||
public void CweNormalizer_LargeLists_DoesNotThrow()
|
||||
{
|
||||
var normalizer = CweNormalizer.Instance;
|
||||
|
||||
// Test with large list of CWEs
|
||||
var largeCweList = Enumerable.Range(1, 1000)
|
||||
.Select(i => $"CWE-{i}")
|
||||
.ToList();
|
||||
|
||||
var exception = Record.Exception(() => normalizer.Normalize(largeCweList));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Patch Lineage Fuzzing
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", "Fuzzing")]
|
||||
[InlineData("abc")]
|
||||
[InlineData("abc123")]
|
||||
[InlineData("abc12")]
|
||||
[InlineData("12345")]
|
||||
[InlineData("GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG")]
|
||||
[InlineData("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz")]
|
||||
[InlineData("https://")]
|
||||
[InlineData("https://github.com")]
|
||||
[InlineData("https://github.com/")]
|
||||
[InlineData("https://github.com/owner")]
|
||||
[InlineData("https://github.com/owner/repo")]
|
||||
[InlineData("https://github.com/owner/repo/")]
|
||||
[InlineData("https://github.com/owner/repo/commit")]
|
||||
[InlineData("https://github.com/owner/repo/commit/")]
|
||||
[InlineData("PATCH")]
|
||||
[InlineData("PATCH-")]
|
||||
[InlineData("PATCH-abc")]
|
||||
[InlineData("patch12345")]
|
||||
public void PatchLineageNormalizer_MalformedInputs_DoesNotThrow(string input)
|
||||
{
|
||||
var normalizer = PatchLineageNormalizer.Instance;
|
||||
var exception = Record.Exception(() => normalizer.Normalize(input));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Full Hash Calculator Fuzzing
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Fuzzing")]
|
||||
public void MergeHashCalculator_RandomInputs_AlwaysProducesValidHash()
|
||||
{
|
||||
for (var i = 0; i < FuzzIterations; i++)
|
||||
{
|
||||
var input = GenerateRandomMergeHashInput();
|
||||
|
||||
var hash = _calculator.ComputeMergeHash(input);
|
||||
|
||||
Assert.NotNull(hash);
|
||||
Assert.StartsWith("sha256:", hash);
|
||||
Assert.Equal(71, hash.Length); // sha256: + 64 hex chars
|
||||
Assert.Matches(@"^sha256:[0-9a-f]{64}$", hash);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Fuzzing")]
|
||||
public void MergeHashCalculator_RandomInputs_IsDeterministic()
|
||||
{
|
||||
var inputs = new List<MergeHashInput>();
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
inputs.Add(GenerateRandomMergeHashInput());
|
||||
}
|
||||
|
||||
// First pass
|
||||
var firstHashes = inputs.Select(i => _calculator.ComputeMergeHash(i)).ToList();
|
||||
|
||||
// Second pass
|
||||
var secondHashes = inputs.Select(i => _calculator.ComputeMergeHash(i)).ToList();
|
||||
|
||||
// All should match
|
||||
for (var i = 0; i < inputs.Count; i++)
|
||||
{
|
||||
Assert.Equal(firstHashes[i], secondHashes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Random Input Generators
|
||||
|
||||
private string GenerateRandomPurl()
|
||||
{
|
||||
var types = new[] { "npm", "maven", "pypi", "nuget", "gem", "golang", "deb", "rpm", "apk", "cargo" };
|
||||
var type = types[_random.Next(types.Length)];
|
||||
|
||||
var hasNamespace = _random.Next(2) == 1;
|
||||
var hasVersion = _random.Next(2) == 1;
|
||||
var hasQualifiers = _random.Next(2) == 1;
|
||||
var hasSubpath = _random.Next(2) == 1;
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("pkg:");
|
||||
sb.Append(type);
|
||||
sb.Append('/');
|
||||
|
||||
if (hasNamespace)
|
||||
{
|
||||
sb.Append(GenerateRandomString(5));
|
||||
sb.Append('/');
|
||||
}
|
||||
|
||||
sb.Append(GenerateRandomString(8));
|
||||
|
||||
if (hasVersion)
|
||||
{
|
||||
sb.Append('@');
|
||||
sb.Append(GenerateRandomVersion());
|
||||
}
|
||||
|
||||
if (hasQualifiers)
|
||||
{
|
||||
sb.Append('?');
|
||||
sb.Append(GenerateRandomString(3));
|
||||
sb.Append('=');
|
||||
sb.Append(GenerateRandomString(5));
|
||||
}
|
||||
|
||||
if (hasSubpath)
|
||||
{
|
||||
sb.Append('#');
|
||||
sb.Append(GenerateRandomString(10));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string GenerateRandomVersionRange()
|
||||
{
|
||||
var patterns = new Func<string>[]
|
||||
{
|
||||
() => $"[{GenerateRandomVersion()}, {GenerateRandomVersion()})",
|
||||
() => $"({GenerateRandomVersion()}, {GenerateRandomVersion()}]",
|
||||
() => $">={GenerateRandomVersion()}",
|
||||
() => $"<{GenerateRandomVersion()}",
|
||||
() => $"={GenerateRandomVersion()}",
|
||||
() => $">={GenerateRandomVersion()},<{GenerateRandomVersion()}",
|
||||
() => $"fixed:{GenerateRandomVersion()}",
|
||||
() => "*",
|
||||
() => GenerateRandomVersion(),
|
||||
() => GenerateRandomString(10)
|
||||
};
|
||||
|
||||
return patterns[_random.Next(patterns.Length)]();
|
||||
}
|
||||
|
||||
private string GenerateRandomCpe()
|
||||
{
|
||||
if (_random.Next(2) == 0)
|
||||
{
|
||||
// CPE 2.3
|
||||
var part = new[] { "a", "o", "h" }[_random.Next(3)];
|
||||
return $"cpe:2.3:{part}:{GenerateRandomString(6)}:{GenerateRandomString(8)}:{GenerateRandomVersion()}:*:*:*:*:*:*:*";
|
||||
}
|
||||
else
|
||||
{
|
||||
// CPE 2.2
|
||||
var part = new[] { "a", "o", "h" }[_random.Next(3)];
|
||||
return $"cpe:/{part}:{GenerateRandomString(6)}:{GenerateRandomString(8)}:{GenerateRandomVersion()}";
|
||||
}
|
||||
}
|
||||
|
||||
private MergeHashInput GenerateRandomMergeHashInput()
|
||||
{
|
||||
return new MergeHashInput
|
||||
{
|
||||
Cve = $"CVE-{2020 + _random.Next(5)}-{_random.Next(10000, 99999)}",
|
||||
AffectsKey = GenerateRandomPurl(),
|
||||
VersionRange = _random.Next(3) > 0 ? GenerateRandomVersionRange() : null,
|
||||
Weaknesses = Enumerable.Range(0, _random.Next(0, 5))
|
||||
.Select(_ => $"CWE-{_random.Next(1, 1000)}")
|
||||
.ToList(),
|
||||
PatchLineage = _random.Next(3) > 0 ? GenerateRandomHex(40) : null
|
||||
};
|
||||
}
|
||||
|
||||
private string GenerateRandomVersion()
|
||||
{
|
||||
return $"{_random.Next(0, 20)}.{_random.Next(0, 50)}.{_random.Next(0, 100)}";
|
||||
}
|
||||
|
||||
private string GenerateRandomString(int length)
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
return new string(Enumerable.Range(0, length)
|
||||
.Select(_ => chars[_random.Next(chars.Length)])
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
private string GenerateRandomHex(int length)
|
||||
{
|
||||
const string hexChars = "0123456789abcdef";
|
||||
return new string(Enumerable.Range(0, length)
|
||||
.Select(_ => hexChars[_random.Next(hexChars.Length)])
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MergeHashGoldenCorpusTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-016
|
||||
// Description: Golden corpus tests for merge hash validation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using StellaOps.Concelier.Merge.Identity;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that validate merge hash computations against golden corpus files.
|
||||
/// Each corpus file contains pairs of advisory sources that should produce
|
||||
/// the same or different merge hashes based on identity normalization.
|
||||
/// </summary>
|
||||
public sealed class MergeHashGoldenCorpusTests
|
||||
{
|
||||
private readonly MergeHashCalculator _calculator = new();
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static string GetCorpusPath(string corpusName)
|
||||
{
|
||||
// Try multiple paths for test execution context
|
||||
var paths = new[]
|
||||
{
|
||||
Path.Combine("Fixtures", "Golden", corpusName),
|
||||
Path.Combine("..", "..", "..", "Fixtures", "Golden", corpusName),
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", "Golden", corpusName)
|
||||
};
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(string.Format("Corpus file not found: {0}", corpusName));
|
||||
}
|
||||
|
||||
#region Debian-RHEL Corpus Tests
|
||||
|
||||
[Fact]
|
||||
public void DeduplicateDebianRhelCorpus_AllItemsValidated()
|
||||
{
|
||||
var corpusPath = GetCorpusPath("dedup-debian-rhel-cve-2024.json");
|
||||
var corpus = LoadCorpus(corpusPath);
|
||||
|
||||
Assert.NotNull(corpus);
|
||||
Assert.NotEmpty(corpus.Items);
|
||||
|
||||
foreach (var item in corpus.Items)
|
||||
{
|
||||
ValidateCorpusItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeduplicateDebianRhelCorpus_SameMergeHashPairs()
|
||||
{
|
||||
var corpusPath = GetCorpusPath("dedup-debian-rhel-cve-2024.json");
|
||||
var corpus = LoadCorpus(corpusPath);
|
||||
|
||||
var sameHashItems = corpus.Items.Where(i => i.Expected.SameMergeHash).ToList();
|
||||
Assert.NotEmpty(sameHashItems);
|
||||
|
||||
foreach (var item in sameHashItems)
|
||||
{
|
||||
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
|
||||
|
||||
var hashes = item.Sources
|
||||
.Select(s => ComputeHashFromSource(s))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Assert.True(hashes.Count == 1, $"Item {item.Id}: Expected same merge hash but got {hashes.Count} distinct values: [{string.Join(", ", hashes)}]. Rationale: {item.Expected.Rationale}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeduplicateDebianRhelCorpus_DifferentMergeHashPairs()
|
||||
{
|
||||
var corpusPath = GetCorpusPath("dedup-debian-rhel-cve-2024.json");
|
||||
var corpus = LoadCorpus(corpusPath);
|
||||
|
||||
var differentHashItems = corpus.Items.Where(i => !i.Expected.SameMergeHash).ToList();
|
||||
Assert.NotEmpty(differentHashItems);
|
||||
|
||||
foreach (var item in differentHashItems)
|
||||
{
|
||||
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
|
||||
|
||||
var hashes = item.Sources
|
||||
.Select(s => ComputeHashFromSource(s))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Assert.True(hashes.Count > 1, $"Item {item.Id}: Expected different merge hashes but got same. Rationale: {item.Expected.Rationale}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Backport Variants Corpus Tests
|
||||
|
||||
[Fact]
|
||||
public void BackportVariantsCorpus_AllItemsValidated()
|
||||
{
|
||||
var corpusPath = GetCorpusPath("dedup-backport-variants.json");
|
||||
var corpus = LoadCorpus(corpusPath);
|
||||
|
||||
Assert.NotNull(corpus);
|
||||
Assert.NotEmpty(corpus.Items);
|
||||
|
||||
foreach (var item in corpus.Items)
|
||||
{
|
||||
ValidateCorpusItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackportVariantsCorpus_SameMergeHashPairs()
|
||||
{
|
||||
var corpusPath = GetCorpusPath("dedup-backport-variants.json");
|
||||
var corpus = LoadCorpus(corpusPath);
|
||||
|
||||
var sameHashItems = corpus.Items.Where(i => i.Expected.SameMergeHash).ToList();
|
||||
Assert.NotEmpty(sameHashItems);
|
||||
|
||||
foreach (var item in sameHashItems)
|
||||
{
|
||||
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
|
||||
|
||||
var hashes = item.Sources
|
||||
.Select(s => ComputeHashFromSource(s))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Assert.True(hashes.Count == 1, $"Item {item.Id}: Expected same merge hash but got {hashes.Count} distinct values: [{string.Join(", ", hashes)}]. Rationale: {item.Expected.Rationale}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackportVariantsCorpus_DifferentMergeHashPairs()
|
||||
{
|
||||
var corpusPath = GetCorpusPath("dedup-backport-variants.json");
|
||||
var corpus = LoadCorpus(corpusPath);
|
||||
|
||||
var differentHashItems = corpus.Items.Where(i => !i.Expected.SameMergeHash).ToList();
|
||||
Assert.NotEmpty(differentHashItems);
|
||||
|
||||
foreach (var item in differentHashItems)
|
||||
{
|
||||
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
|
||||
|
||||
var hashes = item.Sources
|
||||
.Select(s => ComputeHashFromSource(s))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Assert.True(hashes.Count > 1, $"Item {item.Id}: Expected different merge hashes but got same. Rationale: {item.Expected.Rationale}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Alias Collision Corpus Tests
|
||||
|
||||
[Fact]
|
||||
public void AliasCollisionCorpus_AllItemsValidated()
|
||||
{
|
||||
var corpusPath = GetCorpusPath("dedup-alias-collision.json");
|
||||
var corpus = LoadCorpus(corpusPath);
|
||||
|
||||
Assert.NotNull(corpus);
|
||||
Assert.NotEmpty(corpus.Items);
|
||||
|
||||
foreach (var item in corpus.Items)
|
||||
{
|
||||
ValidateCorpusItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AliasCollisionCorpus_SameMergeHashPairs()
|
||||
{
|
||||
var corpusPath = GetCorpusPath("dedup-alias-collision.json");
|
||||
var corpus = LoadCorpus(corpusPath);
|
||||
|
||||
var sameHashItems = corpus.Items.Where(i => i.Expected.SameMergeHash).ToList();
|
||||
Assert.NotEmpty(sameHashItems);
|
||||
|
||||
foreach (var item in sameHashItems)
|
||||
{
|
||||
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
|
||||
|
||||
var hashes = item.Sources
|
||||
.Select(s => ComputeHashFromSource(s))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Assert.True(hashes.Count == 1, $"Item {item.Id}: Expected same merge hash but got {hashes.Count} distinct values: [{string.Join(", ", hashes)}]. Rationale: {item.Expected.Rationale}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AliasCollisionCorpus_DifferentMergeHashPairs()
|
||||
{
|
||||
var corpusPath = GetCorpusPath("dedup-alias-collision.json");
|
||||
var corpus = LoadCorpus(corpusPath);
|
||||
|
||||
var differentHashItems = corpus.Items.Where(i => !i.Expected.SameMergeHash).ToList();
|
||||
Assert.NotEmpty(differentHashItems);
|
||||
|
||||
foreach (var item in differentHashItems)
|
||||
{
|
||||
Assert.True(item.Sources.Count >= 2, $"Item {item.Id} needs at least 2 sources");
|
||||
|
||||
var hashes = item.Sources
|
||||
.Select(s => ComputeHashFromSource(s))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Assert.True(hashes.Count > 1, $"Item {item.Id}: Expected different merge hashes but got same. Rationale: {item.Expected.Rationale}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private GoldenCorpus LoadCorpus(string path)
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<GoldenCorpus>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize corpus: {path}");
|
||||
}
|
||||
|
||||
private void ValidateCorpusItem(CorpusItem item)
|
||||
{
|
||||
Assert.False(string.IsNullOrEmpty(item.Id), "Corpus item must have an ID");
|
||||
Assert.NotEmpty(item.Sources);
|
||||
Assert.NotNull(item.Expected);
|
||||
|
||||
// Validate each source produces a valid hash
|
||||
foreach (var source in item.Sources)
|
||||
{
|
||||
var hash = ComputeHashFromSource(source);
|
||||
Assert.StartsWith("sha256:", hash);
|
||||
Assert.Equal(71, hash.Length); // sha256: + 64 hex chars
|
||||
}
|
||||
}
|
||||
|
||||
private string ComputeHashFromSource(CorpusSource source)
|
||||
{
|
||||
var input = new MergeHashInput
|
||||
{
|
||||
Cve = source.Cve,
|
||||
AffectsKey = source.AffectsKey,
|
||||
VersionRange = source.VersionRange,
|
||||
Weaknesses = source.Weaknesses ?? [],
|
||||
PatchLineage = source.PatchLineage
|
||||
};
|
||||
|
||||
return _calculator.ComputeMergeHash(input);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Corpus Models
|
||||
|
||||
private sealed record GoldenCorpus
|
||||
{
|
||||
public string Corpus { get; init; } = string.Empty;
|
||||
public string Version { get; init; } = string.Empty;
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public IReadOnlyList<CorpusItem> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
private sealed record CorpusItem
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public IReadOnlyList<CorpusSource> Sources { get; init; } = [];
|
||||
public CorpusExpected Expected { get; init; } = new();
|
||||
}
|
||||
|
||||
private sealed record CorpusSource
|
||||
{
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string AdvisoryId { get; init; } = string.Empty;
|
||||
public string Cve { get; init; } = string.Empty;
|
||||
public string AffectsKey { get; init; } = string.Empty;
|
||||
public string? VersionRange { get; init; }
|
||||
public IReadOnlyList<string>? Weaknesses { get; init; }
|
||||
public string? PatchLineage { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CorpusExpected
|
||||
{
|
||||
public bool SameMergeHash { get; init; }
|
||||
public string Rationale { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PatchLineageNormalizerTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-008
|
||||
// Description: Unit tests for PatchLineageNormalizer
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests.Identity;
|
||||
|
||||
public sealed class PatchLineageNormalizerTests
|
||||
{
|
||||
private readonly PatchLineageNormalizer _normalizer = PatchLineageNormalizer.Instance;
|
||||
|
||||
#region Full SHA Extraction
|
||||
|
||||
[Fact]
|
||||
public void Normalize_FullSha_ReturnsLowercase()
|
||||
{
|
||||
var sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
|
||||
var result = _normalizer.Normalize(sha);
|
||||
Assert.Equal(sha.ToLowerInvariant(), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_FullShaUppercase_ReturnsLowercase()
|
||||
{
|
||||
var sha = "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2";
|
||||
var result = _normalizer.Normalize(sha);
|
||||
Assert.Equal(sha.ToLowerInvariant(), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_FullShaMixedCase_ReturnsLowercase()
|
||||
{
|
||||
var sha = "A1b2C3d4E5f6A1b2C3d4E5f6A1b2C3d4E5f6A1b2";
|
||||
var result = _normalizer.Normalize(sha);
|
||||
Assert.Equal(sha.ToLowerInvariant(), result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Abbreviated SHA Extraction
|
||||
|
||||
[Fact]
|
||||
public void Normalize_AbbrevShaWithContext_ExtractsSha()
|
||||
{
|
||||
var result = _normalizer.Normalize("fix: abc1234 addresses the issue");
|
||||
Assert.Equal("abc1234", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_AbbrevShaWithCommitKeyword_ExtractsSha()
|
||||
{
|
||||
var result = _normalizer.Normalize("commit abc1234567");
|
||||
Assert.Equal("abc1234567", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_AbbrevShaSeven_ExtractsSha()
|
||||
{
|
||||
var result = _normalizer.Normalize("patch: fix in abc1234");
|
||||
Assert.Equal("abc1234", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_AbbrevShaTwelve_ExtractsSha()
|
||||
{
|
||||
var result = _normalizer.Normalize("backport of abc123456789");
|
||||
Assert.Equal("abc123456789", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GitHub/GitLab URL Extraction
|
||||
|
||||
[Fact]
|
||||
public void Normalize_GitHubCommitUrl_ExtractsSha()
|
||||
{
|
||||
var url = "https://github.com/owner/repo/commit/abc123def456abc123def456abc123def456abc1";
|
||||
var result = _normalizer.Normalize(url);
|
||||
Assert.Equal("abc123def456abc123def456abc123def456abc1", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_GitLabCommitUrl_ExtractsSha()
|
||||
{
|
||||
var url = "https://gitlab.com/owner/repo/commit/abc123def456";
|
||||
var result = _normalizer.Normalize(url);
|
||||
Assert.Equal("abc123def456", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_GitHubUrlAbbrevSha_ExtractsSha()
|
||||
{
|
||||
var url = "https://github.com/apache/log4j/commit/abc1234";
|
||||
var result = _normalizer.Normalize(url);
|
||||
Assert.Equal("abc1234", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Patch ID Extraction
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PatchIdUppercase_ReturnsUppercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("PATCH-12345");
|
||||
Assert.Equal("PATCH-12345", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PatchIdLowercase_ReturnsUppercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("patch-12345");
|
||||
Assert.Equal("PATCH-12345", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PatchIdInText_ExtractsPatchId()
|
||||
{
|
||||
var result = _normalizer.Normalize("Applied PATCH-67890 to fix issue");
|
||||
Assert.Equal("PATCH-67890", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Empty and Null
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Null_ReturnsNull()
|
||||
{
|
||||
var result = _normalizer.Normalize(null);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_EmptyString_ReturnsNull()
|
||||
{
|
||||
var result = _normalizer.Normalize(string.Empty);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WhitespaceOnly_ReturnsNull()
|
||||
{
|
||||
var result = _normalizer.Normalize(" ");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithWhitespace_ReturnsTrimmed()
|
||||
{
|
||||
var sha = " a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ";
|
||||
var result = _normalizer.Normalize(sha);
|
||||
Assert.Equal("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Unrecognized Patterns
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NoRecognizablePattern_ReturnsNull()
|
||||
{
|
||||
var result = _normalizer.Normalize("some random text without sha or patch id");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ShortHex_ReturnsNull()
|
||||
{
|
||||
// Less than 7 hex chars shouldn't match abbreviated SHA
|
||||
var result = _normalizer.Normalize("abc12 is too short");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NonHexChars_ReturnsNull()
|
||||
{
|
||||
var result = _normalizer.Normalize("ghijkl is not hex");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PatchIdNoNumber_ReturnsNull()
|
||||
{
|
||||
var result = _normalizer.Normalize("PATCH-abc is invalid");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Priority Testing
|
||||
|
||||
[Fact]
|
||||
public void Normalize_UrlOverPlainSha_PrefersUrl()
|
||||
{
|
||||
// When URL contains SHA, should extract from URL pattern
|
||||
var input = "https://github.com/owner/repo/commit/abcdef1234567890abcdef1234567890abcdef12";
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal("abcdef1234567890abcdef1234567890abcdef12", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_FullShaOverAbbrev_PrefersFullSha()
|
||||
{
|
||||
// When both full and abbreviated SHA present, should prefer full
|
||||
var input = "abc1234 mentioned in commit a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism
|
||||
|
||||
[Theory]
|
||||
[InlineData("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")]
|
||||
[InlineData("https://github.com/owner/repo/commit/abc1234")]
|
||||
[InlineData("PATCH-12345")]
|
||||
[InlineData("commit abc1234567")]
|
||||
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
|
||||
{
|
||||
var first = _normalizer.Normalize(input);
|
||||
var second = _normalizer.Normalize(input);
|
||||
var third = _normalizer.Normalize(input);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.Equal(second, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Determinism_100Runs()
|
||||
{
|
||||
const string input = "https://github.com/apache/log4j/commit/abc123def456abc123def456abc123def456abc1";
|
||||
var expected = _normalizer.Normalize(input);
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Real-World Lineage Formats
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://github.com/apache/logging-log4j2/commit/7fe72d6", "7fe72d6")]
|
||||
[InlineData("backport of abc123def456", "abc123def456")]
|
||||
public void Normalize_RealWorldLineages_ReturnsExpected(string input, string expected)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PatchId_ExtractsAndUppercases()
|
||||
{
|
||||
// PATCH-NNNNN format is recognized and uppercased
|
||||
var result = _normalizer.Normalize("Applied patch-12345 to fix issue");
|
||||
Assert.Equal("PATCH-12345", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Singleton Instance
|
||||
|
||||
[Fact]
|
||||
public void Instance_ReturnsSameInstance()
|
||||
{
|
||||
var instance1 = PatchLineageNormalizer.Instance;
|
||||
var instance2 = PatchLineageNormalizer.Instance;
|
||||
Assert.Same(instance1, instance2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PurlNormalizerTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-008
|
||||
// Description: Unit tests for PurlNormalizer
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests.Identity;
|
||||
|
||||
public sealed class PurlNormalizerTests
|
||||
{
|
||||
private readonly PurlNormalizer _normalizer = PurlNormalizer.Instance;
|
||||
|
||||
#region Basic Normalization
|
||||
|
||||
[Fact]
|
||||
public void Normalize_SimplePurl_ReturnsLowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:npm/lodash@4.17.21");
|
||||
Assert.Equal("pkg:npm/lodash@4.17.21", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_UppercaseType_ReturnsLowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:NPM/lodash@4.17.21");
|
||||
Assert.Equal("pkg:npm/lodash@4.17.21", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithNamespace_ReturnsNormalized()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:maven/org.apache.commons/commons-lang3@3.12.0");
|
||||
Assert.Equal("pkg:maven/org.apache.commons/commons-lang3@3.12.0", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scoped NPM Packages
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NpmScopedPackage_ReturnsLowercaseScope()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:npm/@Angular/core@14.0.0");
|
||||
Assert.StartsWith("pkg:npm/", result);
|
||||
Assert.Contains("angular", result.ToLowerInvariant());
|
||||
Assert.Contains("core", result.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NpmScopedPackageEncoded_DecodesAndNormalizes()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:npm/%40angular/core@14.0.0");
|
||||
Assert.Contains("angular", result.ToLowerInvariant());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Qualifier Stripping
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithArchQualifier_StripsArch()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:deb/debian/curl@7.68.0-1?arch=amd64");
|
||||
Assert.DoesNotContain("arch=", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithTypeQualifier_StripsType()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:maven/org.apache/commons@1.0?type=jar");
|
||||
Assert.DoesNotContain("type=", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithChecksumQualifier_StripsChecksum()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:npm/lodash@4.17.21?checksum=sha256:abc123");
|
||||
Assert.DoesNotContain("checksum=", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithPlatformQualifier_StripsPlatform()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:npm/lodash@4.17.21?platform=linux");
|
||||
Assert.DoesNotContain("platform=", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithMultipleQualifiers_StripsNonIdentity()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:deb/debian/curl@7.68.0-1?arch=amd64&distro=bullseye");
|
||||
Assert.DoesNotContain("arch=", result);
|
||||
Assert.Contains("distro=bullseye", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithIdentityQualifiers_KeepsIdentity()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:deb/debian/curl@7.68.0-1?distro=bullseye");
|
||||
Assert.Contains("distro=bullseye", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Qualifier Sorting
|
||||
|
||||
[Fact]
|
||||
public void Normalize_UnsortedQualifiers_ReturnsSorted()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:npm/pkg@1.0?z=1&a=2&m=3");
|
||||
// Qualifiers should be sorted alphabetically
|
||||
var queryStart = result.IndexOf('?');
|
||||
if (queryStart > 0)
|
||||
{
|
||||
var qualifiers = result[(queryStart + 1)..].Split('&');
|
||||
var sorted = qualifiers.OrderBy(q => q).ToArray();
|
||||
Assert.Equal(sorted, qualifiers);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Empty and Null
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Null_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(null!);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_EmptyString_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(string.Empty);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WhitespaceOnly_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(" ");
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithWhitespace_ReturnsTrimmed()
|
||||
{
|
||||
var result = _normalizer.Normalize(" pkg:npm/lodash@4.17.21 ");
|
||||
Assert.Equal("pkg:npm/lodash@4.17.21", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Non-PURL Input
|
||||
|
||||
[Fact]
|
||||
public void Normalize_CpeInput_ReturnsAsIs()
|
||||
{
|
||||
var input = "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*";
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(input, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PlainPackageName_ReturnsLowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("SomePackage");
|
||||
Assert.Equal("somepackage", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_InvalidPurlFormat_ReturnsLowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:invalid");
|
||||
Assert.Equal("pkg:invalid", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Special Characters
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithSubpath_StripsSubpath()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:npm/lodash@4.17.21#src/index.js");
|
||||
Assert.DoesNotContain("#", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_UrlEncodedName_DecodesAndNormalizes()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:npm/%40scope%2Fpkg@1.0.0");
|
||||
// Should decode and normalize
|
||||
Assert.StartsWith("pkg:npm/", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ecosystem-Specific Behavior
|
||||
|
||||
[Fact]
|
||||
public void Normalize_GolangPackage_PreservesNameCase()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:golang/github.com/User/Repo@v1.0.0");
|
||||
// Go namespace is lowercased but name retains original chars
|
||||
// The current normalizer lowercases everything except golang name
|
||||
Assert.StartsWith("pkg:golang/", result);
|
||||
Assert.Contains("repo", result.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NugetPackage_ReturnsLowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:nuget/Newtonsoft.Json@13.0.1");
|
||||
Assert.Contains("newtonsoft.json", result.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_DebianPackage_ReturnsLowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:deb/debian/CURL@7.68.0-1");
|
||||
Assert.Contains("curl", result.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_RpmPackage_ReturnsLowercase()
|
||||
{
|
||||
var result = _normalizer.Normalize("pkg:rpm/redhat/OPENSSL@1.1.1");
|
||||
Assert.Contains("openssl", result.ToLowerInvariant());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism
|
||||
|
||||
[Theory]
|
||||
[InlineData("pkg:npm/lodash@4.17.21")]
|
||||
[InlineData("pkg:NPM/LODASH@4.17.21")]
|
||||
[InlineData("pkg:npm/@angular/core@14.0.0")]
|
||||
[InlineData("pkg:maven/org.apache/commons@1.0")]
|
||||
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
|
||||
{
|
||||
var first = _normalizer.Normalize(input);
|
||||
var second = _normalizer.Normalize(input);
|
||||
var third = _normalizer.Normalize(input);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.Equal(second, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Determinism_100Runs()
|
||||
{
|
||||
const string input = "pkg:npm/@SCOPE/Package@1.0.0?arch=amd64&distro=bullseye";
|
||||
var expected = _normalizer.Normalize(input);
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Real-World PURL Formats
|
||||
|
||||
[Theory]
|
||||
[InlineData("pkg:npm/lodash@4.17.21", "pkg:npm/lodash@4.17.21")]
|
||||
[InlineData("pkg:pypi/requests@2.28.0", "pkg:pypi/requests@2.28.0")]
|
||||
[InlineData("pkg:gem/rails@7.0.0", "pkg:gem/rails@7.0.0")]
|
||||
public void Normalize_RealWorldPurls_ReturnsExpected(string input, string expected)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Singleton Instance
|
||||
|
||||
[Fact]
|
||||
public void Instance_ReturnsSameInstance()
|
||||
{
|
||||
var instance1 = PurlNormalizer.Instance;
|
||||
var instance2 = PurlNormalizer.Instance;
|
||||
Assert.Same(instance1, instance2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VersionRangeNormalizerTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0001_CONCEL_merge_hash_library
|
||||
// Task: MHASH-8200-008
|
||||
// Description: Unit tests for VersionRangeNormalizer
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.Merge.Identity.Normalizers;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests.Identity;
|
||||
|
||||
public sealed class VersionRangeNormalizerTests
|
||||
{
|
||||
private readonly VersionRangeNormalizer _normalizer = VersionRangeNormalizer.Instance;
|
||||
|
||||
#region Interval Notation
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ClosedOpen_ConvertsToComparison()
|
||||
{
|
||||
var result = _normalizer.Normalize("[1.0.0, 2.0.0)");
|
||||
Assert.Equal(">=1.0.0,<2.0.0", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_OpenClosed_ConvertsToComparison()
|
||||
{
|
||||
var result = _normalizer.Normalize("(1.0.0, 2.0.0]");
|
||||
Assert.Equal(">1.0.0,<=2.0.0", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ClosedClosed_ConvertsToComparison()
|
||||
{
|
||||
var result = _normalizer.Normalize("[1.0.0, 2.0.0]");
|
||||
Assert.Equal(">=1.0.0,<=2.0.0", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_OpenOpen_ConvertsToComparison()
|
||||
{
|
||||
var result = _normalizer.Normalize("(1.0.0, 2.0.0)");
|
||||
Assert.Equal(">1.0.0,<2.0.0", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_IntervalWithSpaces_ConvertsToComparison()
|
||||
{
|
||||
var result = _normalizer.Normalize("[ 1.0.0 , 2.0.0 )");
|
||||
Assert.Equal(">=1.0.0,<2.0.0", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_LeftOpenInterval_ConvertsToUpperBound()
|
||||
{
|
||||
var result = _normalizer.Normalize("(, 2.0.0)");
|
||||
Assert.Equal("<2.0.0", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_RightOpenInterval_ConvertsToLowerBound()
|
||||
{
|
||||
var result = _normalizer.Normalize("[1.0.0,)");
|
||||
Assert.Equal(">=1.0.0", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Comparison Operators
|
||||
|
||||
[Theory]
|
||||
[InlineData(">= 1.0.0", ">=1.0.0")]
|
||||
[InlineData(">=1.0.0", ">=1.0.0")]
|
||||
[InlineData("> 1.0.0", ">1.0.0")]
|
||||
[InlineData("<= 2.0.0", "<=2.0.0")]
|
||||
[InlineData("< 2.0.0", "<2.0.0")]
|
||||
[InlineData("= 1.0.0", "=1.0.0")]
|
||||
[InlineData("!= 1.0.0", "!=1.0.0")]
|
||||
public void Normalize_ComparisonOperators_NormalizesWhitespace(string input, string expected)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("~= 1.0.0", "~=1.0.0")]
|
||||
[InlineData("~> 1.0.0", "~=1.0.0")]
|
||||
[InlineData("^ 1.0.0", "^1.0.0")]
|
||||
public void Normalize_SemverOperators_Normalizes(string input, string expected)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multi-Constraint
|
||||
|
||||
[Fact]
|
||||
public void Normalize_MultipleConstraints_SortsAndJoins()
|
||||
{
|
||||
var result = _normalizer.Normalize("<2.0.0,>=1.0.0");
|
||||
// Should be sorted alphabetically
|
||||
Assert.Contains("<2.0.0", result);
|
||||
Assert.Contains(">=1.0.0", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_DuplicateConstraints_Deduplicates()
|
||||
{
|
||||
var result = _normalizer.Normalize(">= 1.0.0, >=1.0.0");
|
||||
// Should deduplicate
|
||||
var count = result.Split(',').Count(s => s == ">=1.0.0");
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fixed Version
|
||||
|
||||
[Fact]
|
||||
public void Normalize_FixedNotation_ConvertsToGreaterOrEqual()
|
||||
{
|
||||
var result = _normalizer.Normalize("fixed: 1.5.1");
|
||||
Assert.Equal(">=1.5.1", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_FixedNotationNoSpace_ConvertsToGreaterOrEqual()
|
||||
{
|
||||
var result = _normalizer.Normalize("fixed:1.5.1");
|
||||
Assert.Equal(">=1.5.1", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Wildcard
|
||||
|
||||
[Theory]
|
||||
[InlineData("*", "*")]
|
||||
[InlineData("all", "*")]
|
||||
[InlineData("any", "*")]
|
||||
public void Normalize_WildcardMarkers_ReturnsAsterisk(string input, string expected)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Plain Version
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PlainVersion_ConvertsToExact()
|
||||
{
|
||||
var result = _normalizer.Normalize("1.0.0");
|
||||
Assert.Equal("=1.0.0", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PlainVersionWithPatch_ConvertsToExact()
|
||||
{
|
||||
var result = _normalizer.Normalize("1.2.3");
|
||||
Assert.Equal("=1.2.3", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Empty and Null
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Null_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(null);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_EmptyString_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(string.Empty);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WhitespaceOnly_ReturnsEmpty()
|
||||
{
|
||||
var result = _normalizer.Normalize(" ");
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WithWhitespace_ReturnsTrimmed()
|
||||
{
|
||||
var result = _normalizer.Normalize(" >= 1.0.0 ");
|
||||
Assert.Equal(">=1.0.0", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases - Malformed Input
|
||||
|
||||
[Fact]
|
||||
public void Normalize_UnrecognizedFormat_ReturnsAsIs()
|
||||
{
|
||||
var result = _normalizer.Normalize("some-weird-format");
|
||||
Assert.Equal("some-weird-format", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_MalformedInterval_ReturnsAsIs()
|
||||
{
|
||||
var result = _normalizer.Normalize("[1.0.0");
|
||||
// Should return as-is if can't parse
|
||||
Assert.Contains("1.0.0", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism
|
||||
|
||||
[Theory]
|
||||
[InlineData("[1.0.0, 2.0.0)")]
|
||||
[InlineData(">= 1.0.0")]
|
||||
[InlineData("fixed: 1.5.1")]
|
||||
[InlineData("*")]
|
||||
public void Normalize_MultipleRuns_ReturnsSameResult(string input)
|
||||
{
|
||||
var first = _normalizer.Normalize(input);
|
||||
var second = _normalizer.Normalize(input);
|
||||
var third = _normalizer.Normalize(input);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.Equal(second, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Determinism_100Runs()
|
||||
{
|
||||
const string input = "[1.0.0, 2.0.0)";
|
||||
var expected = _normalizer.Normalize(input);
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_EquivalentFormats_ProduceSameOutput()
|
||||
{
|
||||
// Different ways to express the same range
|
||||
var interval = _normalizer.Normalize("[1.0.0, 2.0.0)");
|
||||
var comparison = _normalizer.Normalize(">=1.0.0,<2.0.0");
|
||||
|
||||
Assert.Equal(interval, comparison);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Real-World Version Ranges
|
||||
|
||||
[Theory]
|
||||
[InlineData("<7.68.0-1+deb10u2", "<7.68.0-1+deb10u2")]
|
||||
[InlineData(">=0,<1.2.3", ">=0,<1.2.3")]
|
||||
public void Normalize_RealWorldRanges_ReturnsExpected(string input, string expected)
|
||||
{
|
||||
var result = _normalizer.Normalize(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Singleton Instance
|
||||
|
||||
[Fact]
|
||||
public void Instance_ReturnsSameInstance()
|
||||
{
|
||||
var instance1 = VersionRangeNormalizer.Instance;
|
||||
var instance2 = VersionRangeNormalizer.Instance;
|
||||
Assert.Same(instance1, instance2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -302,9 +302,9 @@ public sealed class MergePropertyTests
|
||||
// Assert - merge provenance trace should contain all original sources
|
||||
var mergeProvenance = result.Provenance.FirstOrDefault(p => p.Source == "merge");
|
||||
mergeProvenance.Should().NotBeNull();
|
||||
mergeProvenance!.Value.Should().Contain("redhat", StringComparison.OrdinalIgnoreCase);
|
||||
mergeProvenance.Value.Should().Contain("ghsa", StringComparison.OrdinalIgnoreCase);
|
||||
mergeProvenance.Value.Should().Contain("osv", StringComparison.OrdinalIgnoreCase);
|
||||
mergeProvenance!.Value.ToLowerInvariant().Should().Contain("redhat");
|
||||
mergeProvenance.Value.ToLowerInvariant().Should().Contain("ghsa");
|
||||
mergeProvenance.Value.ToLowerInvariant().Should().Contain("osv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AdvisoryCanonicalRepositoryTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0002_DB_canonical_source_edge_schema
|
||||
// Task: SCHEMA-8200-011
|
||||
// Description: Integration tests for AdvisoryCanonicalRepository
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="AdvisoryCanonicalRepository"/>.
|
||||
/// Tests CRUD operations, unique constraints, and cascade delete behavior.
|
||||
/// </summary>
|
||||
[Collection(ConcelierPostgresCollection.Name)]
|
||||
public sealed class AdvisoryCanonicalRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConcelierPostgresFixture _fixture;
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly AdvisoryCanonicalRepository _repository;
|
||||
private readonly SourceRepository _sourceRepository;
|
||||
|
||||
public AdvisoryCanonicalRepositoryTests(ConcelierPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
|
||||
_repository = new AdvisoryCanonicalRepository(_dataSource, NullLogger<AdvisoryCanonicalRepository>.Instance);
|
||||
_sourceRepository = new SourceRepository(_dataSource, NullLogger<SourceRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
#region GetByIdAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ShouldReturnEntity_WhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical();
|
||||
var id = await _repository.UpsertAsync(canonical);
|
||||
|
||||
// Act
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(id);
|
||||
result.Cve.Should().Be(canonical.Cve);
|
||||
result.AffectsKey.Should().Be(canonical.AffectsKey);
|
||||
result.MergeHash.Should().Be(canonical.MergeHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ShouldReturnNull_WhenNotExists()
|
||||
{
|
||||
// Act
|
||||
var result = await _repository.GetByIdAsync(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetByMergeHashAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByMergeHashAsync_ShouldReturnEntity_WhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical();
|
||||
await _repository.UpsertAsync(canonical);
|
||||
|
||||
// Act
|
||||
var result = await _repository.GetByMergeHashAsync(canonical.MergeHash);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.MergeHash.Should().Be(canonical.MergeHash);
|
||||
result.Cve.Should().Be(canonical.Cve);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByMergeHashAsync_ShouldReturnNull_WhenNotExists()
|
||||
{
|
||||
// Act
|
||||
var result = await _repository.GetByMergeHashAsync("sha256:nonexistent");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetByCveAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCveAsync_ShouldReturnAllMatchingEntities()
|
||||
{
|
||||
// Arrange
|
||||
var cve = "CVE-2024-12345";
|
||||
var canonical1 = CreateTestCanonical(cve: cve, affectsKey: "pkg:npm/lodash@4.17.0");
|
||||
var canonical2 = CreateTestCanonical(cve: cve, affectsKey: "pkg:npm/express@4.0.0");
|
||||
var canonical3 = CreateTestCanonical(cve: "CVE-2024-99999");
|
||||
|
||||
await _repository.UpsertAsync(canonical1);
|
||||
await _repository.UpsertAsync(canonical2);
|
||||
await _repository.UpsertAsync(canonical3);
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetByCveAsync(cve);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().AllSatisfy(r => r.Cve.Should().Be(cve));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCveAsync_ShouldReturnEmptyList_WhenNoMatches()
|
||||
{
|
||||
// Act
|
||||
var results = await _repository.GetByCveAsync("CVE-2099-00000");
|
||||
|
||||
// Assert
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetByAffectsKeyAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByAffectsKeyAsync_ShouldReturnAllMatchingEntities()
|
||||
{
|
||||
// Arrange
|
||||
var affectsKey = "pkg:npm/lodash@4.17.21";
|
||||
var canonical1 = CreateTestCanonical(cve: "CVE-2024-11111", affectsKey: affectsKey);
|
||||
var canonical2 = CreateTestCanonical(cve: "CVE-2024-22222", affectsKey: affectsKey);
|
||||
var canonical3 = CreateTestCanonical(cve: "CVE-2024-33333", affectsKey: "pkg:npm/express@4.0.0");
|
||||
|
||||
await _repository.UpsertAsync(canonical1);
|
||||
await _repository.UpsertAsync(canonical2);
|
||||
await _repository.UpsertAsync(canonical3);
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetByAffectsKeyAsync(affectsKey);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().AllSatisfy(r => r.AffectsKey.Should().Be(affectsKey));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpsertAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_ShouldInsertNewEntity()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical();
|
||||
|
||||
// Act
|
||||
var id = await _repository.UpsertAsync(canonical);
|
||||
|
||||
// Assert
|
||||
id.Should().NotBeEmpty();
|
||||
|
||||
var retrieved = await _repository.GetByIdAsync(id);
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.Cve.Should().Be(canonical.Cve);
|
||||
retrieved.AffectsKey.Should().Be(canonical.AffectsKey);
|
||||
retrieved.MergeHash.Should().Be(canonical.MergeHash);
|
||||
retrieved.Status.Should().Be("active");
|
||||
retrieved.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_ShouldUpdateExistingByMergeHash()
|
||||
{
|
||||
// Arrange
|
||||
var mergeHash = $"sha256:{Guid.NewGuid():N}";
|
||||
var original = CreateTestCanonical(mergeHash: mergeHash, severity: "high");
|
||||
await _repository.UpsertAsync(original);
|
||||
|
||||
// Get original timestamps
|
||||
var originalEntity = await _repository.GetByMergeHashAsync(mergeHash);
|
||||
var originalCreatedAt = originalEntity!.CreatedAt;
|
||||
|
||||
// Create update with same merge_hash but different values
|
||||
var updated = new AdvisoryCanonicalEntity
|
||||
{
|
||||
Id = Guid.NewGuid(), // Different ID
|
||||
Cve = original.Cve,
|
||||
AffectsKey = original.AffectsKey,
|
||||
MergeHash = mergeHash, // Same merge_hash
|
||||
Severity = "critical", // Updated severity
|
||||
Title = "Updated Title"
|
||||
};
|
||||
|
||||
// Act
|
||||
var id = await _repository.UpsertAsync(updated);
|
||||
|
||||
// Assert - should return original ID, not new one
|
||||
id.Should().Be(originalEntity.Id);
|
||||
|
||||
var result = await _repository.GetByMergeHashAsync(mergeHash);
|
||||
result.Should().NotBeNull();
|
||||
result!.Severity.Should().Be("critical");
|
||||
result.Title.Should().Be("Updated Title");
|
||||
result.CreatedAt.Should().BeCloseTo(originalCreatedAt, TimeSpan.FromSeconds(1)); // CreatedAt unchanged
|
||||
result.UpdatedAt.Should().BeAfter(result.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_ShouldPreserveExistingValues_WhenNewValuesAreNull()
|
||||
{
|
||||
// Arrange
|
||||
var mergeHash = $"sha256:{Guid.NewGuid():N}";
|
||||
var original = CreateTestCanonical(
|
||||
mergeHash: mergeHash,
|
||||
severity: "high",
|
||||
title: "Original Title",
|
||||
summary: "Original Summary");
|
||||
await _repository.UpsertAsync(original);
|
||||
|
||||
// Create update with null values for severity, title, summary
|
||||
var updated = new AdvisoryCanonicalEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Cve = original.Cve,
|
||||
AffectsKey = original.AffectsKey,
|
||||
MergeHash = mergeHash,
|
||||
Severity = null,
|
||||
Title = null,
|
||||
Summary = null
|
||||
};
|
||||
|
||||
// Act
|
||||
await _repository.UpsertAsync(updated);
|
||||
|
||||
// Assert - original values should be preserved
|
||||
var result = await _repository.GetByMergeHashAsync(mergeHash);
|
||||
result.Should().NotBeNull();
|
||||
result!.Severity.Should().Be("high");
|
||||
result.Title.Should().Be("Original Title");
|
||||
result.Summary.Should().Be("Original Summary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_ShouldStoreWeaknessArray()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical(weaknesses: ["CWE-79", "CWE-89", "CWE-120"]);
|
||||
|
||||
// Act
|
||||
var id = await _repository.UpsertAsync(canonical);
|
||||
|
||||
// Assert
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
result.Should().NotBeNull();
|
||||
result!.Weakness.Should().BeEquivalentTo(["CWE-79", "CWE-89", "CWE-120"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_ShouldStoreVersionRangeAsJson()
|
||||
{
|
||||
// Arrange
|
||||
var versionRange = """{"introduced": "1.0.0", "fixed": "1.5.1"}""";
|
||||
var canonical = CreateTestCanonical(versionRange: versionRange);
|
||||
|
||||
// Act
|
||||
var id = await _repository.UpsertAsync(canonical);
|
||||
|
||||
// Assert
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
result.Should().NotBeNull();
|
||||
result!.VersionRange.Should().Contain("introduced");
|
||||
result.VersionRange.Should().Contain("fixed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateStatusAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_ShouldUpdateStatus()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical();
|
||||
var id = await _repository.UpsertAsync(canonical);
|
||||
|
||||
// Act
|
||||
await _repository.UpdateStatusAsync(id, "withdrawn");
|
||||
|
||||
// Assert
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
result.Should().NotBeNull();
|
||||
result!.Status.Should().Be("withdrawn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_ShouldUpdateTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical();
|
||||
var id = await _repository.UpsertAsync(canonical);
|
||||
var original = await _repository.GetByIdAsync(id);
|
||||
|
||||
// Wait a bit to ensure timestamp difference
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act
|
||||
await _repository.UpdateStatusAsync(id, "stub");
|
||||
|
||||
// Assert
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
result.Should().NotBeNull();
|
||||
result!.UpdatedAt.Should().BeAfter(original!.UpdatedAt);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeleteAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ShouldRemoveEntity()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical();
|
||||
var id = await _repository.UpsertAsync(canonical);
|
||||
|
||||
// Verify exists
|
||||
var exists = await _repository.GetByIdAsync(id);
|
||||
exists.Should().NotBeNull();
|
||||
|
||||
// Act
|
||||
await _repository.DeleteAsync(id);
|
||||
|
||||
// Assert
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ShouldCascadeDeleteSourceEdges()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical();
|
||||
var canonicalId = await _repository.UpsertAsync(canonical);
|
||||
|
||||
// Create a source first (required FK)
|
||||
var source = CreateTestSource();
|
||||
await _sourceRepository.UpsertAsync(source);
|
||||
|
||||
// Add source edge
|
||||
var edge = CreateTestSourceEdge(canonicalId, source.Id);
|
||||
var edgeId = await _repository.AddSourceEdgeAsync(edge);
|
||||
|
||||
// Verify edge exists
|
||||
var edgeExists = await _repository.GetSourceEdgeByIdAsync(edgeId);
|
||||
edgeExists.Should().NotBeNull();
|
||||
|
||||
// Act - delete canonical
|
||||
await _repository.DeleteAsync(canonicalId);
|
||||
|
||||
// Assert - source edge should be deleted via cascade
|
||||
var edgeAfterDelete = await _repository.GetSourceEdgeByIdAsync(edgeId);
|
||||
edgeAfterDelete.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CountAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CountAsync_ShouldReturnActiveCount()
|
||||
{
|
||||
// Arrange
|
||||
await _repository.UpsertAsync(CreateTestCanonical());
|
||||
await _repository.UpsertAsync(CreateTestCanonical());
|
||||
|
||||
var withdrawnCanonical = CreateTestCanonical();
|
||||
var withdrawnId = await _repository.UpsertAsync(withdrawnCanonical);
|
||||
await _repository.UpdateStatusAsync(withdrawnId, "withdrawn");
|
||||
|
||||
// Act
|
||||
var count = await _repository.CountAsync();
|
||||
|
||||
// Assert
|
||||
count.Should().Be(2); // Only active ones
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StreamActiveAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StreamActiveAsync_ShouldStreamOnlyActiveEntities()
|
||||
{
|
||||
// Arrange
|
||||
await _repository.UpsertAsync(CreateTestCanonical(cve: "CVE-2024-00001"));
|
||||
await _repository.UpsertAsync(CreateTestCanonical(cve: "CVE-2024-00002"));
|
||||
|
||||
var withdrawnId = await _repository.UpsertAsync(CreateTestCanonical(cve: "CVE-2024-00003"));
|
||||
await _repository.UpdateStatusAsync(withdrawnId, "withdrawn");
|
||||
|
||||
// Act
|
||||
var results = new List<AdvisoryCanonicalEntity>();
|
||||
await foreach (var entity in _repository.StreamActiveAsync())
|
||||
{
|
||||
results.Add(entity);
|
||||
}
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().AllSatisfy(e => e.Status.Should().Be("active"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Source Edge Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetSourceEdgesAsync_ShouldReturnEdgesForCanonical()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical();
|
||||
var canonicalId = await _repository.UpsertAsync(canonical);
|
||||
|
||||
var source1 = CreateTestSource();
|
||||
var source2 = CreateTestSource();
|
||||
await _sourceRepository.UpsertAsync(source1);
|
||||
await _sourceRepository.UpsertAsync(source2);
|
||||
|
||||
await _repository.AddSourceEdgeAsync(CreateTestSourceEdge(canonicalId, source1.Id, precedence: 10));
|
||||
await _repository.AddSourceEdgeAsync(CreateTestSourceEdge(canonicalId, source2.Id, precedence: 20));
|
||||
|
||||
// Act
|
||||
var edges = await _repository.GetSourceEdgesAsync(canonicalId);
|
||||
|
||||
// Assert
|
||||
edges.Should().HaveCount(2);
|
||||
edges.Should().BeInAscendingOrder(e => e.PrecedenceRank);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSourceEdgeAsync_ShouldInsertNewEdge()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical();
|
||||
var canonicalId = await _repository.UpsertAsync(canonical);
|
||||
|
||||
var source = CreateTestSource();
|
||||
await _sourceRepository.UpsertAsync(source);
|
||||
|
||||
var edge = CreateTestSourceEdge(canonicalId, source.Id);
|
||||
|
||||
// Act
|
||||
var edgeId = await _repository.AddSourceEdgeAsync(edge);
|
||||
|
||||
// Assert
|
||||
edgeId.Should().NotBeEmpty();
|
||||
|
||||
var result = await _repository.GetSourceEdgeByIdAsync(edgeId);
|
||||
result.Should().NotBeNull();
|
||||
result!.CanonicalId.Should().Be(canonicalId);
|
||||
result.SourceId.Should().Be(source.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSourceEdgeAsync_ShouldUpsertOnConflict()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical();
|
||||
var canonicalId = await _repository.UpsertAsync(canonical);
|
||||
|
||||
var source = CreateTestSource();
|
||||
await _sourceRepository.UpsertAsync(source);
|
||||
|
||||
var sourceDocHash = $"sha256:{Guid.NewGuid():N}";
|
||||
var edge1 = CreateTestSourceEdge(canonicalId, source.Id, sourceDocHash: sourceDocHash, precedence: 100);
|
||||
var id1 = await _repository.AddSourceEdgeAsync(edge1);
|
||||
|
||||
// Create edge with same (canonical_id, source_id, source_doc_hash) but different precedence
|
||||
var edge2 = CreateTestSourceEdge(canonicalId, source.Id, sourceDocHash: sourceDocHash, precedence: 10);
|
||||
|
||||
// Act
|
||||
var id2 = await _repository.AddSourceEdgeAsync(edge2);
|
||||
|
||||
// Assert - should return same ID
|
||||
id2.Should().Be(id1);
|
||||
|
||||
var result = await _repository.GetSourceEdgeByIdAsync(id1);
|
||||
result.Should().NotBeNull();
|
||||
// Should use LEAST of precedence values
|
||||
result!.PrecedenceRank.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSourceEdgeAsync_ShouldStoreDsseEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical();
|
||||
var canonicalId = await _repository.UpsertAsync(canonical);
|
||||
|
||||
var source = CreateTestSource();
|
||||
await _sourceRepository.UpsertAsync(source);
|
||||
|
||||
var dsseEnvelope = """{"payloadType": "application/vnd.in-toto+json", "payload": "eyJ0ZXN0IjogdHJ1ZX0=", "signatures": []}""";
|
||||
var edge = CreateTestSourceEdge(canonicalId, source.Id, dsseEnvelope: dsseEnvelope);
|
||||
|
||||
// Act
|
||||
var edgeId = await _repository.AddSourceEdgeAsync(edge);
|
||||
|
||||
// Assert
|
||||
var result = await _repository.GetSourceEdgeByIdAsync(edgeId);
|
||||
result.Should().NotBeNull();
|
||||
result!.DsseEnvelope.Should().Contain("payloadType");
|
||||
result.DsseEnvelope.Should().Contain("signatures");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSourceEdgesByAdvisoryIdAsync_ShouldReturnMatchingEdges()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical();
|
||||
var canonicalId = await _repository.UpsertAsync(canonical);
|
||||
|
||||
var source = CreateTestSource();
|
||||
await _sourceRepository.UpsertAsync(source);
|
||||
|
||||
var advisoryId = "DSA-5678-1";
|
||||
await _repository.AddSourceEdgeAsync(CreateTestSourceEdge(canonicalId, source.Id, sourceAdvisoryId: advisoryId));
|
||||
await _repository.AddSourceEdgeAsync(CreateTestSourceEdge(canonicalId, source.Id, sourceAdvisoryId: "OTHER-123"));
|
||||
|
||||
// Act
|
||||
var edges = await _repository.GetSourceEdgesByAdvisoryIdAsync(advisoryId);
|
||||
|
||||
// Assert
|
||||
edges.Should().ContainSingle();
|
||||
edges[0].SourceAdvisoryId.Should().Be(advisoryId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Statistics Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_ShouldReturnCorrectCounts()
|
||||
{
|
||||
// Arrange
|
||||
await _repository.UpsertAsync(CreateTestCanonical(cve: "CVE-2024-00001"));
|
||||
await _repository.UpsertAsync(CreateTestCanonical(cve: "CVE-2024-00002"));
|
||||
var withdrawnId = await _repository.UpsertAsync(CreateTestCanonical(cve: "CVE-2024-00003"));
|
||||
await _repository.UpdateStatusAsync(withdrawnId, "withdrawn");
|
||||
|
||||
var source = CreateTestSource();
|
||||
await _sourceRepository.UpsertAsync(source);
|
||||
|
||||
var canonicals = await _repository.GetByCveAsync("CVE-2024-00001");
|
||||
await _repository.AddSourceEdgeAsync(CreateTestSourceEdge(canonicals[0].Id, source.Id));
|
||||
|
||||
// Act
|
||||
var stats = await _repository.GetStatisticsAsync();
|
||||
|
||||
// Assert
|
||||
stats.TotalCanonicals.Should().Be(3);
|
||||
stats.ActiveCanonicals.Should().Be(2);
|
||||
stats.TotalSourceEdges.Should().Be(1);
|
||||
stats.LastUpdatedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unique Constraint Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_WithDuplicateMergeHash_ShouldUpdateNotInsert()
|
||||
{
|
||||
// Arrange
|
||||
var mergeHash = $"sha256:{Guid.NewGuid():N}";
|
||||
var canonical1 = CreateTestCanonical(mergeHash: mergeHash, title: "First");
|
||||
var canonical2 = CreateTestCanonical(mergeHash: mergeHash, title: "Second");
|
||||
|
||||
await _repository.UpsertAsync(canonical1);
|
||||
|
||||
// Act - should update, not throw
|
||||
await _repository.UpsertAsync(canonical2);
|
||||
|
||||
// Assert
|
||||
var results = await _repository.GetByMergeHashAsync(mergeHash);
|
||||
results.Should().NotBeNull();
|
||||
// There should be exactly one record
|
||||
var count = await _repository.CountAsync();
|
||||
count.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_WithEmptyWeaknessArray_ShouldSucceed()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical(weaknesses: []);
|
||||
|
||||
// Act
|
||||
var id = await _repository.UpsertAsync(canonical);
|
||||
|
||||
// Assert
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
result.Should().NotBeNull();
|
||||
result!.Weakness.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_WithNullOptionalFields_ShouldSucceed()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = new AdvisoryCanonicalEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Cve = "CVE-2024-99999",
|
||||
AffectsKey = "pkg:npm/test@1.0.0",
|
||||
MergeHash = $"sha256:{Guid.NewGuid():N}",
|
||||
VersionRange = null,
|
||||
Severity = null,
|
||||
EpssScore = null,
|
||||
Title = null,
|
||||
Summary = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var id = await _repository.UpsertAsync(canonical);
|
||||
|
||||
// Assert
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
result.Should().NotBeNull();
|
||||
result!.VersionRange.Should().BeNull();
|
||||
result.Severity.Should().BeNull();
|
||||
result.EpssScore.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_WithEpssScore_ShouldStoreCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical(epssScore: 0.9754m);
|
||||
|
||||
// Act
|
||||
var id = await _repository.UpsertAsync(canonical);
|
||||
|
||||
// Assert
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
result.Should().NotBeNull();
|
||||
result!.EpssScore.Should().Be(0.9754m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_WithExploitKnown_ShouldOrWithExisting()
|
||||
{
|
||||
// Arrange
|
||||
var mergeHash = $"sha256:{Guid.NewGuid():N}";
|
||||
var canonical1 = CreateTestCanonical(mergeHash: mergeHash, exploitKnown: true);
|
||||
await _repository.UpsertAsync(canonical1);
|
||||
|
||||
// Try to update with exploitKnown = false
|
||||
var canonical2 = new AdvisoryCanonicalEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Cve = canonical1.Cve,
|
||||
AffectsKey = canonical1.AffectsKey,
|
||||
MergeHash = mergeHash,
|
||||
ExploitKnown = false // Trying to set to false
|
||||
};
|
||||
|
||||
// Act
|
||||
await _repository.UpsertAsync(canonical2);
|
||||
|
||||
// Assert - should remain true (OR semantics)
|
||||
var result = await _repository.GetByMergeHashAsync(mergeHash);
|
||||
result.Should().NotBeNull();
|
||||
result!.ExploitKnown.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static AdvisoryCanonicalEntity CreateTestCanonical(
|
||||
string? cve = null,
|
||||
string? affectsKey = null,
|
||||
string? mergeHash = null,
|
||||
string? severity = null,
|
||||
string? title = null,
|
||||
string? summary = null,
|
||||
string? versionRange = null,
|
||||
string[]? weaknesses = null,
|
||||
decimal? epssScore = null,
|
||||
bool exploitKnown = false)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
return new AdvisoryCanonicalEntity
|
||||
{
|
||||
Id = id,
|
||||
Cve = cve ?? $"CVE-2024-{id.ToString("N")[..5]}",
|
||||
AffectsKey = affectsKey ?? $"pkg:npm/{id:N}@1.0.0",
|
||||
MergeHash = mergeHash ?? $"sha256:{id:N}",
|
||||
Severity = severity,
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
VersionRange = versionRange,
|
||||
Weakness = weaknesses ?? [],
|
||||
EpssScore = epssScore,
|
||||
ExploitKnown = exploitKnown
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceEntity CreateTestSource()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var key = $"source-{id:N}"[..20];
|
||||
return new SourceEntity
|
||||
{
|
||||
Id = id,
|
||||
Key = key,
|
||||
Name = $"Test Source {key}",
|
||||
SourceType = "nvd",
|
||||
Url = "https://example.com/feed",
|
||||
Priority = 100,
|
||||
Enabled = true,
|
||||
Config = """{"apiKey": "test"}"""
|
||||
};
|
||||
}
|
||||
|
||||
private static AdvisorySourceEdgeEntity CreateTestSourceEdge(
|
||||
Guid canonicalId,
|
||||
Guid sourceId,
|
||||
string? sourceAdvisoryId = null,
|
||||
string? sourceDocHash = null,
|
||||
int precedence = 100,
|
||||
string? dsseEnvelope = null)
|
||||
{
|
||||
return new AdvisorySourceEdgeEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CanonicalId = canonicalId,
|
||||
SourceId = sourceId,
|
||||
SourceAdvisoryId = sourceAdvisoryId ?? $"ADV-{Guid.NewGuid():N}"[..15],
|
||||
SourceDocHash = sourceDocHash ?? $"sha256:{Guid.NewGuid():N}",
|
||||
VendorStatus = "affected",
|
||||
PrecedenceRank = precedence,
|
||||
DsseEnvelope = dsseEnvelope,
|
||||
FetchedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Documents;
|
||||
using StellaOps.Concelier.Storage.Advisories;
|
||||
using StellaOps.Concelier.Storage.Postgres;
|
||||
using StellaOps.Concelier.Storage.Postgres.Converters;
|
||||
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(ConcelierPostgresCollection.Name)]
|
||||
public sealed class AdvisoryConversionServiceTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConcelierPostgresFixture _fixture;
|
||||
private readonly AdvisoryConversionService _service;
|
||||
private readonly AdvisoryRepository _advisories;
|
||||
private readonly AdvisoryAliasRepository _aliases;
|
||||
private readonly AdvisoryAffectedRepository _affected;
|
||||
|
||||
public AdvisoryConversionServiceTests(ConcelierPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
|
||||
|
||||
_advisories = new AdvisoryRepository(dataSource, NullLogger<AdvisoryRepository>.Instance);
|
||||
_aliases = new AdvisoryAliasRepository(dataSource, NullLogger<AdvisoryAliasRepository>.Instance);
|
||||
_affected = new AdvisoryAffectedRepository(dataSource, NullLogger<AdvisoryAffectedRepository>.Instance);
|
||||
_service = new AdvisoryConversionService(_advisories);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task ConvertAndUpsert_PersistsAdvisoryAndChildren()
|
||||
{
|
||||
var doc = CreateDoc();
|
||||
var sourceId = Guid.NewGuid();
|
||||
|
||||
var stored = await _service.ConvertAndUpsertAsync(doc, "osv", sourceId);
|
||||
|
||||
var fetched = await _advisories.GetByKeyAsync(doc.AdvisoryKey);
|
||||
var aliases = await _aliases.GetByAdvisoryAsync(stored.Id);
|
||||
var affected = await _affected.GetByAdvisoryAsync(stored.Id);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.PrimaryVulnId.Should().Be("CVE-2024-0002");
|
||||
fetched.RawPayload.Should().NotBeNull();
|
||||
fetched.Provenance.Should().Contain("osv");
|
||||
aliases.Should().NotBeEmpty();
|
||||
affected.Should().ContainSingle(a => a.Purl == "pkg:npm/example@2.0.0");
|
||||
affected[0].VersionRange.Should().Contain("introduced");
|
||||
}
|
||||
|
||||
private static AdvisoryDocument CreateDoc()
|
||||
{
|
||||
var payload = new DocumentObject
|
||||
{
|
||||
{ "primaryVulnId", "CVE-2024-0002" },
|
||||
{ "title", "Another advisory" },
|
||||
{ "severity", "medium" },
|
||||
{ "aliases", new DocumentArray { "CVE-2024-0002" } },
|
||||
{ "affected", new DocumentArray
|
||||
{
|
||||
new DocumentObject
|
||||
{
|
||||
{ "ecosystem", "npm" },
|
||||
{ "packageName", "example" },
|
||||
{ "purl", "pkg:npm/example@2.0.0" },
|
||||
{ "range", "{\"introduced\":\"0\",\"fixed\":\"2.0.1\"}" },
|
||||
{ "versionsAffected", new DocumentArray { "2.0.0" } },
|
||||
{ "versionsFixed", new DocumentArray { "2.0.1" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return new AdvisoryDocument
|
||||
{
|
||||
AdvisoryKey = "ADV-2",
|
||||
Payload = payload,
|
||||
Modified = DateTime.UtcNow,
|
||||
Published = DateTime.UtcNow.AddDays(-2)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.Documents;
|
||||
using StellaOps.Concelier.Storage.Advisories;
|
||||
using StellaOps.Concelier.Storage.Postgres.Converters;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Tests;
|
||||
|
||||
public sealed class AdvisoryConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Convert_MapsCoreFieldsAndChildren()
|
||||
{
|
||||
var doc = CreateAdvisoryDocument();
|
||||
|
||||
var result = AdvisoryConverter.Convert(doc, sourceKey: "osv", sourceId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));
|
||||
|
||||
result.Advisory.AdvisoryKey.Should().Be("ADV-1");
|
||||
result.Advisory.PrimaryVulnId.Should().Be("CVE-2024-0001");
|
||||
result.Advisory.Severity.Should().Be("high");
|
||||
result.Aliases.Should().ContainSingle(a => a.AliasValue == "CVE-2024-0001");
|
||||
result.Cvss.Should().ContainSingle(c => c.BaseScore == 9.8m && c.BaseSeverity == "critical");
|
||||
result.Affected.Should().ContainSingle(a => a.Purl == "pkg:npm/example@1.0.0");
|
||||
result.References.Should().ContainSingle(r => r.Url == "https://ref.example/test");
|
||||
result.Credits.Should().ContainSingle(c => c.Name == "Researcher One");
|
||||
result.Weaknesses.Should().ContainSingle(w => w.CweId == "CWE-79");
|
||||
result.KevFlags.Should().ContainSingle(k => k.CveId == "CVE-2024-0001");
|
||||
}
|
||||
|
||||
private static AdvisoryDocument CreateAdvisoryDocument()
|
||||
{
|
||||
var payload = new DocumentObject
|
||||
{
|
||||
{ "primaryVulnId", "CVE-2024-0001" },
|
||||
{ "title", "Sample Advisory" },
|
||||
{ "summary", "Summary" },
|
||||
{ "description", "Description" },
|
||||
{ "severity", "high" },
|
||||
{ "aliases", new DocumentArray { "CVE-2024-0001", "GHSA-aaaa-bbbb-cccc" } },
|
||||
{ "cvss", new DocumentArray
|
||||
{
|
||||
new DocumentObject
|
||||
{
|
||||
{ "version", "3.1" },
|
||||
{ "vector", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" },
|
||||
{ "baseScore", 9.8 },
|
||||
{ "baseSeverity", "critical" },
|
||||
{ "exploitabilityScore", 3.9 },
|
||||
{ "impactScore", 5.9 },
|
||||
{ "source", "nvd" },
|
||||
{ "isPrimary", true }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "affected", new DocumentArray
|
||||
{
|
||||
new DocumentObject
|
||||
{
|
||||
{ "ecosystem", "npm" },
|
||||
{ "packageName", "example" },
|
||||
{ "purl", "pkg:npm/example@1.0.0" },
|
||||
{ "range", "{\"introduced\":\"0\",\"fixed\":\"1.0.1\"}" },
|
||||
{ "versionsAffected", new DocumentArray { "1.0.0" } },
|
||||
{ "versionsFixed", new DocumentArray { "1.0.1" } },
|
||||
{ "databaseSpecific", "{\"severity\":\"high\"}" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "references", new DocumentArray
|
||||
{
|
||||
new DocumentObject
|
||||
{
|
||||
{ "type", "advisory" },
|
||||
{ "url", "https://ref.example/test" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "credits", new DocumentArray
|
||||
{
|
||||
new DocumentObject
|
||||
{
|
||||
{ "name", "Researcher One" },
|
||||
{ "contact", "r1@example.test" },
|
||||
{ "type", "finder" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "weaknesses", new DocumentArray
|
||||
{
|
||||
new DocumentObject
|
||||
{
|
||||
{ "cweId", "CWE-79" },
|
||||
{ "description", "XSS" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "kev", new DocumentArray
|
||||
{
|
||||
new DocumentObject
|
||||
{
|
||||
{ "cveId", "CVE-2024-0001" },
|
||||
{ "vendorProject", "Example" },
|
||||
{ "product", "Example Product" },
|
||||
{ "name", "Critical vuln" },
|
||||
{ "knownRansomwareUse", false },
|
||||
{ "dateAdded", DateTime.UtcNow },
|
||||
{ "dueDate", DateTime.UtcNow.AddDays(7) },
|
||||
{ "notes", "note" }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return new AdvisoryDocument
|
||||
{
|
||||
AdvisoryKey = "ADV-1",
|
||||
Payload = payload,
|
||||
Modified = DateTime.UtcNow,
|
||||
Published = DateTime.UtcNow.AddDays(-1)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,7 @@ public sealed class AdvisoryIdempotencyTests : IAsyncLifetime
|
||||
// Assert - Should have updated the cursor
|
||||
var retrieved = await _sourceStateRepository.GetBySourceIdAsync(source.Id);
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.LastCursor.Should().Be("cursor2");
|
||||
retrieved!.Cursor.Should().Be("cursor2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -369,11 +369,9 @@ public sealed class AdvisoryIdempotencyTests : IAsyncLifetime
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SourceId = sourceId,
|
||||
LastCursor = cursor ?? "default-cursor",
|
||||
LastFetchAt = DateTimeOffset.UtcNow,
|
||||
LastSuccessAt = DateTimeOffset.UtcNow,
|
||||
TotalAdvisoriesProcessed = 100,
|
||||
Status = "active"
|
||||
Cursor = cursor ?? "default-cursor",
|
||||
LastSyncAt = DateTimeOffset.UtcNow,
|
||||
LastSuccessAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,18 +13,9 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Update="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CanonicalAdvisoryEndpointTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Task: CANSVC-8200-020
|
||||
// Description: Integration tests for canonical advisory API endpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Concelier.WebService.Extensions;
|
||||
using StellaOps.Concelier.WebService.Tests.Fixtures;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.Canonical;
|
||||
|
||||
public sealed class CanonicalAdvisoryEndpointTests : IAsyncLifetime
|
||||
{
|
||||
private WebApplicationFactory<Program> _factory = null!;
|
||||
private HttpClient _client = null!;
|
||||
private readonly Mock<ICanonicalAdvisoryService> _serviceMock = new();
|
||||
|
||||
private static readonly Guid TestCanonicalId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private const string TestCve = "CVE-2025-0001";
|
||||
private const string TestArtifactKey = "pkg:npm/lodash@4.17.21";
|
||||
private const string TestMergeHash = "sha256:abc123def456789";
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove existing ICanonicalAdvisoryService registration if any
|
||||
var descriptor = services.FirstOrDefault(d =>
|
||||
d.ServiceType == typeof(ICanonicalAdvisoryService));
|
||||
if (descriptor != null)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
|
||||
// Register mock service
|
||||
services.AddSingleton(_serviceMock.Object);
|
||||
});
|
||||
});
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
_factory.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#region GET /api/v1/canonical/{id}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_ReturnsOk_WhenCanonicalExists()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical(TestCanonicalId, TestCve);
|
||||
_serviceMock
|
||||
.Setup(x => x.GetByIdAsync(TestCanonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonical);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/canonical/{TestCanonicalId}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryResponse>();
|
||||
content.Should().NotBeNull();
|
||||
content!.Id.Should().Be(TestCanonicalId);
|
||||
content.Cve.Should().Be(TestCve);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_ReturnsNotFound_WhenCanonicalDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var nonExistentId = Guid.NewGuid();
|
||||
_serviceMock
|
||||
.Setup(x => x.GetByIdAsync(nonExistentId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/canonical/{nonExistentId}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GET /api/v1/canonical?cve={cve}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryByCve_ReturnsCanonicals()
|
||||
{
|
||||
// Arrange
|
||||
var canonicals = new List<CanonicalAdvisory>
|
||||
{
|
||||
CreateTestCanonical(TestCanonicalId, TestCve),
|
||||
CreateTestCanonical(Guid.NewGuid(), TestCve)
|
||||
};
|
||||
_serviceMock
|
||||
.Setup(x => x.GetByCveAsync(TestCve, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonicals);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/canonical?cve={TestCve}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryListResponse>();
|
||||
content.Should().NotBeNull();
|
||||
content!.Items.Should().HaveCount(2);
|
||||
content.TotalCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryByCve_ReturnsEmptyList_WhenNoneFound()
|
||||
{
|
||||
// Arrange
|
||||
_serviceMock
|
||||
.Setup(x => x.GetByCveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory>());
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v1/canonical?cve=CVE-9999-9999");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryListResponse>();
|
||||
content.Should().NotBeNull();
|
||||
content!.Items.Should().BeEmpty();
|
||||
content.TotalCount.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GET /api/v1/canonical?artifact={artifact}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryByArtifact_ReturnsCanonicals()
|
||||
{
|
||||
// Arrange
|
||||
var canonicals = new List<CanonicalAdvisory>
|
||||
{
|
||||
CreateTestCanonical(TestCanonicalId, TestCve, TestArtifactKey)
|
||||
};
|
||||
_serviceMock
|
||||
.Setup(x => x.GetByArtifactAsync(TestArtifactKey, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonicals);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/canonical?artifact={Uri.EscapeDataString(TestArtifactKey)}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryListResponse>();
|
||||
content.Should().NotBeNull();
|
||||
content!.Items.Should().HaveCount(1);
|
||||
content.Items[0].AffectsKey.Should().Be(TestArtifactKey);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GET /api/v1/canonical?mergeHash={mergeHash}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryByMergeHash_ReturnsCanonical()
|
||||
{
|
||||
// Arrange
|
||||
var canonical = CreateTestCanonical(TestCanonicalId, TestCve);
|
||||
_serviceMock
|
||||
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(canonical);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/canonical?mergeHash={Uri.EscapeDataString(TestMergeHash)}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryListResponse>();
|
||||
content.Should().NotBeNull();
|
||||
content!.Items.Should().HaveCount(1);
|
||||
content.TotalCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryByMergeHash_ReturnsEmpty_WhenNotFound()
|
||||
{
|
||||
// Arrange
|
||||
_serviceMock
|
||||
.Setup(x => x.GetByMergeHashAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/canonical?mergeHash=sha256:nonexistent");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryListResponse>();
|
||||
content.Should().NotBeNull();
|
||||
content!.Items.Should().BeEmpty();
|
||||
content.TotalCount.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GET /api/v1/canonical (pagination)
|
||||
|
||||
[Fact]
|
||||
public async Task Query_SupportsPagination()
|
||||
{
|
||||
// Arrange
|
||||
var pagedResult = new PagedResult<CanonicalAdvisory>
|
||||
{
|
||||
Items = new List<CanonicalAdvisory> { CreateTestCanonical(TestCanonicalId, TestCve) },
|
||||
TotalCount = 100,
|
||||
Offset = 10,
|
||||
Limit = 25
|
||||
};
|
||||
_serviceMock
|
||||
.Setup(x => x.QueryAsync(It.Is<CanonicalQueryOptions>(o =>
|
||||
o.Offset == 10 && o.Limit == 25), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pagedResult);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v1/canonical?offset=10&limit=25");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<CanonicalAdvisoryListResponse>();
|
||||
content.Should().NotBeNull();
|
||||
content!.TotalCount.Should().Be(100);
|
||||
content.Offset.Should().Be(10);
|
||||
content.Limit.Should().Be(25);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region POST /api/v1/canonical/ingest/{source}
|
||||
|
||||
[Fact]
|
||||
public async Task Ingest_ReturnsOk_WhenCreated()
|
||||
{
|
||||
// Arrange
|
||||
var ingestResult = IngestResult.Created(TestCanonicalId, TestMergeHash, Guid.NewGuid(), "nvd", "NVD-001");
|
||||
_serviceMock
|
||||
.Setup(x => x.IngestAsync(
|
||||
"nvd",
|
||||
It.Is<RawAdvisory>(a => a.Cve == TestCve),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ingestResult);
|
||||
|
||||
var request = new RawAdvisoryRequest
|
||||
{
|
||||
Cve = TestCve,
|
||||
AffectsKey = TestArtifactKey,
|
||||
VersionRangeJson = "{\"introduced\":\"1.0.0\",\"fixed\":\"1.2.0\"}",
|
||||
Severity = "high",
|
||||
Title = "Test vulnerability"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/canonical/ingest/nvd", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<IngestResultResponse>();
|
||||
content.Should().NotBeNull();
|
||||
content!.Decision.Should().Be("Created");
|
||||
content.CanonicalId.Should().Be(TestCanonicalId);
|
||||
content.MergeHash.Should().Be(TestMergeHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ingest_ReturnsOk_WhenMerged()
|
||||
{
|
||||
// Arrange
|
||||
var ingestResult = IngestResult.Merged(TestCanonicalId, TestMergeHash, Guid.NewGuid(), "ghsa", "GHSA-001");
|
||||
_serviceMock
|
||||
.Setup(x => x.IngestAsync(
|
||||
"ghsa",
|
||||
It.IsAny<RawAdvisory>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ingestResult);
|
||||
|
||||
var request = new RawAdvisoryRequest
|
||||
{
|
||||
Cve = TestCve,
|
||||
AffectsKey = TestArtifactKey
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/canonical/ingest/ghsa", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<IngestResultResponse>();
|
||||
content.Should().NotBeNull();
|
||||
content!.Decision.Should().Be("Merged");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ingest_ReturnsConflict_WhenConflict()
|
||||
{
|
||||
// Arrange
|
||||
var ingestResult = IngestResult.Conflict(TestCanonicalId, TestMergeHash, "Version range mismatch", "nvd", "NVD-002");
|
||||
_serviceMock
|
||||
.Setup(x => x.IngestAsync(
|
||||
"nvd",
|
||||
It.IsAny<RawAdvisory>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ingestResult);
|
||||
|
||||
var request = new RawAdvisoryRequest
|
||||
{
|
||||
Cve = TestCve,
|
||||
AffectsKey = TestArtifactKey
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/canonical/ingest/nvd", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<IngestResultResponse>();
|
||||
content.Should().NotBeNull();
|
||||
content!.Decision.Should().Be("Conflict");
|
||||
content.ConflictReason.Should().Be("Version range mismatch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ingest_ReturnsBadRequest_WhenCveMissing()
|
||||
{
|
||||
// Arrange
|
||||
var request = new RawAdvisoryRequest
|
||||
{
|
||||
AffectsKey = TestArtifactKey
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/canonical/ingest/nvd", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ingest_ReturnsBadRequest_WhenAffectsKeyMissing()
|
||||
{
|
||||
// Arrange
|
||||
var request = new RawAdvisoryRequest
|
||||
{
|
||||
Cve = TestCve
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/canonical/ingest/nvd", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region POST /api/v1/canonical/ingest/{source}/batch
|
||||
|
||||
[Fact]
|
||||
public async Task IngestBatch_ReturnsOk_WithSummary()
|
||||
{
|
||||
// Arrange
|
||||
var results = new List<IngestResult>
|
||||
{
|
||||
IngestResult.Created(Guid.NewGuid(), "hash1", Guid.NewGuid(), "nvd", "NVD-001"),
|
||||
IngestResult.Merged(Guid.NewGuid(), "hash2", Guid.NewGuid(), "nvd", "NVD-002"),
|
||||
IngestResult.Duplicate(Guid.NewGuid(), "hash3", "nvd", "NVD-003")
|
||||
};
|
||||
_serviceMock
|
||||
.Setup(x => x.IngestBatchAsync(
|
||||
"nvd",
|
||||
It.IsAny<IEnumerable<RawAdvisory>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(results);
|
||||
|
||||
var requests = new[]
|
||||
{
|
||||
new RawAdvisoryRequest { Cve = "CVE-2025-0001", AffectsKey = "pkg:npm/a@1" },
|
||||
new RawAdvisoryRequest { Cve = "CVE-2025-0002", AffectsKey = "pkg:npm/b@1" },
|
||||
new RawAdvisoryRequest { Cve = "CVE-2025-0003", AffectsKey = "pkg:npm/c@1" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/canonical/ingest/nvd/batch", requests);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<BatchIngestResultResponse>();
|
||||
content.Should().NotBeNull();
|
||||
content!.Results.Should().HaveCount(3);
|
||||
content.Summary.Total.Should().Be(3);
|
||||
content.Summary.Created.Should().Be(1);
|
||||
content.Summary.Merged.Should().Be(1);
|
||||
content.Summary.Duplicates.Should().Be(1);
|
||||
content.Summary.Conflicts.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PATCH /api/v1/canonical/{id}/status
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatus_ReturnsOk_WhenValid()
|
||||
{
|
||||
// Arrange
|
||||
_serviceMock
|
||||
.Setup(x => x.UpdateStatusAsync(TestCanonicalId, CanonicalStatus.Withdrawn, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var request = new UpdateStatusRequest { Status = "Withdrawn" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PatchAsJsonAsync($"/api/v1/canonical/{TestCanonicalId}/status", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
_serviceMock.Verify(x => x.UpdateStatusAsync(
|
||||
TestCanonicalId,
|
||||
CanonicalStatus.Withdrawn,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatus_ReturnsBadRequest_WhenInvalidStatus()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateStatusRequest { Status = "InvalidStatus" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PatchAsJsonAsync($"/api/v1/canonical/{TestCanonicalId}/status", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static CanonicalAdvisory CreateTestCanonical(
|
||||
Guid id,
|
||||
string cve,
|
||||
string affectsKey = "pkg:npm/example@1")
|
||||
{
|
||||
return new CanonicalAdvisory
|
||||
{
|
||||
Id = id,
|
||||
Cve = cve,
|
||||
AffectsKey = affectsKey,
|
||||
MergeHash = TestMergeHash,
|
||||
Status = CanonicalStatus.Active,
|
||||
Severity = "high",
|
||||
Title = $"Test advisory for {cve}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
SourceEdges = new List<SourceEdge>
|
||||
{
|
||||
new SourceEdge
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SourceName = "nvd",
|
||||
SourceAdvisoryId = $"NVD-{cve}",
|
||||
SourceDocHash = "sha256:doctest",
|
||||
PrecedenceRank = 40,
|
||||
FetchedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Update="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user