805 lines
28 KiB
C#
805 lines
28 KiB
C#
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Determinism;
|
|
using StellaOps.Policy;
|
|
using StellaOps.Scanner.Core.Utility;
|
|
using StellaOps.Scanner.Storage.Models;
|
|
using StellaOps.Scanner.Storage.Services;
|
|
using StellaOps.Scanner.WebService.Contracts;
|
|
using StellaOps.Scanner.WebService.Options;
|
|
using StellaOps.Scanner.WebService.Tenancy;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Text;
|
|
|
|
namespace StellaOps.Scanner.WebService.Services;
|
|
|
|
internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
|
{
|
|
private const string DefaultTenant = "default";
|
|
private const string Source = "scanner.webservice";
|
|
|
|
private static readonly Guid TenantNamespace = new("ac8f2b54-72ea-43fa-9c3b-6a87ebd2d48a");
|
|
private static readonly Guid ExecutionNamespace = new("f0b1f40c-0f04-447b-a102-50de3ff79a33");
|
|
private static readonly Guid ManifestNamespace = new("d9c8858c-e2a4-47d6-bf0f-1e76d2865bea");
|
|
|
|
private readonly IPlatformEventPublisher _publisher;
|
|
private readonly IClassificationChangeTracker _classificationChangeTracker;
|
|
private readonly IGuidProvider _guidProvider;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<ReportEventDispatcher> _logger;
|
|
private readonly string[] _apiBaseSegments;
|
|
private readonly string _reportsSegment;
|
|
private readonly string _policySegment;
|
|
private readonly string[] _consoleBaseSegments;
|
|
private readonly string _consoleReportsSegment;
|
|
private readonly string _consolePolicySegment;
|
|
private readonly string _consoleAttestationsSegment;
|
|
|
|
public ReportEventDispatcher(
|
|
IPlatformEventPublisher publisher,
|
|
IClassificationChangeTracker classificationChangeTracker,
|
|
IOptions<ScannerWebServiceOptions> options,
|
|
IGuidProvider guidProvider,
|
|
TimeProvider timeProvider,
|
|
ILogger<ReportEventDispatcher> logger)
|
|
{
|
|
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
|
_classificationChangeTracker = classificationChangeTracker ?? throw new ArgumentNullException(nameof(classificationChangeTracker));
|
|
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
|
if (options is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(options));
|
|
}
|
|
|
|
var apiOptions = options.Value.Api ?? new ScannerWebServiceOptions.ApiOptions();
|
|
_apiBaseSegments = SplitSegments(apiOptions.BasePath);
|
|
_reportsSegment = string.IsNullOrWhiteSpace(apiOptions.ReportsSegment)
|
|
? "reports"
|
|
: apiOptions.ReportsSegment.Trim('/');
|
|
_policySegment = string.IsNullOrWhiteSpace(apiOptions.PolicySegment)
|
|
? "policy"
|
|
: apiOptions.PolicySegment.Trim('/');
|
|
var consoleOptions = options.Value.Console ?? new ScannerWebServiceOptions.ConsoleOptions();
|
|
_consoleBaseSegments = SplitSegments(consoleOptions.BasePath);
|
|
_consoleReportsSegment = string.IsNullOrWhiteSpace(consoleOptions.ReportsSegment)
|
|
? "reports"
|
|
: consoleOptions.ReportsSegment.Trim('/');
|
|
_consolePolicySegment = string.IsNullOrWhiteSpace(consoleOptions.PolicySegment)
|
|
? "policy"
|
|
: consoleOptions.PolicySegment.Trim('/');
|
|
_consoleAttestationsSegment = string.IsNullOrWhiteSpace(consoleOptions.AttestationsSegment)
|
|
? "attestations"
|
|
: consoleOptions.AttestationsSegment.Trim('/');
|
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public async Task PublishAsync(
|
|
ReportRequestDto request,
|
|
PolicyPreviewResponse preview,
|
|
ReportDocumentDto document,
|
|
DsseEnvelopeDto? envelope,
|
|
HttpContext httpContext,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ArgumentNullException.ThrowIfNull(preview);
|
|
ArgumentNullException.ThrowIfNull(document);
|
|
ArgumentNullException.ThrowIfNull(httpContext);
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var now = _timeProvider.GetUtcNow();
|
|
var occurredAt = document.GeneratedAt == default ? now : document.GeneratedAt;
|
|
var tenant = ResolveTenant(httpContext);
|
|
var scope = BuildScope(request, document);
|
|
var attributes = BuildAttributes(document);
|
|
var links = BuildLinks(httpContext, document, envelope);
|
|
var correlationId = document.ReportId;
|
|
var (traceId, spanId) = ResolveTraceContext();
|
|
|
|
var reportEvent = new OrchestratorEvent
|
|
{
|
|
EventId = _guidProvider.NewGuid(),
|
|
Kind = OrchestratorEventKinds.ScannerReportReady,
|
|
Version = 1,
|
|
Tenant = tenant,
|
|
OccurredAt = occurredAt,
|
|
RecordedAt = now,
|
|
Source = Source,
|
|
IdempotencyKey = BuildIdempotencyKey(OrchestratorEventKinds.ScannerReportReady, tenant, document.ReportId),
|
|
CorrelationId = correlationId,
|
|
TraceId = traceId,
|
|
SpanId = spanId,
|
|
Scope = scope,
|
|
Attributes = attributes,
|
|
Payload = BuildReportReadyPayload(request, preview, document, envelope, links, correlationId)
|
|
};
|
|
|
|
await PublishSafelyAsync(reportEvent, document.ReportId, cancellationToken).ConfigureAwait(false);
|
|
|
|
await TrackFnDriftSafelyAsync(request, preview, document, tenant, occurredAt, cancellationToken).ConfigureAwait(false);
|
|
|
|
var scanCompletedEvent = new OrchestratorEvent
|
|
{
|
|
EventId = _guidProvider.NewGuid(),
|
|
Kind = OrchestratorEventKinds.ScannerScanCompleted,
|
|
Version = 1,
|
|
Tenant = tenant,
|
|
OccurredAt = occurredAt,
|
|
RecordedAt = now,
|
|
Source = Source,
|
|
IdempotencyKey = BuildIdempotencyKey(OrchestratorEventKinds.ScannerScanCompleted, tenant, correlationId),
|
|
CorrelationId = correlationId,
|
|
TraceId = traceId,
|
|
SpanId = spanId,
|
|
Scope = scope,
|
|
Attributes = attributes,
|
|
Payload = BuildScanCompletedPayload(request, preview, document, envelope, links, correlationId)
|
|
};
|
|
|
|
await PublishSafelyAsync(scanCompletedEvent, document.ReportId, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task TrackFnDriftSafelyAsync(
|
|
ReportRequestDto request,
|
|
PolicyPreviewResponse preview,
|
|
ReportDocumentDto document,
|
|
string tenant,
|
|
DateTimeOffset occurredAt,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (preview.Diffs.IsDefaultOrEmpty)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var changes = BuildClassificationChanges(request, preview, document, tenant, occurredAt);
|
|
if (changes.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await _classificationChangeTracker.TrackChangesAsync(changes, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to record FN-drift classification changes for report {ReportId}.", document.ReportId);
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<ClassificationChange> BuildClassificationChanges(
|
|
ReportRequestDto request,
|
|
PolicyPreviewResponse preview,
|
|
ReportDocumentDto document,
|
|
string tenant,
|
|
DateTimeOffset occurredAt)
|
|
{
|
|
var findings = request.Findings ?? Array.Empty<PolicyPreviewFindingDto>();
|
|
if (findings.Count == 0)
|
|
{
|
|
return Array.Empty<ClassificationChange>();
|
|
}
|
|
|
|
var findingsById = findings
|
|
.Where(finding => !string.IsNullOrWhiteSpace(finding.Id))
|
|
.ToDictionary(finding => finding.Id!, StringComparer.Ordinal);
|
|
|
|
var tenantId = ResolveTenantId(tenant);
|
|
var executionId = ResolveExecutionId(tenantId, document.ReportId);
|
|
var manifestId = ResolveManifestId(tenantId, document);
|
|
var artifactDigest = string.IsNullOrWhiteSpace(document.ImageDigest) ? request.ImageDigest ?? string.Empty : document.ImageDigest;
|
|
|
|
var changes = new List<ClassificationChange>();
|
|
foreach (var diff in preview.Diffs)
|
|
{
|
|
var projected = diff.Projected;
|
|
if (projected is null || string.IsNullOrWhiteSpace(projected.FindingId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!findingsById.TryGetValue(projected.FindingId, out var finding))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(finding.Cve) || string.IsNullOrWhiteSpace(finding.Purl))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var previousStatus = MapVerdictStatus(diff.Baseline.Status);
|
|
var newStatus = MapVerdictStatus(projected.Status);
|
|
|
|
if (previousStatus == ClassificationStatus.Affected && newStatus == ClassificationStatus.Unaffected)
|
|
{
|
|
newStatus = ClassificationStatus.Fixed;
|
|
}
|
|
|
|
changes.Add(new ClassificationChange
|
|
{
|
|
ArtifactDigest = artifactDigest,
|
|
VulnId = finding.Cve!,
|
|
PackagePurl = finding.Purl!,
|
|
TenantId = tenantId,
|
|
ManifestId = manifestId,
|
|
ExecutionId = executionId,
|
|
PreviousStatus = previousStatus,
|
|
NewStatus = newStatus,
|
|
Cause = DetermineCause(diff),
|
|
CauseDetail = BuildCauseDetail(diff, finding),
|
|
ChangedAt = occurredAt
|
|
});
|
|
}
|
|
|
|
return changes;
|
|
}
|
|
|
|
private static Guid ResolveTenantId(string tenant)
|
|
{
|
|
if (Guid.TryParse(tenant, out var tenantId))
|
|
{
|
|
return tenantId;
|
|
}
|
|
|
|
var normalized = tenant.Trim().ToLowerInvariant();
|
|
return ScannerIdentifiers.CreateDeterministicGuid(TenantNamespace, Encoding.UTF8.GetBytes(normalized));
|
|
}
|
|
|
|
private static Guid ResolveExecutionId(Guid tenantId, string reportId)
|
|
{
|
|
var payload = $"{tenantId:D}:{reportId}".Trim().ToLowerInvariant();
|
|
return ScannerIdentifiers.CreateDeterministicGuid(ExecutionNamespace, Encoding.UTF8.GetBytes(payload));
|
|
}
|
|
|
|
private static Guid ResolveManifestId(Guid tenantId, ReportDocumentDto document)
|
|
{
|
|
var manifestDigest = document.Surface?.ManifestDigest;
|
|
var payloadSource = string.IsNullOrWhiteSpace(manifestDigest)
|
|
? document.ImageDigest
|
|
: manifestDigest;
|
|
var payload = $"{tenantId:D}:{payloadSource}".Trim().ToLowerInvariant();
|
|
return ScannerIdentifiers.CreateDeterministicGuid(ManifestNamespace, Encoding.UTF8.GetBytes(payload));
|
|
}
|
|
|
|
private static ClassificationStatus MapVerdictStatus(PolicyVerdictStatus status) => status switch
|
|
{
|
|
PolicyVerdictStatus.Blocked or PolicyVerdictStatus.Escalated => ClassificationStatus.Affected,
|
|
PolicyVerdictStatus.Warned or PolicyVerdictStatus.Deferred or PolicyVerdictStatus.RequiresVex => ClassificationStatus.Unknown,
|
|
_ => ClassificationStatus.Unaffected
|
|
};
|
|
|
|
private static DriftCause DetermineCause(PolicyVerdictDiff diff)
|
|
{
|
|
if (!string.Equals(diff.Baseline.RuleName, diff.Projected.RuleName, StringComparison.Ordinal)
|
|
|| !string.Equals(diff.Baseline.RuleAction, diff.Projected.RuleAction, StringComparison.Ordinal))
|
|
{
|
|
return DriftCause.RuleDelta;
|
|
}
|
|
|
|
if (!string.Equals(diff.Baseline.Reachability, diff.Projected.Reachability, StringComparison.Ordinal))
|
|
{
|
|
return DriftCause.ReachabilityDelta;
|
|
}
|
|
|
|
if (!string.Equals(diff.Baseline.SourceTrust, diff.Projected.SourceTrust, StringComparison.Ordinal))
|
|
{
|
|
return DriftCause.FeedDelta;
|
|
}
|
|
|
|
if (diff.Baseline.Quiet != diff.Projected.Quiet
|
|
|| !string.Equals(diff.Baseline.QuietedBy, diff.Projected.QuietedBy, StringComparison.Ordinal))
|
|
{
|
|
return DriftCause.LatticeDelta;
|
|
}
|
|
|
|
return DriftCause.Other;
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, string>? BuildCauseDetail(PolicyVerdictDiff diff, PolicyPreviewFindingDto finding)
|
|
{
|
|
var details = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
|
|
|
if (!string.IsNullOrWhiteSpace(diff.Projected.RuleName))
|
|
{
|
|
details["ruleName"] = diff.Projected.RuleName!;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(diff.Projected.RuleAction))
|
|
{
|
|
details["ruleAction"] = diff.Projected.RuleAction!;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(diff.Projected.Reachability))
|
|
{
|
|
details["reachability"] = diff.Projected.Reachability!;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(diff.Projected.SourceTrust))
|
|
{
|
|
details["sourceTrust"] = diff.Projected.SourceTrust!;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(finding.Source))
|
|
{
|
|
details["findingSource"] = finding.Source!;
|
|
}
|
|
|
|
return details.Count == 0 ? null : details;
|
|
}
|
|
|
|
private async Task PublishSafelyAsync(OrchestratorEvent @event, string reportId, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await _publisher.PublishAsync(@event, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(
|
|
ex,
|
|
"Failed to publish orchestrator event {EventKind} for report {ReportId}.",
|
|
@event.Kind,
|
|
reportId);
|
|
}
|
|
}
|
|
|
|
private static string ResolveTenant(HttpContext context)
|
|
{
|
|
return ScannerRequestContextResolver.ResolveTenantOrDefault(context, DefaultTenant);
|
|
}
|
|
|
|
private static OrchestratorEventScope BuildScope(ReportRequestDto request, ReportDocumentDto document)
|
|
{
|
|
var repository = ResolveRepository(request);
|
|
var (ns, repo) = SplitRepository(repository);
|
|
|
|
var digest = string.IsNullOrWhiteSpace(document.ImageDigest)
|
|
? request.ImageDigest ?? string.Empty
|
|
: document.ImageDigest;
|
|
|
|
return new OrchestratorEventScope
|
|
{
|
|
Namespace = ns,
|
|
Repo = string.IsNullOrWhiteSpace(repo) ? "(unknown)" : repo,
|
|
Digest = string.IsNullOrWhiteSpace(digest) ? "(unknown)" : digest
|
|
};
|
|
}
|
|
|
|
private static ImmutableSortedDictionary<string, string> BuildAttributes(ReportDocumentDto document)
|
|
{
|
|
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
|
builder["reportId"] = document.ReportId;
|
|
builder["verdict"] = document.Verdict;
|
|
|
|
if (!string.IsNullOrWhiteSpace(document.Policy.RevisionId))
|
|
{
|
|
builder["policyRevisionId"] = document.Policy.RevisionId!;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(document.Policy.Digest))
|
|
{
|
|
builder["policyDigest"] = document.Policy.Digest!;
|
|
}
|
|
|
|
return builder.ToImmutable();
|
|
}
|
|
|
|
private static ReportReadyEventPayload BuildReportReadyPayload(
|
|
ReportRequestDto request,
|
|
PolicyPreviewResponse preview,
|
|
ReportDocumentDto document,
|
|
DsseEnvelopeDto? envelope,
|
|
ReportLinksPayload links,
|
|
string correlationId)
|
|
{
|
|
return new ReportReadyEventPayload
|
|
{
|
|
ReportId = document.ReportId,
|
|
ScanId = correlationId,
|
|
ImageDigest = document.ImageDigest,
|
|
GeneratedAt = document.GeneratedAt,
|
|
Verdict = MapVerdict(document.Verdict),
|
|
Summary = document.Summary,
|
|
Delta = BuildDelta(preview, request),
|
|
QuietedFindingCount = document.Summary.Quieted,
|
|
Policy = document.Policy,
|
|
Links = links,
|
|
Dsse = envelope,
|
|
Report = document
|
|
};
|
|
}
|
|
|
|
private static ScanCompletedEventPayload BuildScanCompletedPayload(
|
|
ReportRequestDto request,
|
|
PolicyPreviewResponse preview,
|
|
ReportDocumentDto document,
|
|
DsseEnvelopeDto? envelope,
|
|
ReportLinksPayload links,
|
|
string correlationId)
|
|
{
|
|
return new ScanCompletedEventPayload
|
|
{
|
|
ReportId = document.ReportId,
|
|
ScanId = correlationId,
|
|
ImageDigest = document.ImageDigest,
|
|
Verdict = MapVerdict(document.Verdict),
|
|
Summary = document.Summary,
|
|
Delta = BuildDelta(preview, request),
|
|
Policy = document.Policy,
|
|
Findings = BuildFindingSummaries(request),
|
|
Links = links,
|
|
Dsse = envelope,
|
|
Report = document
|
|
};
|
|
}
|
|
|
|
private ReportLinksPayload BuildLinks(HttpContext context, ReportDocumentDto document, DsseEnvelopeDto? envelope)
|
|
{
|
|
if (!context.Request.Host.HasValue)
|
|
{
|
|
return new ReportLinksPayload();
|
|
}
|
|
|
|
var reportUi = BuildAbsoluteUri(context, ConcatSegments(_consoleBaseSegments, _consoleReportsSegment, document.ReportId));
|
|
var reportApi = BuildAbsoluteUri(context, ConcatSegments(_apiBaseSegments, _reportsSegment, document.ReportId));
|
|
|
|
LinkTarget? policyLink = null;
|
|
if (!string.IsNullOrWhiteSpace(document.Policy.RevisionId))
|
|
{
|
|
var policyRevision = document.Policy.RevisionId!;
|
|
var policyUi = BuildAbsoluteUri(context, ConcatSegments(_consoleBaseSegments, _consolePolicySegment, "revisions", policyRevision));
|
|
var policyApi = BuildAbsoluteUri(context, ConcatSegments(_apiBaseSegments, _policySegment, "revisions", policyRevision));
|
|
policyLink = LinkTarget.Create(policyUi, policyApi);
|
|
}
|
|
|
|
LinkTarget? attestationLink = null;
|
|
if (envelope is not null)
|
|
{
|
|
var attestationUi = BuildAbsoluteUri(context, ConcatSegments(_consoleBaseSegments, _consoleAttestationsSegment, document.ReportId));
|
|
var attestationApi = BuildAbsoluteUri(context, ConcatSegments(_apiBaseSegments, _reportsSegment, document.ReportId, "attestation"));
|
|
attestationLink = LinkTarget.Create(attestationUi, attestationApi);
|
|
}
|
|
|
|
return new ReportLinksPayload
|
|
{
|
|
Report = LinkTarget.Create(reportUi, reportApi),
|
|
Policy = policyLink,
|
|
Attestation = attestationLink
|
|
};
|
|
}
|
|
|
|
private static ReportDeltaPayload? BuildDelta(PolicyPreviewResponse preview, ReportRequestDto request)
|
|
{
|
|
if (preview.Diffs.IsDefaultOrEmpty)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var findings = BuildFindingsIndex(request.Findings);
|
|
var kevIds = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var newCritical = 0;
|
|
var newHigh = 0;
|
|
|
|
foreach (var diff in preview.Diffs)
|
|
{
|
|
var projected = diff.Projected;
|
|
if (projected is null || string.IsNullOrWhiteSpace(projected.FindingId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
findings.TryGetValue(projected.FindingId, out var finding);
|
|
|
|
if (IsNewlyImportant(diff))
|
|
{
|
|
var severity = finding?.Severity;
|
|
if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
newCritical++;
|
|
}
|
|
else if (string.Equals(severity, "High", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
newHigh++;
|
|
}
|
|
|
|
var kevId = ResolveKevIdentifier(finding);
|
|
if (!string.IsNullOrWhiteSpace(kevId))
|
|
{
|
|
kevIds.Add(kevId);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (newCritical == 0 && newHigh == 0 && kevIds.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new ReportDeltaPayload
|
|
{
|
|
NewCritical = newCritical > 0 ? newCritical : null,
|
|
NewHigh = newHigh > 0 ? newHigh : null,
|
|
Kev = kevIds.Count > 0 ? kevIds.ToArray() : null
|
|
};
|
|
}
|
|
|
|
private static string BuildAbsoluteUri(HttpContext context, params string[] segments)
|
|
=> BuildAbsoluteUri(context, segments.AsEnumerable());
|
|
|
|
private static string BuildAbsoluteUri(HttpContext context, IEnumerable<string> segments)
|
|
{
|
|
var normalized = segments
|
|
.Where(segment => !string.IsNullOrWhiteSpace(segment))
|
|
.Select(segment => segment.Trim('/'))
|
|
.Where(segment => segment.Length > 0)
|
|
.ToArray();
|
|
|
|
if (!context.Request.Host.HasValue || normalized.Length == 0)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
var scheme = string.IsNullOrWhiteSpace(context.Request.Scheme) ? "https" : context.Request.Scheme;
|
|
var builder = new UriBuilder(scheme, context.Request.Host.Host)
|
|
{
|
|
Port = context.Request.Host.Port ?? -1,
|
|
Path = "/" + string.Join('/', normalized.Select(Uri.EscapeDataString)),
|
|
Query = string.Empty,
|
|
Fragment = string.Empty
|
|
};
|
|
|
|
return builder.Uri.ToString();
|
|
}
|
|
|
|
private string[] ConcatSegments(IEnumerable<string> prefix, params string[] suffix)
|
|
{
|
|
var segments = new List<string>();
|
|
foreach (var segment in prefix)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(segment))
|
|
{
|
|
segments.Add(segment.Trim('/'));
|
|
}
|
|
}
|
|
|
|
foreach (var segment in suffix)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(segment))
|
|
{
|
|
segments.Add(segment.Trim('/'));
|
|
}
|
|
}
|
|
|
|
return segments.ToArray();
|
|
}
|
|
|
|
private static string[] SplitSegments(string? path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
return path.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
}
|
|
|
|
private static ImmutableDictionary<string, PolicyPreviewFindingDto> BuildFindingsIndex(
|
|
IReadOnlyList<PolicyPreviewFindingDto>? findings)
|
|
{
|
|
if (findings is null || findings.Count == 0)
|
|
{
|
|
return ImmutableDictionary<string, PolicyPreviewFindingDto>.Empty;
|
|
}
|
|
|
|
var builder = ImmutableDictionary.CreateBuilder<string, PolicyPreviewFindingDto>(StringComparer.Ordinal);
|
|
foreach (var finding in findings)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(finding.Id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!builder.ContainsKey(finding.Id))
|
|
{
|
|
builder.Add(finding.Id, finding);
|
|
}
|
|
}
|
|
|
|
return builder.ToImmutable();
|
|
}
|
|
|
|
private static IReadOnlyList<FindingSummaryPayload> BuildFindingSummaries(ReportRequestDto request)
|
|
{
|
|
if (request.Findings is not { Count: > 0 })
|
|
{
|
|
return Array.Empty<FindingSummaryPayload>();
|
|
}
|
|
|
|
var summaries = new List<FindingSummaryPayload>(request.Findings.Count);
|
|
foreach (var finding in request.Findings)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(finding.Id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
summaries.Add(new FindingSummaryPayload
|
|
{
|
|
Id = finding.Id,
|
|
Severity = finding.Severity,
|
|
Cve = finding.Cve,
|
|
Purl = finding.Purl,
|
|
Reachability = ResolveReachability(finding.Tags)
|
|
});
|
|
}
|
|
|
|
return summaries;
|
|
}
|
|
|
|
private static string ResolveRepository(ReportRequestDto request)
|
|
{
|
|
if (request.Findings is { Count: > 0 })
|
|
{
|
|
foreach (var finding in request.Findings)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(finding.Repository))
|
|
{
|
|
return finding.Repository!.Trim();
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(finding.Image))
|
|
{
|
|
return finding.Image!.Trim();
|
|
}
|
|
}
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
private static (string? Namespace, string Repo) SplitRepository(string repository)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(repository))
|
|
{
|
|
return (null, string.Empty);
|
|
}
|
|
|
|
var normalized = repository.Trim();
|
|
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
if (segments.Length == 0)
|
|
{
|
|
return (null, normalized);
|
|
}
|
|
|
|
if (segments.Length == 1)
|
|
{
|
|
return (null, segments[0]);
|
|
}
|
|
|
|
var repo = segments[^1];
|
|
var ns = string.Join('/', segments[..^1]);
|
|
return (ns, repo);
|
|
}
|
|
|
|
private static bool IsNewlyImportant(PolicyVerdictDiff diff)
|
|
{
|
|
var projected = diff.Projected.Status;
|
|
var baseline = diff.Baseline.Status;
|
|
|
|
return projected switch
|
|
{
|
|
PolicyVerdictStatus.Blocked or PolicyVerdictStatus.Escalated
|
|
=> baseline != PolicyVerdictStatus.Blocked && baseline != PolicyVerdictStatus.Escalated,
|
|
PolicyVerdictStatus.Warned or PolicyVerdictStatus.Deferred or PolicyVerdictStatus.RequiresVex
|
|
=> baseline != PolicyVerdictStatus.Warned
|
|
&& baseline != PolicyVerdictStatus.Deferred
|
|
&& baseline != PolicyVerdictStatus.RequiresVex
|
|
&& baseline != PolicyVerdictStatus.Blocked
|
|
&& baseline != PolicyVerdictStatus.Escalated,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
private static string? ResolveKevIdentifier(PolicyPreviewFindingDto? finding)
|
|
{
|
|
if (finding is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var tags = finding.Tags;
|
|
if (tags is not null)
|
|
{
|
|
foreach (var tag in tags)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tag))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (string.Equals(tag, "kev", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return finding.Cve;
|
|
}
|
|
|
|
if (tag.StartsWith("kev:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var value = tag["kev:".Length..];
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return value.Trim();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return finding.Cve;
|
|
}
|
|
|
|
private static string? ResolveReachability(IReadOnlyList<string>? tags)
|
|
{
|
|
if (tags is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
foreach (var tag in tags)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tag))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (tag.StartsWith("reachability:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return tag["reachability:".Length..];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string MapVerdict(string verdict)
|
|
=> verdict.ToLowerInvariant() switch
|
|
{
|
|
"blocked" or "fail" => "fail",
|
|
"escalated" => "fail",
|
|
"warn" or "warned" or "deferred" or "requiresvex" => "warn",
|
|
_ => "pass"
|
|
};
|
|
|
|
private static string BuildIdempotencyKey(string kind, string tenant, string identifier)
|
|
=> $"{kind}:{tenant}:{identifier}".ToLowerInvariant();
|
|
|
|
private static (string? TraceId, string? SpanId) ResolveTraceContext()
|
|
{
|
|
var activity = Activity.Current;
|
|
if (activity is null)
|
|
{
|
|
return (null, null);
|
|
}
|
|
|
|
var traceId = activity.TraceId.ToString();
|
|
var spanId = activity.SpanId.ToString();
|
|
return (traceId, spanId);
|
|
}
|
|
}
|