up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
@@ -20,3 +20,91 @@ public sealed record RuntimeEventsIngestResponseDto
|
||||
[JsonPropertyName("duplicates")]
|
||||
public int Duplicates { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RuntimeReconcileRequestDto
|
||||
{
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("runtimeEventId")]
|
||||
public string? RuntimeEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("maxMisses")]
|
||||
public int MaxMisses { get; init; } = 100;
|
||||
}
|
||||
|
||||
public sealed record RuntimeReconcileResponseDto
|
||||
{
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("runtimeEventId")]
|
||||
public string? RuntimeEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomArtifactId")]
|
||||
public string? SbomArtifactId { get; init; }
|
||||
|
||||
[JsonPropertyName("totalRuntimeLibraries")]
|
||||
public int TotalRuntimeLibraries { get; init; }
|
||||
|
||||
[JsonPropertyName("totalSbomComponents")]
|
||||
public int TotalSbomComponents { get; init; }
|
||||
|
||||
[JsonPropertyName("matchCount")]
|
||||
public int MatchCount { get; init; }
|
||||
|
||||
[JsonPropertyName("missCount")]
|
||||
public int MissCount { get; init; }
|
||||
|
||||
[JsonPropertyName("misses")]
|
||||
public IReadOnlyList<RuntimeLibraryMissDto> Misses { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("matches")]
|
||||
public IReadOnlyList<RuntimeLibraryMatchDto> Matches { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("reconciledAt")]
|
||||
public DateTimeOffset ReconciledAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errorCode")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ErrorCode { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RuntimeLibraryMissDto
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Sha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("inode")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? Inode { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RuntimeLibraryMatchDto
|
||||
{
|
||||
[JsonPropertyName("runtimePath")]
|
||||
public required string RuntimePath { get; init; }
|
||||
|
||||
[JsonPropertyName("runtimeSha256")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RuntimeSha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomComponentKey")]
|
||||
public required string SbomComponentKey { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomComponentName")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SbomComponentName { get; init; }
|
||||
|
||||
[JsonPropertyName("matchType")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? MatchType { get; init; }
|
||||
}
|
||||
|
||||
@@ -37,6 +37,15 @@ internal static class RuntimeEndpoints
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status429TooManyRequests)
|
||||
.RequireAuthorization(ScannerPolicies.RuntimeIngest);
|
||||
|
||||
runtime.MapPost("/reconcile", HandleRuntimeReconcileAsync)
|
||||
.WithName("scanner.runtime.reconcile")
|
||||
.WithSummary("Reconcile runtime-observed libraries against SBOM inventory")
|
||||
.WithDescription("Compares libraries observed at runtime against the static SBOM to identify discrepancies")
|
||||
.Produces<RuntimeReconcileResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.RuntimeIngest);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleRuntimeEventsAsync(
|
||||
@@ -234,6 +243,75 @@ internal static class RuntimeEndpoints
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleRuntimeReconcileAsync(
|
||||
RuntimeReconcileRequestDto request,
|
||||
IRuntimeInventoryReconciler reconciler,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(reconciler);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ImageDigest))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid reconciliation request",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "imageDigest is required.");
|
||||
}
|
||||
|
||||
var reconcileRequest = new RuntimeReconciliationRequest
|
||||
{
|
||||
ImageDigest = request.ImageDigest,
|
||||
RuntimeEventId = request.RuntimeEventId,
|
||||
MaxMisses = request.MaxMisses > 0 ? request.MaxMisses : 100
|
||||
};
|
||||
|
||||
var result = await reconciler.ReconcileAsync(reconcileRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responseDto = new RuntimeReconcileResponseDto
|
||||
{
|
||||
ImageDigest = result.ImageDigest,
|
||||
RuntimeEventId = result.RuntimeEventId,
|
||||
SbomArtifactId = result.SbomArtifactId,
|
||||
TotalRuntimeLibraries = result.TotalRuntimeLibraries,
|
||||
TotalSbomComponents = result.TotalSbomComponents,
|
||||
MatchCount = result.MatchCount,
|
||||
MissCount = result.MissCount,
|
||||
Misses = result.Misses
|
||||
.Select(m => new RuntimeLibraryMissDto
|
||||
{
|
||||
Path = m.Path,
|
||||
Sha256 = m.Sha256,
|
||||
Inode = m.Inode
|
||||
})
|
||||
.ToList(),
|
||||
Matches = result.Matches
|
||||
.Select(m => new RuntimeLibraryMatchDto
|
||||
{
|
||||
RuntimePath = m.RuntimePath,
|
||||
RuntimeSha256 = m.RuntimeSha256,
|
||||
SbomComponentKey = m.SbomComponentKey,
|
||||
SbomComponentName = m.SbomComponentName,
|
||||
MatchType = m.MatchType
|
||||
})
|
||||
.ToList(),
|
||||
ReconciledAt = result.ReconciledAt,
|
||||
ErrorCode = result.ErrorCode,
|
||||
ErrorMessage = result.ErrorMessage
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(result.ErrorCode) &&
|
||||
result.ErrorCode is "RUNTIME_EVENT_NOT_FOUND" or "NO_RUNTIME_EVENTS")
|
||||
{
|
||||
return Json(responseDto, StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
return Json(responseDto, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static string NormalizeSegment(string segment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segment))
|
||||
|
||||
@@ -367,6 +367,20 @@ public sealed class ScannerWebServiceOptions
|
||||
public int PerTenantBurst { get; set; } = 1000;
|
||||
|
||||
public int PolicyCacheTtlSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Enable automatic scanning when DRIFT events are detected.
|
||||
/// When true, DRIFT events will trigger a new scan of the affected image.
|
||||
/// Default: false (opt-in).
|
||||
/// </summary>
|
||||
public bool AutoScanEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Cooldown period in seconds before the same image can be scanned again due to DRIFT.
|
||||
/// Prevents scan storms from repeated DRIFT events.
|
||||
/// Default: 300 seconds (5 minutes).
|
||||
/// </summary>
|
||||
public int AutoScanCooldownSeconds { get; set; } = 300;
|
||||
}
|
||||
|
||||
public sealed class DeterminismOptions
|
||||
|
||||
@@ -202,7 +202,9 @@ builder.Services.AddScannerStorage(storageOptions =>
|
||||
});
|
||||
builder.Services.AddSingleton<IPostConfigureOptions<ScannerStorageOptions>, ScannerStorageOptionsPostConfigurator>();
|
||||
builder.Services.AddSingleton<RuntimeEventRateLimiter>();
|
||||
builder.Services.AddSingleton<IDeltaScanRequestHandler, DeltaScanRequestHandler>();
|
||||
builder.Services.AddSingleton<IRuntimeEventIngestionService, RuntimeEventIngestionService>();
|
||||
builder.Services.AddSingleton<IRuntimeInventoryReconciler, RuntimeInventoryReconciler>();
|
||||
builder.Services.AddSingleton<IRuntimeAttestationVerifier, RuntimeAttestationVerifier>();
|
||||
builder.Services.AddSingleton<ILinksetResolver, LinksetResolver>();
|
||||
builder.Services.AddSingleton<IRuntimePolicyService, RuntimePolicyService>();
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Handles delta scan requests triggered by runtime DRIFT events.
|
||||
/// </summary>
|
||||
internal interface IDeltaScanRequestHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes a batch of runtime events and triggers scans for DRIFT events when enabled.
|
||||
/// </summary>
|
||||
Task<DeltaScanResult> ProcessAsync(
|
||||
IReadOnlyList<RuntimeEventEnvelope> envelopes,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of delta scan processing.
|
||||
/// </summary>
|
||||
internal readonly record struct DeltaScanResult(
|
||||
int DriftEventsDetected,
|
||||
int ScansTriggered,
|
||||
int ScansSkipped,
|
||||
int ScansDeduped);
|
||||
|
||||
internal sealed class DeltaScanRequestHandler : IDeltaScanRequestHandler
|
||||
{
|
||||
private static readonly Meter DeltaScanMeter = new("StellaOps.Scanner.DeltaScan", "1.0.0");
|
||||
private static readonly Counter<long> DeltaScanTriggered = DeltaScanMeter.CreateCounter<long>(
|
||||
"scanner_delta_scan_triggered_total",
|
||||
unit: "1",
|
||||
description: "Total delta scans triggered from runtime DRIFT events.");
|
||||
private static readonly Counter<long> DeltaScanSkipped = DeltaScanMeter.CreateCounter<long>(
|
||||
"scanner_delta_scan_skipped_total",
|
||||
unit: "1",
|
||||
description: "Total delta scans skipped (feature disabled, rate limited, or missing data).");
|
||||
private static readonly Counter<long> DeltaScanDeduped = DeltaScanMeter.CreateCounter<long>(
|
||||
"scanner_delta_scan_deduped_total",
|
||||
unit: "1",
|
||||
description: "Total delta scans deduplicated within cooldown window.");
|
||||
private static readonly Histogram<double> DeltaScanLatencyMs = DeltaScanMeter.CreateHistogram<double>(
|
||||
"scanner_delta_scan_latency_ms",
|
||||
unit: "ms",
|
||||
description: "Latency for delta scan trigger processing.");
|
||||
|
||||
// Deduplication cache: imageDigest -> last trigger time
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _recentTriggers = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly IScanCoordinator _scanCoordinator;
|
||||
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DeltaScanRequestHandler> _logger;
|
||||
|
||||
public DeltaScanRequestHandler(
|
||||
IScanCoordinator scanCoordinator,
|
||||
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DeltaScanRequestHandler> logger)
|
||||
{
|
||||
_scanCoordinator = scanCoordinator ?? throw new ArgumentNullException(nameof(scanCoordinator));
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<DeltaScanResult> ProcessAsync(
|
||||
IReadOnlyList<RuntimeEventEnvelope> envelopes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelopes);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var options = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
|
||||
|
||||
// Check if autoscan is enabled
|
||||
if (!options.AutoScanEnabled)
|
||||
{
|
||||
var driftCount = envelopes.Count(e => e.Event.Kind == RuntimeEventKind.Drift);
|
||||
if (driftCount > 0)
|
||||
{
|
||||
DeltaScanSkipped.Add(driftCount);
|
||||
_logger.LogDebug(
|
||||
"Delta scan disabled, skipping {DriftCount} DRIFT events",
|
||||
driftCount);
|
||||
}
|
||||
return new DeltaScanResult(driftCount, 0, driftCount, 0);
|
||||
}
|
||||
|
||||
var driftEvents = envelopes
|
||||
.Where(e => e.Event.Kind == RuntimeEventKind.Drift)
|
||||
.ToList();
|
||||
|
||||
if (driftEvents.Count == 0)
|
||||
{
|
||||
return new DeltaScanResult(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var cooldownWindow = TimeSpan.FromSeconds(options.AutoScanCooldownSeconds);
|
||||
var triggered = 0;
|
||||
var skipped = 0;
|
||||
var deduped = 0;
|
||||
|
||||
// Cleanup old entries from dedup cache
|
||||
CleanupDeduplicationCache(now, cooldownWindow);
|
||||
|
||||
foreach (var envelope in driftEvents)
|
||||
{
|
||||
var runtimeEvent = envelope.Event;
|
||||
var imageDigest = ExtractImageDigest(runtimeEvent);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(imageDigest))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DRIFT event {EventId} has no image digest, skipping auto-scan",
|
||||
runtimeEvent.EventId);
|
||||
DeltaScanSkipped.Add(1);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check deduplication
|
||||
if (_recentTriggers.TryGetValue(imageDigest, out var lastTrigger))
|
||||
{
|
||||
if (now - lastTrigger < cooldownWindow)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"DRIFT event {EventId} for image {ImageDigest} within cooldown window, deduplicating",
|
||||
runtimeEvent.EventId,
|
||||
imageDigest);
|
||||
DeltaScanDeduped.Add(1);
|
||||
deduped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger scan
|
||||
var scanTarget = new ScanTarget(
|
||||
runtimeEvent.Workload.ImageRef,
|
||||
imageDigest);
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["stellaops:trigger"] = "drift",
|
||||
["stellaops:drift.eventId"] = runtimeEvent.EventId,
|
||||
["stellaops:drift.tenant"] = runtimeEvent.Tenant,
|
||||
["stellaops:drift.node"] = runtimeEvent.Node
|
||||
};
|
||||
|
||||
if (runtimeEvent.Delta?.BaselineImageDigest is { } baseline)
|
||||
{
|
||||
metadata["stellaops:drift.baselineDigest"] = baseline;
|
||||
}
|
||||
|
||||
if (runtimeEvent.Delta?.ChangedFiles is { Count: > 0 } changedFiles)
|
||||
{
|
||||
metadata["stellaops:drift.changedFilesCount"] = changedFiles.Count.ToString();
|
||||
}
|
||||
|
||||
if (runtimeEvent.Delta?.NewBinaries is { Count: > 0 } newBinaries)
|
||||
{
|
||||
metadata["stellaops:drift.newBinariesCount"] = newBinaries.Count.ToString();
|
||||
}
|
||||
|
||||
var submission = new ScanSubmission(
|
||||
scanTarget.Normalize(),
|
||||
Force: false,
|
||||
ClientRequestId: $"drift:{runtimeEvent.EventId}",
|
||||
Metadata: metadata);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _scanCoordinator.SubmitAsync(submission, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_recentTriggers[imageDigest] = now;
|
||||
DeltaScanTriggered.Add(1);
|
||||
triggered++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Delta scan triggered for DRIFT event {EventId}: scanId={ScanId}, created={Created}",
|
||||
runtimeEvent.EventId,
|
||||
result.Snapshot.Id,
|
||||
result.Created);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to trigger delta scan for DRIFT event {EventId}, image {ImageDigest}",
|
||||
runtimeEvent.EventId,
|
||||
imageDigest);
|
||||
DeltaScanSkipped.Add(1);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
DeltaScanLatencyMs.Record(stopwatch.Elapsed.TotalMilliseconds);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Delta scan processing complete: {DriftCount} DRIFT events, {Triggered} triggered, {Skipped} skipped, {Deduped} deduped",
|
||||
driftEvents.Count,
|
||||
triggered,
|
||||
skipped,
|
||||
deduped);
|
||||
|
||||
return new DeltaScanResult(driftEvents.Count, triggered, skipped, deduped);
|
||||
}
|
||||
|
||||
private void CleanupDeduplicationCache(DateTimeOffset now, TimeSpan cooldownWindow)
|
||||
{
|
||||
var expiredKeys = _recentTriggers
|
||||
.Where(kvp => now - kvp.Value > cooldownWindow * 2)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_recentTriggers.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractImageDigest(RuntimeEvent runtimeEvent)
|
||||
{
|
||||
// Prefer baseline digest from Delta for DRIFT events
|
||||
var digest = runtimeEvent.Delta?.BaselineImageDigest?.Trim().ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
// Fall back to extracting from ImageRef
|
||||
var imageRef = runtimeEvent.Workload.ImageRef;
|
||||
if (string.IsNullOrWhiteSpace(imageRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = imageRef.Trim();
|
||||
var atIndex = trimmed.LastIndexOf('@');
|
||||
if (atIndex >= 0 && atIndex < trimmed.Length - 1)
|
||||
{
|
||||
var candidate = trimmed[(atIndex + 1)..].Trim().ToLowerInvariant();
|
||||
if (candidate.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi
|
||||
|
||||
private readonly RuntimeEventRepository _repository;
|
||||
private readonly RuntimeEventRateLimiter _rateLimiter;
|
||||
private readonly IDeltaScanRequestHandler _deltaScanHandler;
|
||||
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RuntimeEventIngestionService> _logger;
|
||||
@@ -30,12 +31,14 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi
|
||||
public RuntimeEventIngestionService(
|
||||
RuntimeEventRepository repository,
|
||||
RuntimeEventRateLimiter rateLimiter,
|
||||
IDeltaScanRequestHandler deltaScanHandler,
|
||||
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RuntimeEventIngestionService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_rateLimiter = rateLimiter ?? throw new ArgumentNullException(nameof(rateLimiter));
|
||||
_deltaScanHandler = deltaScanHandler ?? throw new ArgumentNullException(nameof(deltaScanHandler));
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -126,9 +129,26 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi
|
||||
insertResult.DuplicateCount,
|
||||
totalPayloadBytes);
|
||||
|
||||
// Process DRIFT events for auto-scan (fire and forget, don't block ingestion)
|
||||
_ = ProcessDriftEventsAsync(envelopes, cancellationToken);
|
||||
|
||||
return RuntimeEventIngestionResult.Success(insertResult.InsertedCount, insertResult.DuplicateCount, totalPayloadBytes);
|
||||
}
|
||||
|
||||
private async Task ProcessDriftEventsAsync(
|
||||
IReadOnlyList<RuntimeEventEnvelope> envelopes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _deltaScanHandler.ProcessAsync(envelopes, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing DRIFT events for auto-scan");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractImageDigest(RuntimeEvent runtimeEvent)
|
||||
{
|
||||
var digest = NormalizeDigest(runtimeEvent.Delta?.BaselineImageDigest);
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
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 key = ArtifactObjectKeyBuilder.Build(
|
||||
artifact.Type,
|
||||
artifact.Format,
|
||||
artifact.BytesSha256,
|
||||
options.ObjectStore.RootPrefix);
|
||||
|
||||
var descriptor = new ArtifactObjectDescriptor(
|
||||
options.ObjectStore.BucketName,
|
||||
key,
|
||||
artifact.Immutable);
|
||||
|
||||
await using var stream = await _objectStore.GetAsync(descriptor, cancellationToken).ConfigureAwait(false);
|
||||
if (stream is null)
|
||||
{
|
||||
_logger.LogWarning("SBOM artifact content not found at {Key}", key);
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
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}", artifact.Id);
|
||||
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 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; } = [];
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
<RootNamespace>StellaOps.Scanner.WebService</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CycloneDX.Core" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
using System.Reflection.Metadata;
|
||||
using System.Reflection.PortableExecutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Bundling;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves publish artifacts (deps/runtimeconfig) into deterministic entrypoint identities.
|
||||
/// Per SCANNER-ANALYZERS-LANG-11-001: maps project/publish artifacts to normalized entrypoint records
|
||||
/// with assembly name, MVID, TFM, RID, host kind, publish mode, ALC hints, and probing paths.
|
||||
/// </summary>
|
||||
public static class DotNetEntrypointResolver
|
||||
{
|
||||
@@ -46,6 +52,7 @@ public static class DotNetEntrypointResolver
|
||||
}
|
||||
|
||||
var name = GetEntrypointName(depsPath);
|
||||
var directory = Path.GetDirectoryName(depsPath) ?? ".";
|
||||
|
||||
DotNetRuntimeConfig? runtimeConfig = null;
|
||||
var runtimeConfigPath = GetRuntimeConfigPath(depsPath, name);
|
||||
@@ -61,16 +68,51 @@ public static class DotNetEntrypointResolver
|
||||
var rids = CollectRuntimeIdentifiers(depsFile, runtimeConfig);
|
||||
var publishKind = DeterminePublishKind(depsFile);
|
||||
|
||||
var id = BuildDeterministicId(name, tfms, rids, publishKind);
|
||||
// Resolve assembly and apphost paths
|
||||
var (assemblyPath, apphostPath) = ResolveEntrypointPaths(directory, name);
|
||||
|
||||
// Extract MVID from PE header (11-001 requirement)
|
||||
var mvid = ExtractMvid(assemblyPath);
|
||||
|
||||
// Compute SHA-256 hash over assembly bytes (11-001 requirement)
|
||||
var (hash, fileSize) = ComputeHashAndSize(assemblyPath);
|
||||
|
||||
// Determine host kind: apphost, framework-dependent, self-contained (11-001 requirement)
|
||||
var hostKind = DetermineHostKind(apphostPath, publishKind);
|
||||
|
||||
// Determine publish mode: single-file, trimmed, normal (11-001 requirement)
|
||||
var publishMode = DeterminePublishMode(apphostPath, depsFile, directory);
|
||||
|
||||
// Collect ALC hints from runtimeconfig.dev.json (11-001 requirement)
|
||||
var alcHints = CollectAlcHints(directory, name);
|
||||
|
||||
// Collect probing paths from runtimeconfig files (11-001 requirement)
|
||||
var probingPaths = CollectProbingPaths(directory, name);
|
||||
|
||||
// Collect native dependencies for apphost bundles (11-001 requirement)
|
||||
var nativeDeps = CollectNativeDependencies(apphostPath, publishMode);
|
||||
|
||||
var id = BuildDeterministicId(name, tfms, rids, publishKind, mvid);
|
||||
|
||||
results.Add(new DotNetEntrypoint(
|
||||
Id: id,
|
||||
Name: name,
|
||||
AssemblyName: Path.GetFileName(assemblyPath ?? $"{name}.dll"),
|
||||
Mvid: mvid,
|
||||
TargetFrameworks: tfms,
|
||||
RuntimeIdentifiers: rids,
|
||||
HostKind: hostKind,
|
||||
PublishKind: publishKind,
|
||||
PublishMode: publishMode,
|
||||
AlcHints: alcHints,
|
||||
ProbingPaths: probingPaths,
|
||||
NativeDependencies: nativeDeps,
|
||||
Hash: hash,
|
||||
FileSizeBytes: fileSize,
|
||||
RelativeDepsPath: relativeDepsPath,
|
||||
RelativeRuntimeConfigPath: relativeRuntimeConfig,
|
||||
PublishKind: publishKind));
|
||||
RelativeAssemblyPath: assemblyPath is not null ? NormalizeRelative(context.GetRelativePath(assemblyPath)) : null,
|
||||
RelativeApphostPath: apphostPath is not null ? NormalizeRelative(context.GetRelativePath(apphostPath)) : null));
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
@@ -89,6 +131,292 @@ public static class DotNetEntrypointResolver
|
||||
return ValueTask.FromResult<IReadOnlyList<DotNetEntrypoint>>(results);
|
||||
}
|
||||
|
||||
private static (string? assemblyPath, string? apphostPath) ResolveEntrypointPaths(string directory, string name)
|
||||
{
|
||||
string? assemblyPath = null;
|
||||
string? apphostPath = null;
|
||||
|
||||
// Look for main assembly (.dll)
|
||||
var dllPath = Path.Combine(directory, $"{name}.dll");
|
||||
if (File.Exists(dllPath))
|
||||
{
|
||||
assemblyPath = dllPath;
|
||||
}
|
||||
|
||||
// Look for apphost executable (.exe on Windows, no extension on Unix)
|
||||
var exePath = Path.Combine(directory, $"{name}.exe");
|
||||
if (File.Exists(exePath))
|
||||
{
|
||||
apphostPath = exePath;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check for Unix-style executable (no extension)
|
||||
var unixExePath = Path.Combine(directory, name);
|
||||
if (File.Exists(unixExePath) && !unixExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
apphostPath = unixExePath;
|
||||
}
|
||||
}
|
||||
|
||||
return (assemblyPath, apphostPath);
|
||||
}
|
||||
|
||||
private static Guid? ExtractMvid(string? assemblyPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assemblyPath) || !File.Exists(assemblyPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(assemblyPath);
|
||||
using var peReader = new PEReader(stream);
|
||||
|
||||
if (!peReader.HasMetadata)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var metadataReader = peReader.GetMetadataReader();
|
||||
var moduleDefinition = metadataReader.GetModuleDefinition();
|
||||
return metadataReader.GetGuid(moduleDefinition.Mvid);
|
||||
}
|
||||
catch (BadImageFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string? hash, long fileSize) ComputeHashAndSize(string? assemblyPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assemblyPath) || !File.Exists(assemblyPath))
|
||||
{
|
||||
return (null, 0);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(assemblyPath);
|
||||
var fileSize = stream.Length;
|
||||
var hashBytes = SHA256.HashData(stream);
|
||||
var hash = $"sha256:{Convert.ToHexStringLower(hashBytes)}";
|
||||
return (hash, fileSize);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return (null, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static DotNetHostKind DetermineHostKind(string? apphostPath, DotNetPublishKind publishKind)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(apphostPath) && File.Exists(apphostPath))
|
||||
{
|
||||
return DotNetHostKind.Apphost;
|
||||
}
|
||||
|
||||
return publishKind switch
|
||||
{
|
||||
DotNetPublishKind.SelfContained => DotNetHostKind.SelfContained,
|
||||
DotNetPublishKind.FrameworkDependent => DotNetHostKind.FrameworkDependent,
|
||||
_ => DotNetHostKind.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static DotNetPublishMode DeterminePublishMode(string? apphostPath, DotNetDepsFile depsFile, string directory)
|
||||
{
|
||||
// Check for single-file bundle
|
||||
if (!string.IsNullOrEmpty(apphostPath) && File.Exists(apphostPath))
|
||||
{
|
||||
var singleFileResult = SingleFileAppDetector.Analyze(apphostPath);
|
||||
if (singleFileResult.IsSingleFile)
|
||||
{
|
||||
return DotNetPublishMode.SingleFile;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for trimmed publish (look for trim markers or reduced dependency count)
|
||||
var trimmedMarkerPath = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(apphostPath ?? "app")}.staticwebassets.runtime.json");
|
||||
if (File.Exists(trimmedMarkerPath))
|
||||
{
|
||||
return DotNetPublishMode.Trimmed;
|
||||
}
|
||||
|
||||
// Check deps.json for trimmed indicators
|
||||
foreach (var library in depsFile.Libraries.Values)
|
||||
{
|
||||
if (library.Id.Contains("ILLink", StringComparison.OrdinalIgnoreCase) ||
|
||||
library.Id.Contains("Trimmer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DotNetPublishMode.Trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return DotNetPublishMode.Normal;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> CollectAlcHints(string directory, string name)
|
||||
{
|
||||
var hints = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// Check runtimeconfig.dev.json for ALC hints
|
||||
var devConfigPath = Path.Combine(directory, $"{name}.runtimeconfig.dev.json");
|
||||
if (File.Exists(devConfigPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(devConfigPath);
|
||||
using var doc = JsonDocument.Parse(json, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
if (doc.RootElement.TryGetProperty("runtimeOptions", out var runtimeOptions))
|
||||
{
|
||||
// Look for additionalProbingPaths which indicate ALC usage
|
||||
if (runtimeOptions.TryGetProperty("additionalProbingPaths", out var probingPaths) &&
|
||||
probingPaths.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var path in probingPaths.EnumerateArray())
|
||||
{
|
||||
if (path.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var pathValue = path.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(pathValue))
|
||||
{
|
||||
// Extract ALC hint from path pattern
|
||||
if (pathValue.Contains(".nuget", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
hints.Add("NuGetAssemblyLoadContext");
|
||||
}
|
||||
else if (pathValue.Contains("sdk", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
hints.Add("SdkAssemblyLoadContext");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Ignore malformed dev config
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
// Add default ALC hint
|
||||
if (hints.Count == 0)
|
||||
{
|
||||
hints.Add("Default");
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> CollectProbingPaths(string directory, string name)
|
||||
{
|
||||
var paths = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// Check runtimeconfig.dev.json for probing paths
|
||||
var devConfigPath = Path.Combine(directory, $"{name}.runtimeconfig.dev.json");
|
||||
if (File.Exists(devConfigPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(devConfigPath);
|
||||
using var doc = JsonDocument.Parse(json, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
if (doc.RootElement.TryGetProperty("runtimeOptions", out var runtimeOptions) &&
|
||||
runtimeOptions.TryGetProperty("additionalProbingPaths", out var probingPaths) &&
|
||||
probingPaths.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var path in probingPaths.EnumerateArray())
|
||||
{
|
||||
if (path.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var pathValue = path.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(pathValue))
|
||||
{
|
||||
// Normalize and add the probing path
|
||||
paths.Add(NormalizeRelative(pathValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Ignore malformed dev config
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> CollectNativeDependencies(string? apphostPath, DotNetPublishMode publishMode)
|
||||
{
|
||||
var nativeDeps = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
if (publishMode != DotNetPublishMode.SingleFile || string.IsNullOrEmpty(apphostPath))
|
||||
{
|
||||
return nativeDeps;
|
||||
}
|
||||
|
||||
// For single-file apps, try to extract bundled native library names
|
||||
// This is a simplified detection - full extraction would require parsing the bundle manifest
|
||||
var directory = Path.GetDirectoryName(apphostPath);
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
{
|
||||
return nativeDeps;
|
||||
}
|
||||
|
||||
// Look for extracted native libraries (some single-file apps extract natives at runtime)
|
||||
var nativePatterns = new[] { "*.so", "*.dylib", "*.dll" };
|
||||
foreach (var pattern in nativePatterns)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var nativePath in Directory.EnumerateFiles(directory, pattern))
|
||||
{
|
||||
var fileName = Path.GetFileName(nativePath);
|
||||
// Filter out managed assemblies
|
||||
if (!fileName.Equals(Path.GetFileName(apphostPath), StringComparison.OrdinalIgnoreCase) &&
|
||||
!fileName.EndsWith(".deps.json", StringComparison.OrdinalIgnoreCase) &&
|
||||
!fileName.EndsWith(".runtimeconfig.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
nativeDeps.Add(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore enumeration errors
|
||||
}
|
||||
}
|
||||
|
||||
return nativeDeps;
|
||||
}
|
||||
|
||||
private static string GetEntrypointName(string depsPath)
|
||||
{
|
||||
// Strip .json then any trailing .deps suffix to yield a logical entrypoint name.
|
||||
@@ -273,12 +601,14 @@ public static class DotNetEntrypointResolver
|
||||
string name,
|
||||
IReadOnlyCollection<string> tfms,
|
||||
IReadOnlyCollection<string> rids,
|
||||
DotNetPublishKind publishKind)
|
||||
DotNetPublishKind publishKind,
|
||||
Guid? mvid)
|
||||
{
|
||||
var tfmPart = tfms.Count == 0 ? "unknown" : string.Join('+', tfms.OrderBy(t => t, StringComparer.OrdinalIgnoreCase));
|
||||
var ridPart = rids.Count == 0 ? "none" : string.Join('+', rids.OrderBy(r => r, StringComparer.OrdinalIgnoreCase));
|
||||
var publishPart = publishKind.ToString().ToLowerInvariant();
|
||||
return $"{name}:{tfmPart}:{ridPart}:{publishPart}";
|
||||
var mvidPart = mvid?.ToString("N") ?? "no-mvid";
|
||||
return $"{name}:{tfmPart}:{ridPart}:{publishPart}:{mvidPart}";
|
||||
}
|
||||
|
||||
private static string NormalizeRelative(string path)
|
||||
@@ -293,18 +623,84 @@ public static class DotNetEntrypointResolver
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a resolved .NET entrypoint with deterministic identity per SCANNER-ANALYZERS-LANG-11-001.
|
||||
/// </summary>
|
||||
public sealed record DotNetEntrypoint(
|
||||
/// <summary>Deterministic identifier: name:tfms:rids:publishKind:mvid</summary>
|
||||
string Id,
|
||||
/// <summary>Logical entrypoint name derived from deps.json</summary>
|
||||
string Name,
|
||||
/// <summary>Assembly file name (e.g., "MyApp.dll")</summary>
|
||||
string AssemblyName,
|
||||
/// <summary>Module Version ID from PE metadata (deterministic per build)</summary>
|
||||
Guid? Mvid,
|
||||
/// <summary>Target frameworks (normalized, e.g., "net8.0")</summary>
|
||||
IReadOnlyCollection<string> TargetFrameworks,
|
||||
/// <summary>Runtime identifiers (e.g., "linux-x64", "win-x64")</summary>
|
||||
IReadOnlyCollection<string> RuntimeIdentifiers,
|
||||
/// <summary>Host kind: apphost, framework-dependent, self-contained</summary>
|
||||
DotNetHostKind HostKind,
|
||||
/// <summary>Publish kind from deps.json analysis</summary>
|
||||
DotNetPublishKind PublishKind,
|
||||
/// <summary>Publish mode: normal, single-file, trimmed</summary>
|
||||
DotNetPublishMode PublishMode,
|
||||
/// <summary>AssemblyLoadContext hints from runtimeconfig.dev.json</summary>
|
||||
IReadOnlyCollection<string> AlcHints,
|
||||
/// <summary>Additional probing paths from runtimeconfig.dev.json</summary>
|
||||
IReadOnlyCollection<string> ProbingPaths,
|
||||
/// <summary>Native dependencies for single-file bundles</summary>
|
||||
IReadOnlyCollection<string> NativeDependencies,
|
||||
/// <summary>SHA-256 hash of assembly bytes (sha256:hex)</summary>
|
||||
string? Hash,
|
||||
/// <summary>Assembly file size in bytes</summary>
|
||||
long FileSizeBytes,
|
||||
/// <summary>Relative path to deps.json</summary>
|
||||
string RelativeDepsPath,
|
||||
/// <summary>Relative path to runtimeconfig.json</summary>
|
||||
string? RelativeRuntimeConfigPath,
|
||||
DotNetPublishKind PublishKind);
|
||||
/// <summary>Relative path to main assembly (.dll)</summary>
|
||||
string? RelativeAssemblyPath,
|
||||
/// <summary>Relative path to apphost executable</summary>
|
||||
string? RelativeApphostPath);
|
||||
|
||||
/// <summary>
|
||||
/// .NET host kind classification per SCANNER-ANALYZERS-LANG-11-001.
|
||||
/// </summary>
|
||||
public enum DotNetHostKind
|
||||
{
|
||||
/// <summary>Host kind could not be determined</summary>
|
||||
Unknown = 0,
|
||||
/// <summary>Application uses apphost executable</summary>
|
||||
Apphost = 1,
|
||||
/// <summary>Framework-dependent deployment (requires shared runtime)</summary>
|
||||
FrameworkDependent = 2,
|
||||
/// <summary>Self-contained deployment (includes runtime)</summary>
|
||||
SelfContained = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// .NET publish kind from deps.json analysis.
|
||||
/// </summary>
|
||||
public enum DotNetPublishKind
|
||||
{
|
||||
/// <summary>Publish kind could not be determined</summary>
|
||||
Unknown = 0,
|
||||
/// <summary>Framework-dependent (relies on shared .NET runtime)</summary>
|
||||
FrameworkDependent = 1,
|
||||
/// <summary>Self-contained (includes .NET runtime)</summary>
|
||||
SelfContained = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// .NET publish mode per SCANNER-ANALYZERS-LANG-11-001.
|
||||
/// </summary>
|
||||
public enum DotNetPublishMode
|
||||
{
|
||||
/// <summary>Normal publish (separate files)</summary>
|
||||
Normal = 0,
|
||||
/// <summary>Single-file publish (assemblies bundled into executable)</summary>
|
||||
SingleFile = 1,
|
||||
/// <summary>Trimmed publish (unused code removed)</summary>
|
||||
Trimmed = 2
|
||||
}
|
||||
|
||||
@@ -204,6 +204,59 @@ public sealed class RuntimeEventRepository : RepositoryBase<ScannerDataSource>
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RuntimeEventDocument?> GetByEventIdAsync(string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
|
||||
|
||||
var sql = $"""
|
||||
SELECT id, event_id, schema_version, tenant, node, kind, "when", received_at, expires_at,
|
||||
platform, namespace, pod, container, container_id, image_ref, image_digest,
|
||||
engine, engine_version, baseline_digest, image_signed, sbom_referrer, build_id, payload
|
||||
FROM {Table}
|
||||
WHERE event_id = @event_id
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "event_id", eventId),
|
||||
MapRuntimeEvent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RuntimeEventDocument>> GetByImageDigestAsync(
|
||||
string imageDigest,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
|
||||
if (limit <= 0)
|
||||
{
|
||||
limit = 100;
|
||||
}
|
||||
|
||||
var sql = $"""
|
||||
SELECT id, event_id, schema_version, tenant, node, kind, "when", received_at, expires_at,
|
||||
platform, namespace, pod, container, container_id, image_ref, image_digest,
|
||||
engine, engine_version, baseline_digest, image_signed, sbom_referrer, build_id, payload
|
||||
FROM {Table}
|
||||
WHERE image_digest = @image_digest
|
||||
ORDER BY received_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "image_digest", imageDigest.Trim().ToLowerInvariant());
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapRuntimeEvent,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static RuntimeEventDocument MapRuntimeEvent(NpgsqlDataReader reader)
|
||||
{
|
||||
var payloadOrdinal = reader.GetOrdinal("payload");
|
||||
|
||||
Reference in New Issue
Block a user