680 lines
24 KiB
C#
680 lines
24 KiB
C#
using System.Collections.Immutable;
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.Metrics;
|
|
using System.Text.Json;
|
|
using CycloneDX.Json;
|
|
using CycloneDX.Models;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Scanner.Storage;
|
|
using StellaOps.Scanner.Storage.Catalog;
|
|
using StellaOps.Scanner.Storage.ObjectStore;
|
|
using StellaOps.Scanner.Storage.Repositories;
|
|
using StellaOps.Zastava.Core.Contracts;
|
|
|
|
namespace StellaOps.Scanner.WebService.Services;
|
|
|
|
/// <summary>
|
|
/// Service responsible for reconciling runtime-observed libraries against static SBOM inventory.
|
|
/// </summary>
|
|
internal interface IRuntimeInventoryReconciler
|
|
{
|
|
/// <summary>
|
|
/// Reconciles runtime libraries from a runtime event against the SBOM for the associated image.
|
|
/// </summary>
|
|
Task<RuntimeReconciliationResult> ReconcileAsync(
|
|
RuntimeReconciliationRequest request,
|
|
CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request for runtime-static reconciliation.
|
|
/// </summary>
|
|
internal sealed record RuntimeReconciliationRequest
|
|
{
|
|
/// <summary>
|
|
/// Image digest to reconcile (e.g., sha256:abc123...).
|
|
/// </summary>
|
|
public required string ImageDigest { get; init; }
|
|
|
|
/// <summary>
|
|
/// Optional runtime event ID to use for library data.
|
|
/// If not provided, the most recent event for the image will be used.
|
|
/// </summary>
|
|
public string? RuntimeEventId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Maximum number of misses to return.
|
|
/// </summary>
|
|
public int MaxMisses { get; init; } = 100;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of runtime-static reconciliation.
|
|
/// </summary>
|
|
internal sealed record RuntimeReconciliationResult
|
|
{
|
|
public required string ImageDigest { get; init; }
|
|
|
|
public string? RuntimeEventId { get; init; }
|
|
|
|
public string? SbomArtifactId { get; init; }
|
|
|
|
public int TotalRuntimeLibraries { get; init; }
|
|
|
|
public int TotalSbomComponents { get; init; }
|
|
|
|
public int MatchCount { get; init; }
|
|
|
|
public int MissCount { get; init; }
|
|
|
|
/// <summary>
|
|
/// Libraries observed at runtime but not found in SBOM.
|
|
/// </summary>
|
|
public ImmutableArray<RuntimeLibraryMiss> Misses { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// Libraries matched between runtime and SBOM.
|
|
/// </summary>
|
|
public ImmutableArray<RuntimeLibraryMatch> Matches { get; init; } = [];
|
|
|
|
public DateTimeOffset ReconciledAt { get; init; }
|
|
|
|
public string? ErrorCode { get; init; }
|
|
|
|
public string? ErrorMessage { get; init; }
|
|
|
|
public static RuntimeReconciliationResult Error(string imageDigest, string code, string message)
|
|
=> new()
|
|
{
|
|
ImageDigest = imageDigest,
|
|
ErrorCode = code,
|
|
ErrorMessage = message,
|
|
ReconciledAt = DateTimeOffset.UtcNow
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// A runtime library not found in the SBOM.
|
|
/// </summary>
|
|
internal sealed record RuntimeLibraryMiss
|
|
{
|
|
public required string Path { get; init; }
|
|
|
|
public string? Sha256 { get; init; }
|
|
|
|
public long? Inode { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A runtime library matched in the SBOM.
|
|
/// </summary>
|
|
internal sealed record RuntimeLibraryMatch
|
|
{
|
|
public required string RuntimePath { get; init; }
|
|
|
|
public string? RuntimeSha256 { get; init; }
|
|
|
|
public required string SbomComponentKey { get; init; }
|
|
|
|
public string? SbomComponentName { get; init; }
|
|
|
|
public string? MatchType { get; init; }
|
|
}
|
|
|
|
internal sealed class RuntimeInventoryReconciler : IRuntimeInventoryReconciler
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
|
|
private static readonly Meter ReconcileMeter = new("StellaOps.Scanner.RuntimeReconcile", "1.0.0");
|
|
private static readonly Counter<long> ReconcileRequests = ReconcileMeter.CreateCounter<long>(
|
|
"scanner_runtime_reconcile_requests_total",
|
|
unit: "1",
|
|
description: "Total runtime-static reconciliation requests processed.");
|
|
private static readonly Counter<long> ReconcileMatches = ReconcileMeter.CreateCounter<long>(
|
|
"scanner_runtime_reconcile_matches_total",
|
|
unit: "1",
|
|
description: "Total library matches between runtime and SBOM.");
|
|
private static readonly Counter<long> ReconcileMisses = ReconcileMeter.CreateCounter<long>(
|
|
"scanner_runtime_reconcile_misses_total",
|
|
unit: "1",
|
|
description: "Total runtime libraries not found in SBOM.");
|
|
private static readonly Counter<long> ReconcileErrors = ReconcileMeter.CreateCounter<long>(
|
|
"scanner_runtime_reconcile_errors_total",
|
|
unit: "1",
|
|
description: "Total reconciliation errors (no SBOM, no events, etc.).");
|
|
private static readonly Histogram<double> ReconcileLatencyMs = ReconcileMeter.CreateHistogram<double>(
|
|
"scanner_runtime_reconcile_latency_ms",
|
|
unit: "ms",
|
|
description: "Latency for runtime-static reconciliation operations.");
|
|
|
|
private readonly RuntimeEventRepository _runtimeEventRepository;
|
|
private readonly LinkRepository _linkRepository;
|
|
private readonly ArtifactRepository _artifactRepository;
|
|
private readonly IArtifactObjectStore _objectStore;
|
|
private readonly IOptionsMonitor<ScannerStorageOptions> _storageOptions;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<RuntimeInventoryReconciler> _logger;
|
|
|
|
public RuntimeInventoryReconciler(
|
|
RuntimeEventRepository runtimeEventRepository,
|
|
LinkRepository linkRepository,
|
|
ArtifactRepository artifactRepository,
|
|
IArtifactObjectStore objectStore,
|
|
IOptionsMonitor<ScannerStorageOptions> storageOptions,
|
|
TimeProvider timeProvider,
|
|
ILogger<RuntimeInventoryReconciler> logger)
|
|
{
|
|
_runtimeEventRepository = runtimeEventRepository ?? throw new ArgumentNullException(nameof(runtimeEventRepository));
|
|
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
|
|
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
|
|
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
|
|
_storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions));
|
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public async Task<RuntimeReconciliationResult> ReconcileAsync(
|
|
RuntimeReconciliationRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.ImageDigest);
|
|
|
|
var stopwatch = Stopwatch.StartNew();
|
|
ReconcileRequests.Add(1);
|
|
|
|
var normalizedDigest = NormalizeDigest(request.ImageDigest);
|
|
var reconciledAt = _timeProvider.GetUtcNow();
|
|
|
|
// Step 1: Get runtime event
|
|
RuntimeEventDocument? runtimeEventDoc;
|
|
if (!string.IsNullOrWhiteSpace(request.RuntimeEventId))
|
|
{
|
|
runtimeEventDoc = await _runtimeEventRepository.GetByEventIdAsync(
|
|
request.RuntimeEventId,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
if (runtimeEventDoc is null)
|
|
{
|
|
ReconcileErrors.Add(1);
|
|
RecordLatency(stopwatch);
|
|
return RuntimeReconciliationResult.Error(
|
|
normalizedDigest,
|
|
"RUNTIME_EVENT_NOT_FOUND",
|
|
$"Runtime event '{request.RuntimeEventId}' not found.");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var recentEvents = await _runtimeEventRepository.GetByImageDigestAsync(
|
|
normalizedDigest,
|
|
1,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
runtimeEventDoc = recentEvents.FirstOrDefault();
|
|
if (runtimeEventDoc is null)
|
|
{
|
|
ReconcileErrors.Add(1);
|
|
RecordLatency(stopwatch);
|
|
return RuntimeReconciliationResult.Error(
|
|
normalizedDigest,
|
|
"NO_RUNTIME_EVENTS",
|
|
$"No runtime events found for image '{normalizedDigest}'.");
|
|
}
|
|
}
|
|
|
|
// Step 2: Parse runtime event payload to get LoadedLibraries
|
|
var runtimeLibraries = ParseLoadedLibraries(runtimeEventDoc.PayloadJson);
|
|
if (runtimeLibraries.Count == 0)
|
|
{
|
|
_logger.LogInformation(
|
|
"No loaded libraries in runtime event {EventId} for image {ImageDigest}",
|
|
runtimeEventDoc.EventId,
|
|
normalizedDigest);
|
|
|
|
RecordLatency(stopwatch);
|
|
return new RuntimeReconciliationResult
|
|
{
|
|
ImageDigest = normalizedDigest,
|
|
RuntimeEventId = runtimeEventDoc.EventId,
|
|
TotalRuntimeLibraries = 0,
|
|
TotalSbomComponents = 0,
|
|
MatchCount = 0,
|
|
MissCount = 0,
|
|
ReconciledAt = reconciledAt
|
|
};
|
|
}
|
|
|
|
// Step 3: Get SBOM artifact for the image
|
|
var links = await _linkRepository.ListBySourceAsync(
|
|
LinkSourceType.Image,
|
|
normalizedDigest,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
var sbomLink = links.FirstOrDefault(l =>
|
|
l.ArtifactId.Contains("imagebom", StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (sbomLink is null)
|
|
{
|
|
_logger.LogWarning(
|
|
"No SBOM artifact linked to image {ImageDigest}",
|
|
normalizedDigest);
|
|
|
|
ReconcileMisses.Add(runtimeLibraries.Count);
|
|
ReconcileErrors.Add(1);
|
|
RecordLatency(stopwatch);
|
|
|
|
// Return all runtime libraries as misses since no SBOM exists
|
|
return new RuntimeReconciliationResult
|
|
{
|
|
ImageDigest = normalizedDigest,
|
|
RuntimeEventId = runtimeEventDoc.EventId,
|
|
TotalRuntimeLibraries = runtimeLibraries.Count,
|
|
TotalSbomComponents = 0,
|
|
MatchCount = 0,
|
|
MissCount = runtimeLibraries.Count,
|
|
Misses = runtimeLibraries
|
|
.Take(request.MaxMisses)
|
|
.Select(lib => new RuntimeLibraryMiss
|
|
{
|
|
Path = lib.Path,
|
|
Sha256 = lib.Sha256,
|
|
Inode = lib.Inode
|
|
})
|
|
.ToImmutableArray(),
|
|
ReconciledAt = reconciledAt,
|
|
ErrorCode = "NO_SBOM",
|
|
ErrorMessage = "No SBOM artifact linked to this image."
|
|
};
|
|
}
|
|
|
|
// Step 4: Get SBOM content
|
|
var sbomArtifact = await _artifactRepository.GetAsync(sbomLink.ArtifactId, cancellationToken).ConfigureAwait(false);
|
|
if (sbomArtifact is null)
|
|
{
|
|
ReconcileErrors.Add(1);
|
|
RecordLatency(stopwatch);
|
|
return RuntimeReconciliationResult.Error(
|
|
normalizedDigest,
|
|
"SBOM_ARTIFACT_NOT_FOUND",
|
|
$"SBOM artifact '{sbomLink.ArtifactId}' metadata not found.");
|
|
}
|
|
|
|
var sbomComponents = await LoadSbomComponentsAsync(sbomArtifact, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Step 5: Build lookup indexes for matching
|
|
var sbomByPath = BuildPathIndex(sbomComponents);
|
|
var sbomByHash = BuildHashIndex(sbomComponents);
|
|
|
|
// Step 6: Reconcile
|
|
var matches = new List<RuntimeLibraryMatch>();
|
|
var misses = new List<RuntimeLibraryMiss>();
|
|
|
|
foreach (var runtimeLib in runtimeLibraries)
|
|
{
|
|
var matched = TryMatchLibrary(runtimeLib, sbomByPath, sbomByHash, out var match);
|
|
if (matched && match is not null)
|
|
{
|
|
matches.Add(match);
|
|
}
|
|
else
|
|
{
|
|
misses.Add(new RuntimeLibraryMiss
|
|
{
|
|
Path = runtimeLib.Path,
|
|
Sha256 = runtimeLib.Sha256,
|
|
Inode = runtimeLib.Inode
|
|
});
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"Reconciliation complete for image {ImageDigest}: {MatchCount} matches, {MissCount} misses out of {TotalRuntime} runtime libs",
|
|
normalizedDigest,
|
|
matches.Count,
|
|
misses.Count,
|
|
runtimeLibraries.Count);
|
|
|
|
// Record metrics
|
|
ReconcileMatches.Add(matches.Count);
|
|
ReconcileMisses.Add(misses.Count);
|
|
RecordLatency(stopwatch);
|
|
|
|
return new RuntimeReconciliationResult
|
|
{
|
|
ImageDigest = normalizedDigest,
|
|
RuntimeEventId = runtimeEventDoc.EventId,
|
|
SbomArtifactId = sbomArtifact.Id,
|
|
TotalRuntimeLibraries = runtimeLibraries.Count,
|
|
TotalSbomComponents = sbomComponents.Count,
|
|
MatchCount = matches.Count,
|
|
MissCount = misses.Count,
|
|
Matches = matches.ToImmutableArray(),
|
|
Misses = misses.Take(request.MaxMisses).ToImmutableArray(),
|
|
ReconciledAt = reconciledAt
|
|
};
|
|
}
|
|
|
|
private IReadOnlyList<RuntimeLoadedLibrary> ParseLoadedLibraries(string payloadJson)
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(payloadJson);
|
|
var root = doc.RootElement;
|
|
|
|
// Navigate to event.loadedLibs
|
|
if (root.TryGetProperty("event", out var eventElement) &&
|
|
eventElement.TryGetProperty("loadedLibs", out var loadedLibsElement))
|
|
{
|
|
return JsonSerializer.Deserialize<List<RuntimeLoadedLibrary>>(
|
|
loadedLibsElement.GetRawText(),
|
|
JsonOptions) ?? [];
|
|
}
|
|
|
|
// Fallback: try loadedLibs at root level
|
|
if (root.TryGetProperty("loadedLibs", out loadedLibsElement))
|
|
{
|
|
return JsonSerializer.Deserialize<List<RuntimeLoadedLibrary>>(
|
|
loadedLibsElement.GetRawText(),
|
|
JsonOptions) ?? [];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to parse loadedLibraries from runtime event payload");
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private async Task<IReadOnlyList<SbomComponent>> LoadSbomComponentsAsync(
|
|
ArtifactDocument artifact,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var options = _storageOptions.CurrentValue;
|
|
|
|
var primaryKey = ArtifactObjectKeyBuilder.Build(
|
|
artifact.Type,
|
|
artifact.Format,
|
|
artifact.BytesSha256,
|
|
options.ObjectStore.RootPrefix);
|
|
|
|
var candidates = new List<string>
|
|
{
|
|
primaryKey,
|
|
ArtifactObjectKeyBuilder.Build(
|
|
artifact.Type,
|
|
artifact.Format,
|
|
artifact.BytesSha256,
|
|
rootPrefix: null)
|
|
};
|
|
|
|
var legacyDigest = NormalizeLegacyDigest(artifact.BytesSha256);
|
|
candidates.Add($"{MapLegacyTypeSegment(artifact.Type)}/{MapLegacyFormatSegment(artifact.Format)}/{legacyDigest}");
|
|
|
|
if (legacyDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
candidates.Add($"{MapLegacyTypeSegment(artifact.Type)}/{MapLegacyFormatSegment(artifact.Format)}/{legacyDigest["sha256:".Length..]}");
|
|
}
|
|
|
|
Stream? stream = null;
|
|
string? resolvedKey = null;
|
|
|
|
foreach (var candidateKey in candidates.Distinct(StringComparer.Ordinal))
|
|
{
|
|
var descriptor = new ArtifactObjectDescriptor(
|
|
options.ObjectStore.BucketName,
|
|
candidateKey,
|
|
artifact.Immutable);
|
|
|
|
stream = await _objectStore.GetAsync(descriptor, cancellationToken).ConfigureAwait(false);
|
|
if (stream is not null)
|
|
{
|
|
resolvedKey = candidateKey;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (stream is null)
|
|
{
|
|
_logger.LogWarning("SBOM artifact content not found at {Key}", primaryKey);
|
|
return [];
|
|
}
|
|
|
|
try
|
|
{
|
|
await using (stream)
|
|
{
|
|
var bom = await Serializer.DeserializeAsync(stream).ConfigureAwait(false);
|
|
if (bom?.Components is null)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return bom.Components
|
|
.Select(c => new SbomComponent
|
|
{
|
|
BomRef = c.BomRef ?? string.Empty,
|
|
Name = c.Name ?? string.Empty,
|
|
Version = c.Version,
|
|
Purl = c.Purl,
|
|
Hashes = c.Hashes?
|
|
.Where(h => h.Alg == Hash.HashAlgorithm.SHA_256)
|
|
.Select(h => h.Content)
|
|
.Where(content => !string.IsNullOrWhiteSpace(content))
|
|
.ToList() ?? [],
|
|
FilePaths = ExtractFilePaths(c)
|
|
})
|
|
.ToList();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to deserialize SBOM from artifact {ArtifactId} ({ResolvedKey})", artifact.Id, resolvedKey ?? primaryKey);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<string> ExtractFilePaths(Component component)
|
|
{
|
|
var paths = new List<string>();
|
|
|
|
// Extract from evidence.occurrences
|
|
if (component.Evidence?.Occurrences is { } occurrences)
|
|
{
|
|
foreach (var occurrence in occurrences)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(occurrence.Location))
|
|
{
|
|
paths.Add(occurrence.Location);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract from properties with specific names
|
|
if (component.Properties is { } props)
|
|
{
|
|
foreach (var prop in props)
|
|
{
|
|
if (prop.Name is "stellaops:file.path" or "cdx:file:path" &&
|
|
!string.IsNullOrWhiteSpace(prop.Value))
|
|
{
|
|
paths.Add(prop.Value);
|
|
}
|
|
}
|
|
}
|
|
|
|
return paths;
|
|
}
|
|
|
|
private static Dictionary<string, SbomComponent> BuildPathIndex(IReadOnlyList<SbomComponent> components)
|
|
{
|
|
var index = new Dictionary<string, SbomComponent>(StringComparer.Ordinal);
|
|
|
|
foreach (var component in components)
|
|
{
|
|
foreach (var path in component.FilePaths)
|
|
{
|
|
var normalizedPath = NormalizePath(path);
|
|
index.TryAdd(normalizedPath, component);
|
|
}
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
private static Dictionary<string, SbomComponent> BuildHashIndex(IReadOnlyList<SbomComponent> components)
|
|
{
|
|
var index = new Dictionary<string, SbomComponent>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var component in components)
|
|
{
|
|
foreach (var hash in component.Hashes)
|
|
{
|
|
var normalizedHash = NormalizeHash(hash);
|
|
index.TryAdd(normalizedHash, component);
|
|
}
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
private static bool TryMatchLibrary(
|
|
RuntimeLoadedLibrary runtimeLib,
|
|
Dictionary<string, SbomComponent> pathIndex,
|
|
Dictionary<string, SbomComponent> hashIndex,
|
|
out RuntimeLibraryMatch? match)
|
|
{
|
|
match = null;
|
|
|
|
// Try hash match first (most reliable)
|
|
if (!string.IsNullOrWhiteSpace(runtimeLib.Sha256))
|
|
{
|
|
var normalizedHash = NormalizeHash(runtimeLib.Sha256);
|
|
if (hashIndex.TryGetValue(normalizedHash, out var componentByHash))
|
|
{
|
|
match = new RuntimeLibraryMatch
|
|
{
|
|
RuntimePath = runtimeLib.Path,
|
|
RuntimeSha256 = runtimeLib.Sha256,
|
|
SbomComponentKey = componentByHash.BomRef,
|
|
SbomComponentName = componentByHash.Name,
|
|
MatchType = "sha256"
|
|
};
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Try path match
|
|
var normalizedPath = NormalizePath(runtimeLib.Path);
|
|
if (pathIndex.TryGetValue(normalizedPath, out var componentByPath))
|
|
{
|
|
match = new RuntimeLibraryMatch
|
|
{
|
|
RuntimePath = runtimeLib.Path,
|
|
RuntimeSha256 = runtimeLib.Sha256,
|
|
SbomComponentKey = componentByPath.BomRef,
|
|
SbomComponentName = componentByPath.Name,
|
|
MatchType = "path"
|
|
};
|
|
return true;
|
|
}
|
|
|
|
// Try matching by filename only (less strict)
|
|
var fileName = Path.GetFileName(runtimeLib.Path);
|
|
if (!string.IsNullOrWhiteSpace(fileName))
|
|
{
|
|
foreach (var component in pathIndex.Values)
|
|
{
|
|
if (component.FilePaths.Any(p => Path.GetFileName(p).Equals(fileName, StringComparison.Ordinal)))
|
|
{
|
|
match = new RuntimeLibraryMatch
|
|
{
|
|
RuntimePath = runtimeLib.Path,
|
|
RuntimeSha256 = runtimeLib.Sha256,
|
|
SbomComponentKey = component.BomRef,
|
|
SbomComponentName = component.Name,
|
|
MatchType = "filename"
|
|
};
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static string NormalizeDigest(string digest)
|
|
{
|
|
var trimmed = digest.Trim();
|
|
return trimmed.ToLowerInvariant();
|
|
}
|
|
|
|
private static string NormalizePath(string path)
|
|
{
|
|
// Normalize to forward slashes and trim
|
|
return path.Trim().Replace('\\', '/');
|
|
}
|
|
|
|
private static string NormalizeHash(string hash)
|
|
{
|
|
// Remove "sha256:" prefix if present and normalize to lowercase
|
|
var trimmed = hash.Trim();
|
|
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
trimmed = trimmed["sha256:".Length..];
|
|
}
|
|
return trimmed.ToLowerInvariant();
|
|
}
|
|
|
|
private static string NormalizeLegacyDigest(string digest)
|
|
=> digest.Contains(':', StringComparison.Ordinal)
|
|
? digest.Trim()
|
|
: $"sha256:{digest.Trim()}";
|
|
|
|
private static string MapLegacyTypeSegment(ArtifactDocumentType type) => type switch
|
|
{
|
|
ArtifactDocumentType.LayerBom => "layerbom",
|
|
ArtifactDocumentType.ImageBom => "imagebom",
|
|
ArtifactDocumentType.Index => "index",
|
|
ArtifactDocumentType.Attestation => "attestation",
|
|
ArtifactDocumentType.SurfaceManifest => "surface-manifest",
|
|
ArtifactDocumentType.SurfaceEntryTrace => "surface-entrytrace",
|
|
ArtifactDocumentType.SurfaceLayerFragment => "surface-layer-fragment",
|
|
ArtifactDocumentType.Diff => "diff",
|
|
_ => type.ToString().ToLowerInvariant()
|
|
};
|
|
|
|
private static string MapLegacyFormatSegment(ArtifactDocumentFormat format) => format switch
|
|
{
|
|
ArtifactDocumentFormat.CycloneDxJson => "cyclonedx-json",
|
|
ArtifactDocumentFormat.CycloneDxProtobuf => "cyclonedx-protobuf",
|
|
ArtifactDocumentFormat.SpdxJson => "spdx-json",
|
|
ArtifactDocumentFormat.BomIndex => "bom-index",
|
|
ArtifactDocumentFormat.DsseJson => "dsse-json",
|
|
ArtifactDocumentFormat.SurfaceManifestJson => "surface-manifest-json",
|
|
ArtifactDocumentFormat.EntryTraceNdjson => "entrytrace-ndjson",
|
|
ArtifactDocumentFormat.EntryTraceGraphJson => "entrytrace-graph-json",
|
|
ArtifactDocumentFormat.ComponentFragmentJson => "component-fragment-json",
|
|
_ => format.ToString().ToLowerInvariant()
|
|
};
|
|
|
|
private static void RecordLatency(Stopwatch stopwatch)
|
|
{
|
|
stopwatch.Stop();
|
|
ReconcileLatencyMs.Record(stopwatch.Elapsed.TotalMilliseconds);
|
|
}
|
|
|
|
private sealed record SbomComponent
|
|
{
|
|
public required string BomRef { get; init; }
|
|
public required string Name { get; init; }
|
|
public string? Version { get; init; }
|
|
public string? Purl { get; init; }
|
|
public IReadOnlyList<string> Hashes { get; init; } = [];
|
|
public IReadOnlyList<string> FilePaths { get; init; } = [];
|
|
}
|
|
}
|