consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -5,7 +5,7 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
internal static class OrchestratorEventKinds
|
||||
internal static class JobEngineEventKinds
|
||||
{
|
||||
public const string ScannerReportReady = "scanner.event.report.ready";
|
||||
public const string ScannerScanCompleted = "scanner.event.scan.completed";
|
||||
@@ -15,7 +15,7 @@ internal static class OrchestratorEventKinds
|
||||
public const string ScannerVulnerabilityDetected = "scanner.event.vulnerability.detected";
|
||||
}
|
||||
|
||||
internal sealed record OrchestratorEvent
|
||||
internal sealed record JobEngineEvent
|
||||
{
|
||||
[JsonPropertyName("eventId")]
|
||||
[JsonPropertyOrder(0)]
|
||||
@@ -68,11 +68,11 @@ internal sealed record OrchestratorEvent
|
||||
[JsonPropertyName("scope")]
|
||||
[JsonPropertyOrder(11)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public OrchestratorEventScope? Scope { get; init; }
|
||||
public JobEngineEventScope? Scope { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
[JsonPropertyOrder(12)]
|
||||
public OrchestratorEventPayload Payload { get; init; } = default!;
|
||||
public JobEngineEventPayload Payload { get; init; } = default!;
|
||||
|
||||
[JsonPropertyName("attributes")]
|
||||
[JsonPropertyOrder(13)]
|
||||
@@ -86,7 +86,7 @@ internal sealed record OrchestratorEvent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for Notifier service ingestion per orchestrator-envelope.schema.json.
|
||||
/// Metadata for Notifier service ingestion per jobengine-envelope.schema.json.
|
||||
/// </summary>
|
||||
internal sealed record NotifierIngestionMetadata
|
||||
{
|
||||
@@ -113,7 +113,7 @@ internal sealed record NotifierIngestionMetadata
|
||||
public string? Priority { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record OrchestratorEventScope
|
||||
internal sealed record JobEngineEventScope
|
||||
{
|
||||
[JsonPropertyName("namespace")]
|
||||
[JsonPropertyOrder(0)]
|
||||
@@ -139,9 +139,9 @@ internal sealed record OrchestratorEventScope
|
||||
public string? Image { get; init; }
|
||||
}
|
||||
|
||||
internal abstract record OrchestratorEventPayload;
|
||||
internal abstract record JobEngineEventPayload;
|
||||
|
||||
internal sealed record ReportReadyEventPayload : OrchestratorEventPayload
|
||||
internal sealed record ReportReadyEventPayload : JobEngineEventPayload
|
||||
{
|
||||
[JsonPropertyName("reportId")]
|
||||
[JsonPropertyOrder(0)]
|
||||
@@ -195,7 +195,7 @@ internal sealed record ReportReadyEventPayload : OrchestratorEventPayload
|
||||
public ReportDocumentDto Report { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed record ScanCompletedEventPayload : OrchestratorEventPayload
|
||||
internal sealed record ScanCompletedEventPayload : JobEngineEventPayload
|
||||
{
|
||||
[JsonPropertyName("reportId")]
|
||||
[JsonPropertyOrder(0)]
|
||||
@@ -393,7 +393,7 @@ internal sealed record FindingSummaryPayload
|
||||
/// <summary>
|
||||
/// Payload for scanner.event.scan.started events.
|
||||
/// </summary>
|
||||
internal sealed record ScanStartedEventPayload : OrchestratorEventPayload
|
||||
internal sealed record ScanStartedEventPayload : JobEngineEventPayload
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
[JsonPropertyOrder(0)]
|
||||
@@ -420,7 +420,7 @@ internal sealed record ScanStartedEventPayload : OrchestratorEventPayload
|
||||
/// <summary>
|
||||
/// Payload for scanner.event.scan.failed events.
|
||||
/// </summary>
|
||||
internal sealed record ScanFailedEventPayload : OrchestratorEventPayload
|
||||
internal sealed record ScanFailedEventPayload : JobEngineEventPayload
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
[JsonPropertyOrder(0)]
|
||||
@@ -461,7 +461,7 @@ internal sealed record ScanFailedEventPayload : OrchestratorEventPayload
|
||||
/// <summary>
|
||||
/// Payload for scanner.event.sbom.generated events.
|
||||
/// </summary>
|
||||
internal sealed record SbomGeneratedEventPayload : OrchestratorEventPayload
|
||||
internal sealed record SbomGeneratedEventPayload : JobEngineEventPayload
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
[JsonPropertyOrder(0)]
|
||||
@@ -506,7 +506,7 @@ internal sealed record SbomGeneratedEventPayload : OrchestratorEventPayload
|
||||
/// <summary>
|
||||
/// Payload for scanner.event.vulnerability.detected events.
|
||||
/// </summary>
|
||||
internal sealed record VulnerabilityDetectedEventPayload : OrchestratorEventPayload
|
||||
internal sealed record VulnerabilityDetectedEventPayload : JobEngineEventPayload
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
[JsonPropertyOrder(0)]
|
||||
@@ -203,89 +203,103 @@ public sealed class ActionablesService : IActionablesService
|
||||
|
||||
public async Task<ActionablesResponseDto?> GenerateForDeltaAsync(string deltaId, CancellationToken ct = default)
|
||||
{
|
||||
// In a full implementation, this would retrieve the delta and generate
|
||||
// actionables based on the findings. For now, return sample actionables.
|
||||
|
||||
var delta = await _deltaService.GetComparisonAsync(deltaId, ct);
|
||||
if (delta is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Even if delta is null, we can still generate sample actionables for demo
|
||||
var actionables = new List<ActionableDto>();
|
||||
var componentsByPurl = (delta.Components ?? [])
|
||||
.ToDictionary(c => c.Purl, StringComparer.Ordinal);
|
||||
|
||||
// Sample upgrade actionable
|
||||
actionables.Add(new ActionableDto
|
||||
foreach (var vulnerability in delta.Vulnerabilities ?? [])
|
||||
{
|
||||
Id = $"action-upgrade-{deltaId[..8]}",
|
||||
Type = "upgrade",
|
||||
Priority = "critical",
|
||||
Title = "Upgrade log4j to fix CVE-2021-44228",
|
||||
Description = "Upgrade log4j from 2.14.1 to 2.17.1 to remediate the Log4Shell vulnerability. " +
|
||||
"This is a critical remote code execution vulnerability.",
|
||||
Component = "pkg:maven/org.apache.logging.log4j/log4j-core",
|
||||
CurrentVersion = "2.14.1",
|
||||
TargetVersion = "2.17.1",
|
||||
CveIds = ["CVE-2021-44228", "CVE-2021-45046"],
|
||||
EstimatedEffort = "low",
|
||||
Evidence = new ActionableEvidenceDto
|
||||
if (!vulnerability.ChangeType.Equals("Added", StringComparison.OrdinalIgnoreCase)
|
||||
&& !vulnerability.ChangeType.Equals("Modified", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
PolicyRuleId = "rule-critical-cve"
|
||||
continue;
|
||||
}
|
||||
});
|
||||
|
||||
// Sample VEX actionable
|
||||
actionables.Add(new ActionableDto
|
||||
{
|
||||
Id = $"action-vex-{deltaId[..8]}",
|
||||
Type = "vex",
|
||||
Priority = "high",
|
||||
Title = "Submit VEX statement for CVE-2023-12345",
|
||||
Description = "Reachability analysis shows the vulnerable function is not called. " +
|
||||
"Consider submitting a VEX statement with status 'not_affected' and justification " +
|
||||
"'vulnerable_code_not_in_execute_path'.",
|
||||
Component = "pkg:npm/example-lib",
|
||||
CveIds = ["CVE-2023-12345"],
|
||||
EstimatedEffort = "trivial",
|
||||
Evidence = new ActionableEvidenceDto
|
||||
var priority = vulnerability.Severity switch
|
||||
{
|
||||
WitnessId = "witness-12345"
|
||||
}
|
||||
});
|
||||
|
||||
// Sample investigate actionable
|
||||
actionables.Add(new ActionableDto
|
||||
{
|
||||
Id = $"action-investigate-{deltaId[..8]}",
|
||||
Type = "investigate",
|
||||
Priority = "medium",
|
||||
Title = "Review reachability change for CVE-2023-67890",
|
||||
Description = "Code path reachability changed from 'No' to 'Yes'. Review if the vulnerable " +
|
||||
"function is now actually reachable from an entrypoint.",
|
||||
Component = "pkg:pypi/requests",
|
||||
CveIds = ["CVE-2023-67890"],
|
||||
EstimatedEffort = "medium",
|
||||
Evidence = new ActionableEvidenceDto
|
||||
"critical" => "critical",
|
||||
"high" => "high",
|
||||
"medium" => "medium",
|
||||
_ => "low"
|
||||
};
|
||||
var type = vulnerability.FixedVersion is null ? "investigate" : "upgrade";
|
||||
if (vulnerability.ChangeType.Equals("Modified", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
WitnessId = "witness-67890"
|
||||
type = "investigate";
|
||||
}
|
||||
});
|
||||
|
||||
// Sample config actionable
|
||||
actionables.Add(new ActionableDto
|
||||
componentsByPurl.TryGetValue(vulnerability.Purl, out var component);
|
||||
actionables.Add(new ActionableDto
|
||||
{
|
||||
Id = BuildActionableId(deltaId, type, vulnerability.VulnId, vulnerability.Purl),
|
||||
Type = type,
|
||||
Priority = priority,
|
||||
Title = $"{ToTitle(type)} {vulnerability.VulnId}",
|
||||
Description = BuildDescription(vulnerability),
|
||||
Component = vulnerability.Purl,
|
||||
CurrentVersion = component?.CurrentVersion ?? component?.PreviousVersion,
|
||||
TargetVersion = vulnerability.FixedVersion,
|
||||
CveIds = [vulnerability.VulnId],
|
||||
EstimatedEffort = EstimateEffort(priority, type),
|
||||
Evidence = new ActionableEvidenceDto
|
||||
{
|
||||
PolicyRuleId = "delta.finding.changed",
|
||||
WitnessId = $"wit-{NormalizeId(vulnerability.VulnId)}"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var component in delta.Components ?? [])
|
||||
{
|
||||
Id = $"action-config-{deltaId[..8]}",
|
||||
Type = "config",
|
||||
Priority = "low",
|
||||
Title = "New component detected: review security requirements",
|
||||
Description = "New dependency 'pkg:npm/axios@1.6.0' was added. Verify it meets security " +
|
||||
"requirements and is from a trusted source.",
|
||||
Component = "pkg:npm/axios",
|
||||
CurrentVersion = "1.6.0",
|
||||
EstimatedEffort = "trivial"
|
||||
});
|
||||
if (!component.ChangeType.Equals("Added", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
actionables.Add(new ActionableDto
|
||||
{
|
||||
Id = BuildActionableId(deltaId, "config", component.Purl, component.CurrentVersion ?? string.Empty),
|
||||
Type = "config",
|
||||
Priority = component.VulnerabilitiesInTarget > 0 ? "medium" : "low",
|
||||
Title = $"Review new component {component.Purl}",
|
||||
Description = "New component introduced in target snapshot. Validate provenance and policy posture.",
|
||||
Component = component.Purl,
|
||||
CurrentVersion = component.CurrentVersion,
|
||||
EstimatedEffort = "trivial",
|
||||
Evidence = new ActionableEvidenceDto
|
||||
{
|
||||
PolicyRuleId = "delta.component.added"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (delta.PolicyDiff is { VerdictChanged: true })
|
||||
{
|
||||
actionables.Add(new ActionableDto
|
||||
{
|
||||
Id = BuildActionableId(deltaId, "vex", delta.PolicyDiff.BaseVerdict, delta.PolicyDiff.TargetVerdict),
|
||||
Type = "vex",
|
||||
Priority = delta.PolicyDiff.TargetVerdict.Equals("Block", StringComparison.OrdinalIgnoreCase) ? "high" : "medium",
|
||||
Title = $"Policy verdict changed: {delta.PolicyDiff.BaseVerdict} -> {delta.PolicyDiff.TargetVerdict}",
|
||||
Description = "Review reachability context and publish a VEX statement when findings are not exploitable.",
|
||||
EstimatedEffort = "low",
|
||||
Evidence = new ActionableEvidenceDto
|
||||
{
|
||||
PolicyRuleId = "delta.policy.verdict"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
var sortedActionables = actionables
|
||||
.DistinctBy(a => a.Id, StringComparer.Ordinal)
|
||||
.OrderBy(a => GetPriorityOrder(a.Priority))
|
||||
.ThenBy(a => a.Title, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return new ActionablesResponseDto
|
||||
@@ -296,6 +310,43 @@ public sealed class ActionablesService : IActionablesService
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildActionableId(string deltaId, string type, string part1, string part2)
|
||||
{
|
||||
var input = $"{deltaId}|{type}|{part1}|{part2}";
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
return $"act-{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string BuildDescription(DeltaVulnerabilityDto vulnerability)
|
||||
{
|
||||
if (vulnerability.ChangeType.Equals("Added", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"New vulnerable finding detected for {vulnerability.Purl}. Prioritize remediation for severity '{vulnerability.Severity}'.";
|
||||
}
|
||||
|
||||
return $"Finding metadata changed for {vulnerability.VulnId}. Review reachability/verdict transitions before promotion.";
|
||||
}
|
||||
|
||||
private static string ToTitle(string type) => type switch
|
||||
{
|
||||
"upgrade" => "Upgrade to remediate",
|
||||
"patch" => "Patch required for",
|
||||
"vex" => "Publish VEX for",
|
||||
"config" => "Configuration review for",
|
||||
_ => "Investigate"
|
||||
};
|
||||
|
||||
private static string EstimateEffort(string priority, string type) => (priority, type) switch
|
||||
{
|
||||
("critical", "upgrade") => "medium",
|
||||
("high", _) => "low",
|
||||
(_, "config") => "trivial",
|
||||
_ => "low"
|
||||
};
|
||||
|
||||
private static string NormalizeId(string value)
|
||||
=> value.Replace(':', '-').Replace('/', '-').ToLowerInvariant();
|
||||
|
||||
private static int GetPriorityOrder(string priority)
|
||||
{
|
||||
return priority.ToLowerInvariant() switch
|
||||
|
||||
@@ -9,10 +9,9 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -23,12 +22,6 @@ namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
/// </summary>
|
||||
internal static class DeltaCompareEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps delta compare endpoints.
|
||||
/// </summary>
|
||||
@@ -190,6 +183,20 @@ public interface IDeltaCompareService
|
||||
/// </summary>
|
||||
public sealed class DeltaCompareService : IDeltaCompareService
|
||||
{
|
||||
private static readonly (string Ecosystem, string Name, string License)[] ComponentTemplates =
|
||||
[
|
||||
("npm", "axios", "MIT"),
|
||||
("npm", "lodash", "MIT"),
|
||||
("maven", "org.apache.logging.log4j/log4j-core", "Apache-2.0"),
|
||||
("maven", "org.springframework/spring-core", "Apache-2.0"),
|
||||
("pypi", "requests", "Apache-2.0"),
|
||||
("nuget", "Newtonsoft.Json", "MIT"),
|
||||
("golang", "golang.org/x/net", "BSD-3-Clause"),
|
||||
("cargo", "tokio", "MIT"),
|
||||
];
|
||||
|
||||
private static readonly string[] OrderedSeverities = ["critical", "high", "medium", "low", "unknown"];
|
||||
private readonly ConcurrentDictionary<string, DeltaCompareResponseDto> _comparisons = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeltaCompareService(TimeProvider timeProvider)
|
||||
@@ -199,95 +206,552 @@ public sealed class DeltaCompareService : IDeltaCompareService
|
||||
|
||||
public Task<DeltaCompareResponseDto> CompareAsync(DeltaCompareRequestDto request, CancellationToken ct = default)
|
||||
{
|
||||
// Compute deterministic comparison ID
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var comparisonId = ComputeComparisonId(request.BaseDigest, request.TargetDigest);
|
||||
|
||||
// In a full implementation, this would:
|
||||
// 1. Load both snapshots from storage
|
||||
// 2. Compare vulnerabilities and components
|
||||
// 3. Compute policy diffs
|
||||
// For now, return a structured response
|
||||
|
||||
var baseSummary = CreateSnapshotSummary(request.BaseDigest, "Block");
|
||||
var targetSummary = CreateSnapshotSummary(request.TargetDigest, "Ship");
|
||||
|
||||
var response = new DeltaCompareResponseDto
|
||||
if (!_comparisons.TryGetValue(comparisonId, out var fullComparison))
|
||||
{
|
||||
Base = baseSummary,
|
||||
Target = targetSummary,
|
||||
Summary = new DeltaChangeSummaryDto
|
||||
{
|
||||
Added = 0,
|
||||
Removed = 0,
|
||||
Modified = 0,
|
||||
Unchanged = 0,
|
||||
NetVulnerabilityChange = 0,
|
||||
NetComponentChange = 0,
|
||||
SeverityChanges = new DeltaSeverityChangesDto(),
|
||||
VerdictChanged = baseSummary.PolicyVerdict != targetSummary.PolicyVerdict,
|
||||
RiskDirection = "unchanged"
|
||||
},
|
||||
Vulnerabilities = request.IncludeVulnerabilities ? [] : null,
|
||||
Components = request.IncludeComponents ? [] : null,
|
||||
PolicyDiff = request.IncludePolicyDiff
|
||||
? new DeltaPolicyDiffDto
|
||||
{
|
||||
BaseVerdict = baseSummary.PolicyVerdict ?? "Unknown",
|
||||
TargetVerdict = targetSummary.PolicyVerdict ?? "Unknown",
|
||||
VerdictChanged = baseSummary.PolicyVerdict != targetSummary.PolicyVerdict,
|
||||
BlockToShipCount = 0,
|
||||
ShipToBlockCount = 0
|
||||
}
|
||||
: null,
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
ComparisonId = comparisonId
|
||||
};
|
||||
fullComparison = BuildComparison(request.BaseDigest.Trim(), request.TargetDigest.Trim(), comparisonId);
|
||||
_comparisons[comparisonId] = fullComparison;
|
||||
}
|
||||
|
||||
return Task.FromResult(response);
|
||||
return Task.FromResult(ProjectComparison(fullComparison, request));
|
||||
}
|
||||
|
||||
public Task<QuickDiffSummaryDto> GetQuickDiffAsync(string baseDigest, string targetDigest, CancellationToken ct = default)
|
||||
public async Task<QuickDiffSummaryDto> GetQuickDiffAsync(string baseDigest, string targetDigest, CancellationToken ct = default)
|
||||
{
|
||||
var summary = new QuickDiffSummaryDto
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var comparisonId = ComputeComparisonId(baseDigest, targetDigest);
|
||||
if (!_comparisons.TryGetValue(comparisonId, out var comparison))
|
||||
{
|
||||
comparison = await CompareAsync(
|
||||
new DeltaCompareRequestDto
|
||||
{
|
||||
BaseDigest = baseDigest,
|
||||
TargetDigest = targetDigest,
|
||||
IncludeComponents = true,
|
||||
IncludePolicyDiff = true,
|
||||
IncludeVulnerabilities = true,
|
||||
IncludeUnchanged = true
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var netBlockingChange = (comparison.Target.SeverityCounts.Critical + comparison.Target.SeverityCounts.High)
|
||||
- (comparison.Base.SeverityCounts.Critical + comparison.Base.SeverityCounts.High);
|
||||
|
||||
return new QuickDiffSummaryDto
|
||||
{
|
||||
BaseDigest = baseDigest,
|
||||
TargetDigest = targetDigest,
|
||||
CanShip = true,
|
||||
RiskDirection = "unchanged",
|
||||
NetBlockingChange = 0,
|
||||
CriticalAdded = 0,
|
||||
CriticalRemoved = 0,
|
||||
HighAdded = 0,
|
||||
HighRemoved = 0,
|
||||
Summary = "No material changes detected"
|
||||
CanShip = !string.Equals(comparison.Target.PolicyVerdict, "Block", StringComparison.OrdinalIgnoreCase),
|
||||
RiskDirection = comparison.Summary.RiskDirection,
|
||||
NetBlockingChange = netBlockingChange,
|
||||
CriticalAdded = comparison.Summary.SeverityChanges.CriticalAdded,
|
||||
CriticalRemoved = comparison.Summary.SeverityChanges.CriticalRemoved,
|
||||
HighAdded = comparison.Summary.SeverityChanges.HighAdded,
|
||||
HighRemoved = comparison.Summary.SeverityChanges.HighRemoved,
|
||||
Summary = comparison.Summary.RiskDirection switch
|
||||
{
|
||||
"degraded" => "Risk increased between snapshots.",
|
||||
"improved" => "Risk reduced between snapshots.",
|
||||
_ => "Risk profile is unchanged."
|
||||
}
|
||||
};
|
||||
|
||||
return Task.FromResult(summary);
|
||||
}
|
||||
|
||||
public Task<DeltaCompareResponseDto?> GetComparisonAsync(string comparisonId, CancellationToken ct = default)
|
||||
{
|
||||
// In a full implementation, this would retrieve from cache/storage
|
||||
return Task.FromResult<DeltaCompareResponseDto?>(null);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (string.IsNullOrWhiteSpace(comparisonId))
|
||||
{
|
||||
return Task.FromResult<DeltaCompareResponseDto?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult(_comparisons.TryGetValue(comparisonId.Trim(), out var comparison) ? comparison : null);
|
||||
}
|
||||
|
||||
private DeltaSnapshotSummaryDto CreateSnapshotSummary(string digest, string verdict)
|
||||
private DeltaCompareResponseDto BuildComparison(string baseDigest, string targetDigest, string comparisonId)
|
||||
{
|
||||
return new DeltaSnapshotSummaryDto
|
||||
var baseSnapshot = BuildSnapshot(baseDigest);
|
||||
var targetSnapshot = BuildSnapshot(targetDigest);
|
||||
|
||||
var vulnerabilities = BuildVulnerabilityDiffs(baseSnapshot, targetSnapshot, includeUnchanged: true);
|
||||
var components = BuildComponentDiffs(baseSnapshot, targetSnapshot, includeUnchanged: true);
|
||||
var severityChanges = BuildSeverityChanges(vulnerabilities);
|
||||
var policyDiff = BuildPolicyDiff(baseSnapshot, targetSnapshot, vulnerabilities);
|
||||
|
||||
var riskScore =
|
||||
(severityChanges.CriticalAdded - severityChanges.CriticalRemoved) * 4 +
|
||||
(severityChanges.HighAdded - severityChanges.HighRemoved) * 3 +
|
||||
(severityChanges.MediumAdded - severityChanges.MediumRemoved) * 2 +
|
||||
(severityChanges.LowAdded - severityChanges.LowRemoved) +
|
||||
((policyDiff.ShipToBlockCount - policyDiff.BlockToShipCount) * 5);
|
||||
|
||||
return new DeltaCompareResponseDto
|
||||
{
|
||||
Digest = digest,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ComponentCount = 0,
|
||||
VulnerabilityCount = 0,
|
||||
SeverityCounts = new DeltaSeverityCountsDto(),
|
||||
PolicyVerdict = verdict
|
||||
Base = BuildSummary(baseSnapshot),
|
||||
Target = BuildSummary(targetSnapshot),
|
||||
Summary = new DeltaChangeSummaryDto
|
||||
{
|
||||
Added = vulnerabilities.Count(v => v.ChangeType.Equals("Added", StringComparison.Ordinal)),
|
||||
Removed = vulnerabilities.Count(v => v.ChangeType.Equals("Removed", StringComparison.Ordinal)),
|
||||
Modified = vulnerabilities.Count(v => v.ChangeType.Equals("Modified", StringComparison.Ordinal)),
|
||||
Unchanged = vulnerabilities.Count(v => v.ChangeType.Equals("Unchanged", StringComparison.Ordinal)),
|
||||
NetVulnerabilityChange = targetSnapshot.Vulnerabilities.Count - baseSnapshot.Vulnerabilities.Count,
|
||||
NetComponentChange = targetSnapshot.Components.Count - baseSnapshot.Components.Count,
|
||||
SeverityChanges = severityChanges,
|
||||
VerdictChanged = !string.Equals(baseSnapshot.PolicyVerdict, targetSnapshot.PolicyVerdict, StringComparison.OrdinalIgnoreCase),
|
||||
RiskDirection = riskScore > 0 ? "degraded" : riskScore < 0 ? "improved" : "unchanged"
|
||||
},
|
||||
Vulnerabilities = vulnerabilities,
|
||||
Components = components,
|
||||
PolicyDiff = policyDiff,
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
ComparisonId = comparisonId
|
||||
};
|
||||
}
|
||||
|
||||
private DeltaCompareResponseDto ProjectComparison(DeltaCompareResponseDto full, DeltaCompareRequestDto request)
|
||||
{
|
||||
var changeTypeFilter = request.ChangeTypes?
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var severityFilter = request.Severities?
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var filteredVulnerabilities = (full.Vulnerabilities ?? [])
|
||||
.Where(v => request.IncludeUnchanged || !v.ChangeType.Equals("Unchanged", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(v => changeTypeFilter is null || changeTypeFilter.Contains(v.ChangeType))
|
||||
.Where(v => severityFilter is null || severityFilter.Contains(EffectiveSeverity(v)))
|
||||
.OrderBy(v => ChangeTypeOrder(v.ChangeType))
|
||||
.ThenBy(v => v.VulnId, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Purl, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var filteredComponents = (full.Components ?? [])
|
||||
.Where(c => request.IncludeUnchanged || !c.ChangeType.Equals("Unchanged", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(c => ChangeTypeOrder(c.ChangeType))
|
||||
.ThenBy(c => c.Purl, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return full with
|
||||
{
|
||||
Summary = full.Summary with
|
||||
{
|
||||
Added = filteredVulnerabilities.Count(v => v.ChangeType.Equals("Added", StringComparison.OrdinalIgnoreCase)),
|
||||
Removed = filteredVulnerabilities.Count(v => v.ChangeType.Equals("Removed", StringComparison.OrdinalIgnoreCase)),
|
||||
Modified = filteredVulnerabilities.Count(v => v.ChangeType.Equals("Modified", StringComparison.OrdinalIgnoreCase)),
|
||||
Unchanged = filteredVulnerabilities.Count(v => v.ChangeType.Equals("Unchanged", StringComparison.OrdinalIgnoreCase)),
|
||||
SeverityChanges = BuildSeverityChanges(filteredVulnerabilities),
|
||||
},
|
||||
Vulnerabilities = request.IncludeVulnerabilities ? filteredVulnerabilities : null,
|
||||
Components = request.IncludeComponents ? filteredComponents : null,
|
||||
PolicyDiff = request.IncludePolicyDiff ? full.PolicyDiff : null
|
||||
};
|
||||
}
|
||||
|
||||
private Snapshot BuildSnapshot(string digest)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(digest));
|
||||
var createdAt = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
.AddSeconds(BitConverter.ToUInt32(hash.AsSpan(0, sizeof(uint))) % (365 * 24 * 60 * 60));
|
||||
var componentCount = 4 + (hash[1] % 3);
|
||||
var components = new Dictionary<string, SnapshotComponent>(StringComparer.Ordinal);
|
||||
|
||||
for (var i = 0; i < componentCount; i++)
|
||||
{
|
||||
var template = ComponentTemplates[(hash[(i * 5 + 7) % hash.Length] + i) % ComponentTemplates.Length];
|
||||
var version = $"{1 + (hash[(i * 3 + 9) % hash.Length] % 3)}.{hash[(i * 7 + 13) % hash.Length] % 10}.{hash[(i * 11 + 17) % hash.Length] % 20}";
|
||||
var purl = template.Ecosystem switch
|
||||
{
|
||||
"rpm" or "deb" => $"pkg:generic/{template.Name}@{version}",
|
||||
_ => $"pkg:{template.Ecosystem}/{template.Name}@{version}"
|
||||
};
|
||||
|
||||
components[purl] = new SnapshotComponent(purl, version, template.License);
|
||||
}
|
||||
|
||||
var vulnerabilities = new List<SnapshotVulnerability>();
|
||||
foreach (var (component, index) in components.Values.OrderBy(v => v.Purl, StringComparer.Ordinal).Select((value, idx) => (value, idx)))
|
||||
{
|
||||
var vulnerabilityCount = 1 + (hash[(index + 19) % hash.Length] % 2);
|
||||
for (var slot = 0; slot < vulnerabilityCount; slot++)
|
||||
{
|
||||
var cve = $"CVE-{2024 + (hash[(index + slot + 3) % hash.Length] % 3)}-{1000 + (((hash[(index * 3 + slot + 5) % hash.Length] << 8) + hash[(index * 3 + slot + 6) % hash.Length]) % 8000):D4}";
|
||||
var severity = OrderedSeverities[hash[(index * 3 + slot + 23) % hash.Length] % 4];
|
||||
var reachability = (hash[(index * 3 + slot + 29) % hash.Length] % 3) switch
|
||||
{
|
||||
0 => "reachable",
|
||||
1 => "likely",
|
||||
_ => "unreachable"
|
||||
};
|
||||
var verdict = severity is "critical" or "high" ? "Block" : severity == "medium" ? "Warn" : "Ship";
|
||||
vulnerabilities.Add(new SnapshotVulnerability(cve, component.Purl, severity, reachability, verdict, IncrementPatch(component.Version)));
|
||||
}
|
||||
}
|
||||
|
||||
var distinctVulnerabilities = vulnerabilities
|
||||
.DistinctBy(v => $"{v.VulnId}|{v.Purl}", StringComparer.Ordinal)
|
||||
.OrderBy(v => v.VulnId, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Purl, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var hasBlocking = distinctVulnerabilities.Any(v => v.Severity is "critical" or "high");
|
||||
var hasMedium = distinctVulnerabilities.Any(v => v.Severity == "medium");
|
||||
var policyVerdict = hasBlocking ? "Block" : hasMedium ? "Warn" : "Ship";
|
||||
|
||||
return new Snapshot(digest, createdAt, components.Values.OrderBy(v => v.Purl, StringComparer.Ordinal).ToList(), distinctVulnerabilities, policyVerdict);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DeltaVulnerabilityDto> BuildVulnerabilityDiffs(Snapshot baseline, Snapshot target, bool includeUnchanged)
|
||||
{
|
||||
var baseIndex = baseline.Vulnerabilities.ToDictionary(v => $"{v.VulnId}|{v.Purl}", StringComparer.Ordinal);
|
||||
var targetIndex = target.Vulnerabilities.ToDictionary(v => $"{v.VulnId}|{v.Purl}", StringComparer.Ordinal);
|
||||
var keys = baseIndex.Keys.Union(targetIndex.Keys, StringComparer.Ordinal).OrderBy(v => v, StringComparer.Ordinal);
|
||||
var results = new List<DeltaVulnerabilityDto>();
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
baseIndex.TryGetValue(key, out var before);
|
||||
targetIndex.TryGetValue(key, out var after);
|
||||
if (before is null && after is not null)
|
||||
{
|
||||
results.Add(new DeltaVulnerabilityDto
|
||||
{
|
||||
VulnId = after.VulnId,
|
||||
Purl = after.Purl,
|
||||
ChangeType = "Added",
|
||||
Severity = after.Severity,
|
||||
Reachability = after.Reachability,
|
||||
Verdict = after.Verdict,
|
||||
FixedVersion = after.FixedVersion
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (before is not null && after is null)
|
||||
{
|
||||
results.Add(new DeltaVulnerabilityDto
|
||||
{
|
||||
VulnId = before.VulnId,
|
||||
Purl = before.Purl,
|
||||
ChangeType = "Removed",
|
||||
Severity = "unknown",
|
||||
PreviousSeverity = before.Severity,
|
||||
PreviousReachability = before.Reachability,
|
||||
PreviousVerdict = before.Verdict,
|
||||
FixedVersion = before.FixedVersion
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (before is null || after is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fields = new List<DeltaFieldChangeDto>();
|
||||
AddFieldChange(fields, "severity", before.Severity, after.Severity);
|
||||
AddFieldChange(fields, "reachability", before.Reachability, after.Reachability);
|
||||
AddFieldChange(fields, "verdict", before.Verdict, after.Verdict);
|
||||
AddFieldChange(fields, "fixedVersion", before.FixedVersion, after.FixedVersion);
|
||||
|
||||
if (fields.Count == 0)
|
||||
{
|
||||
if (!includeUnchanged)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add(new DeltaVulnerabilityDto
|
||||
{
|
||||
VulnId = after.VulnId,
|
||||
Purl = after.Purl,
|
||||
ChangeType = "Unchanged",
|
||||
Severity = after.Severity,
|
||||
Reachability = after.Reachability,
|
||||
Verdict = after.Verdict,
|
||||
FixedVersion = after.FixedVersion
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add(new DeltaVulnerabilityDto
|
||||
{
|
||||
VulnId = after.VulnId,
|
||||
Purl = after.Purl,
|
||||
ChangeType = "Modified",
|
||||
Severity = after.Severity,
|
||||
PreviousSeverity = before.Severity,
|
||||
Reachability = after.Reachability,
|
||||
PreviousReachability = before.Reachability,
|
||||
Verdict = after.Verdict,
|
||||
PreviousVerdict = before.Verdict,
|
||||
FixedVersion = after.FixedVersion,
|
||||
FieldChanges = fields
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DeltaComponentDto> BuildComponentDiffs(Snapshot baseline, Snapshot target, bool includeUnchanged)
|
||||
{
|
||||
var baseIndex = baseline.Components.ToDictionary(v => v.Purl, StringComparer.Ordinal);
|
||||
var targetIndex = target.Components.ToDictionary(v => v.Purl, StringComparer.Ordinal);
|
||||
var baseVulnCount = baseline.Vulnerabilities.GroupBy(v => v.Purl, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
||||
var targetVulnCount = target.Vulnerabilities.GroupBy(v => v.Purl, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
||||
var keys = baseIndex.Keys.Union(targetIndex.Keys, StringComparer.Ordinal).OrderBy(v => v, StringComparer.Ordinal);
|
||||
var results = new List<DeltaComponentDto>();
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
baseIndex.TryGetValue(key, out var before);
|
||||
targetIndex.TryGetValue(key, out var after);
|
||||
var beforeVuln = baseVulnCount.TryGetValue(key, out var bc) ? bc : 0;
|
||||
var afterVuln = targetVulnCount.TryGetValue(key, out var ac) ? ac : 0;
|
||||
if (before is null && after is not null)
|
||||
{
|
||||
results.Add(new DeltaComponentDto
|
||||
{
|
||||
Purl = key,
|
||||
ChangeType = "Added",
|
||||
CurrentVersion = after.Version,
|
||||
VulnerabilitiesInBase = beforeVuln,
|
||||
VulnerabilitiesInTarget = afterVuln,
|
||||
License = after.License
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (before is not null && after is null)
|
||||
{
|
||||
results.Add(new DeltaComponentDto
|
||||
{
|
||||
Purl = key,
|
||||
ChangeType = "Removed",
|
||||
PreviousVersion = before.Version,
|
||||
VulnerabilitiesInBase = beforeVuln,
|
||||
VulnerabilitiesInTarget = afterVuln,
|
||||
License = before.License
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (before is null || after is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(before.Version, after.Version, StringComparison.Ordinal))
|
||||
{
|
||||
results.Add(new DeltaComponentDto
|
||||
{
|
||||
Purl = key,
|
||||
ChangeType = "VersionChanged",
|
||||
PreviousVersion = before.Version,
|
||||
CurrentVersion = after.Version,
|
||||
VulnerabilitiesInBase = beforeVuln,
|
||||
VulnerabilitiesInTarget = afterVuln,
|
||||
License = after.License
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!includeUnchanged)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add(new DeltaComponentDto
|
||||
{
|
||||
Purl = key,
|
||||
ChangeType = "Unchanged",
|
||||
PreviousVersion = before.Version,
|
||||
CurrentVersion = after.Version,
|
||||
VulnerabilitiesInBase = beforeVuln,
|
||||
VulnerabilitiesInTarget = afterVuln,
|
||||
License = after.License
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static DeltaPolicyDiffDto BuildPolicyDiff(Snapshot baseline, Snapshot target, IReadOnlyList<DeltaVulnerabilityDto> vulnerabilities)
|
||||
{
|
||||
return new DeltaPolicyDiffDto
|
||||
{
|
||||
BaseVerdict = baseline.PolicyVerdict,
|
||||
TargetVerdict = target.PolicyVerdict,
|
||||
VerdictChanged = !string.Equals(baseline.PolicyVerdict, target.PolicyVerdict, StringComparison.OrdinalIgnoreCase),
|
||||
BlockToShipCount = vulnerabilities.Count(v => string.Equals(v.PreviousVerdict, "Block", StringComparison.OrdinalIgnoreCase) && string.Equals(v.Verdict, "Ship", StringComparison.OrdinalIgnoreCase)),
|
||||
ShipToBlockCount = vulnerabilities.Count(v => string.Equals(v.PreviousVerdict, "Ship", StringComparison.OrdinalIgnoreCase) && string.Equals(v.Verdict, "Block", StringComparison.OrdinalIgnoreCase)),
|
||||
WouldPassIf = vulnerabilities
|
||||
.Where(v => string.Equals(v.Verdict, "Block", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(v => $"Mitigate {v.VulnId} in {v.Purl}")
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.Take(3)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static DeltaSeverityChangesDto BuildSeverityChanges(IReadOnlyList<DeltaVulnerabilityDto> vulnerabilities)
|
||||
{
|
||||
var criticalAdded = 0;
|
||||
var criticalRemoved = 0;
|
||||
var highAdded = 0;
|
||||
var highRemoved = 0;
|
||||
var mediumAdded = 0;
|
||||
var mediumRemoved = 0;
|
||||
var lowAdded = 0;
|
||||
var lowRemoved = 0;
|
||||
|
||||
foreach (var vulnerability in vulnerabilities)
|
||||
{
|
||||
var current = NormalizeSeverity(vulnerability.Severity);
|
||||
var previous = NormalizeSeverity(vulnerability.PreviousSeverity);
|
||||
|
||||
if (vulnerability.ChangeType.Equals("Added", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Increment(current, isAdded: true);
|
||||
}
|
||||
else if (vulnerability.ChangeType.Equals("Removed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Increment(previous, isAdded: false);
|
||||
}
|
||||
else if (vulnerability.ChangeType.Equals("Modified", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(current, previous, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Increment(previous, isAdded: false);
|
||||
Increment(current, isAdded: true);
|
||||
}
|
||||
}
|
||||
|
||||
return new DeltaSeverityChangesDto
|
||||
{
|
||||
CriticalAdded = criticalAdded,
|
||||
CriticalRemoved = criticalRemoved,
|
||||
HighAdded = highAdded,
|
||||
HighRemoved = highRemoved,
|
||||
MediumAdded = mediumAdded,
|
||||
MediumRemoved = mediumRemoved,
|
||||
LowAdded = lowAdded,
|
||||
LowRemoved = lowRemoved
|
||||
};
|
||||
|
||||
void Increment(string severity, bool isAdded)
|
||||
{
|
||||
switch (severity)
|
||||
{
|
||||
case "critical":
|
||||
if (isAdded) criticalAdded++; else criticalRemoved++;
|
||||
break;
|
||||
case "high":
|
||||
if (isAdded) highAdded++; else highRemoved++;
|
||||
break;
|
||||
case "medium":
|
||||
if (isAdded) mediumAdded++; else mediumRemoved++;
|
||||
break;
|
||||
case "low":
|
||||
if (isAdded) lowAdded++; else lowRemoved++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DeltaSnapshotSummaryDto BuildSummary(Snapshot snapshot) => new()
|
||||
{
|
||||
Digest = snapshot.Digest,
|
||||
CreatedAt = snapshot.CreatedAt,
|
||||
ComponentCount = snapshot.Components.Count,
|
||||
VulnerabilityCount = snapshot.Vulnerabilities.Count,
|
||||
SeverityCounts = new DeltaSeverityCountsDto
|
||||
{
|
||||
Critical = snapshot.Vulnerabilities.Count(v => v.Severity == "critical"),
|
||||
High = snapshot.Vulnerabilities.Count(v => v.Severity == "high"),
|
||||
Medium = snapshot.Vulnerabilities.Count(v => v.Severity == "medium"),
|
||||
Low = snapshot.Vulnerabilities.Count(v => v.Severity == "low"),
|
||||
Unknown = snapshot.Vulnerabilities.Count(v => v.Severity == "unknown")
|
||||
},
|
||||
PolicyVerdict = snapshot.PolicyVerdict
|
||||
};
|
||||
|
||||
private static void AddFieldChange(List<DeltaFieldChangeDto> changes, string field, string oldValue, string newValue)
|
||||
{
|
||||
if (string.Equals(oldValue, newValue, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
changes.Add(new DeltaFieldChangeDto
|
||||
{
|
||||
Field = field,
|
||||
PreviousValue = oldValue,
|
||||
CurrentValue = newValue
|
||||
});
|
||||
}
|
||||
|
||||
private static string EffectiveSeverity(DeltaVulnerabilityDto vulnerability)
|
||||
=> vulnerability.ChangeType.Equals("Removed", StringComparison.OrdinalIgnoreCase)
|
||||
? NormalizeSeverity(vulnerability.PreviousSeverity)
|
||||
: NormalizeSeverity(vulnerability.Severity);
|
||||
|
||||
private static int ChangeTypeOrder(string value) => value switch
|
||||
{
|
||||
"Added" => 0,
|
||||
"Removed" => 1,
|
||||
"Modified" => 2,
|
||||
"VersionChanged" => 3,
|
||||
"Unchanged" => 4,
|
||||
_ => 5
|
||||
};
|
||||
|
||||
private static string NormalizeSeverity(string? severity)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
var normalized = severity.Trim().ToLowerInvariant();
|
||||
return OrderedSeverities.Contains(normalized, StringComparer.Ordinal) ? normalized : "unknown";
|
||||
}
|
||||
|
||||
private static string IncrementPatch(string version)
|
||||
{
|
||||
var parts = version.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 3
|
||||
|| !int.TryParse(parts[0], out var major)
|
||||
|| !int.TryParse(parts[1], out var minor)
|
||||
|| !int.TryParse(parts[2], out var patch))
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
return $"{major}.{minor}.{patch + 1}";
|
||||
}
|
||||
|
||||
private static string ComputeComparisonId(string baseDigest, string targetDigest)
|
||||
{
|
||||
var input = $"{baseDigest}|{targetDigest}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"cmp-{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private sealed record Snapshot(
|
||||
string Digest,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<SnapshotComponent> Components,
|
||||
IReadOnlyList<SnapshotVulnerability> Vulnerabilities,
|
||||
string PolicyVerdict);
|
||||
|
||||
private sealed record SnapshotComponent(string Purl, string Version, string License);
|
||||
private sealed record SnapshotVulnerability(string VulnId, string Purl, string Severity, string Reachability, string Verdict, string FixedVersion);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Reachability.Stack;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
@@ -55,7 +56,7 @@ internal static class ReachabilityStackEndpoints
|
||||
|
||||
private static async Task<IResult> HandleGetStackAsync(
|
||||
string findingId,
|
||||
IReachabilityStackRepository? stackRepository,
|
||||
[FromServices] IReachabilityStackRepository? stackRepository,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -100,7 +101,7 @@ internal static class ReachabilityStackEndpoints
|
||||
private static async Task<IResult> HandleGetLayerAsync(
|
||||
string findingId,
|
||||
int layerNumber,
|
||||
IReachabilityStackRepository? stackRepository,
|
||||
[FromServices] IReachabilityStackRepository? stackRepository,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -78,8 +78,8 @@ internal static class SbomHotLookupEndpoints
|
||||
string? purl,
|
||||
string? name,
|
||||
string? minVersion,
|
||||
int limit,
|
||||
int offset,
|
||||
int? limit,
|
||||
int? offset,
|
||||
ISbomHotLookupService hotLookupService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -109,7 +109,10 @@ internal static class SbomHotLookupEndpoints
|
||||
detail: "Use either 'purl' or 'name', not both.");
|
||||
}
|
||||
|
||||
if (!SbomHotLookupService.IsLimitValid(limit))
|
||||
var requestedLimit = limit ?? 0;
|
||||
var requestedOffset = offset ?? 0;
|
||||
|
||||
if (!SbomHotLookupService.IsLimitValid(requestedLimit))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
@@ -119,7 +122,7 @@ internal static class SbomHotLookupEndpoints
|
||||
detail: "limit must be between 1 and 200.");
|
||||
}
|
||||
|
||||
if (!SbomHotLookupService.IsOffsetValid(offset))
|
||||
if (!SbomHotLookupService.IsOffsetValid(requestedOffset))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
@@ -130,22 +133,31 @@ internal static class SbomHotLookupEndpoints
|
||||
}
|
||||
|
||||
var result = await hotLookupService
|
||||
.SearchComponentsAsync(purl, name, minVersion, limit, offset, cancellationToken)
|
||||
.SearchComponentsAsync(
|
||||
purl,
|
||||
name,
|
||||
minVersion,
|
||||
requestedLimit,
|
||||
requestedOffset,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleSearchPendingTriageAsync(
|
||||
int limit,
|
||||
int offset,
|
||||
int? limit,
|
||||
int? offset,
|
||||
ISbomHotLookupService hotLookupService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hotLookupService);
|
||||
|
||||
if (!SbomHotLookupService.IsLimitValid(limit))
|
||||
var requestedLimit = limit ?? 0;
|
||||
var requestedOffset = offset ?? 0;
|
||||
|
||||
if (!SbomHotLookupService.IsLimitValid(requestedLimit))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
@@ -155,7 +167,7 @@ internal static class SbomHotLookupEndpoints
|
||||
detail: "limit must be between 1 and 200.");
|
||||
}
|
||||
|
||||
if (!SbomHotLookupService.IsOffsetValid(offset))
|
||||
if (!SbomHotLookupService.IsOffsetValid(requestedOffset))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
@@ -166,7 +178,10 @@ internal static class SbomHotLookupEndpoints
|
||||
}
|
||||
|
||||
var result = await hotLookupService
|
||||
.SearchPendingTriageAsync(limit, offset, cancellationToken)
|
||||
.SearchPendingTriageAsync(
|
||||
requestedLimit,
|
||||
requestedOffset,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(result);
|
||||
|
||||
@@ -20,10 +20,42 @@ internal static class ScoreReplayEndpoints
|
||||
{
|
||||
public static void MapScoreReplayEndpoints(this RouteGroupBuilder apiGroup)
|
||||
{
|
||||
var score = apiGroup.MapGroup("/score")
|
||||
var legacy = apiGroup.MapGroup("/score")
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
var scans = apiGroup.MapGroup("/scans/{scanId}/score")
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
score.MapPost("/{scanId}/replay", HandleReplayAsync)
|
||||
scans.MapPost("/replay", HandleReplayAsync)
|
||||
.WithName("scanner.scans.score.replay")
|
||||
.Produces<ScoreReplayResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)
|
||||
.WithDescription(_t("scanner.score_replay.replay_description"))
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
scans.MapGet("/bundle", HandleGetBundleAsync)
|
||||
.WithName("scanner.scans.score.bundle")
|
||||
.Produces<ScoreBundleResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.WithDescription(_t("scanner.score_replay.bundle_description"));
|
||||
|
||||
scans.MapPost("/verify", HandleVerifyAsync)
|
||||
.WithName("scanner.scans.score.verify")
|
||||
.Produces<ScoreVerifyResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)
|
||||
.WithDescription(_t("scanner.score_replay.verify_description"))
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
scans.MapGet("/history", HandleGetHistoryAsync)
|
||||
.WithName("scanner.scans.score.history")
|
||||
.Produces<IReadOnlyList<ScoreHistoryResponseItem>>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.WithDescription(_t("scanner.score_replay.history_description"));
|
||||
|
||||
// Backward-compatible aliases (/score/{scanId}/...) retained while clients migrate.
|
||||
legacy.MapPost("/{scanId}/replay", HandleReplayAsync)
|
||||
.WithName("scanner.score.replay")
|
||||
.Produces<ScoreReplayResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
@@ -32,19 +64,25 @@ internal static class ScoreReplayEndpoints
|
||||
.WithDescription(_t("scanner.score_replay.replay_description"))
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
score.MapGet("/{scanId}/bundle", HandleGetBundleAsync)
|
||||
legacy.MapGet("/{scanId}/bundle", HandleGetBundleAsync)
|
||||
.WithName("scanner.score.bundle")
|
||||
.Produces<ScoreBundleResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.WithDescription(_t("scanner.score_replay.bundle_description"));
|
||||
|
||||
score.MapPost("/{scanId}/verify", HandleVerifyAsync)
|
||||
legacy.MapPost("/{scanId}/verify", HandleVerifyAsync)
|
||||
.WithName("scanner.score.verify")
|
||||
.Produces<ScoreVerifyResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)
|
||||
.WithDescription(_t("scanner.score_replay.verify_description"))
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
legacy.MapGet("/{scanId}/history", HandleGetHistoryAsync)
|
||||
.WithName("scanner.score.history")
|
||||
.Produces<IReadOnlyList<ScoreHistoryResponseItem>>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.WithDescription(_t("scanner.score_replay.history_description"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -91,6 +129,17 @@ internal static class ScoreReplayEndpoints
|
||||
RootHash: result.RootHash,
|
||||
BundleUri: result.BundleUri,
|
||||
ManifestHash: result.ManifestHash,
|
||||
ManifestDigest: result.ManifestDigest,
|
||||
CanonicalInputHash: result.CanonicalInputHash,
|
||||
CanonicalInputPayload: result.CanonicalInputPayload,
|
||||
SeedHex: result.SeedHex,
|
||||
Factors: result.Factors.Select(f => new ScoreReplayFactor(
|
||||
f.Name,
|
||||
f.Weight,
|
||||
f.Raw,
|
||||
f.Weighted,
|
||||
f.Source)).ToArray(),
|
||||
VerificationStatus: result.VerificationStatus,
|
||||
ReplayedAt: result.ReplayedAt,
|
||||
Deterministic: result.Deterministic));
|
||||
}
|
||||
@@ -200,6 +249,8 @@ internal static class ScoreReplayEndpoints
|
||||
scanId,
|
||||
request.ExpectedRootHash,
|
||||
request.BundleUri,
|
||||
request.ExpectedCanonicalInputHash,
|
||||
request.CanonicalInputPayload,
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new ScoreVerifyResponse(
|
||||
@@ -208,6 +259,9 @@ internal static class ScoreReplayEndpoints
|
||||
ExpectedRootHash: request.ExpectedRootHash,
|
||||
ManifestValid: result.ManifestValid,
|
||||
LedgerValid: result.LedgerValid,
|
||||
CanonicalInputHashValid: result.CanonicalInputHashValid,
|
||||
ExpectedCanonicalInputHash: result.ExpectedCanonicalInputHash,
|
||||
CanonicalInputHash: result.CanonicalInputHash,
|
||||
VerifiedAtUtc: result.VerifiedAt,
|
||||
ErrorMessage: result.ErrorMessage));
|
||||
}
|
||||
@@ -221,6 +275,41 @@ internal static class ScoreReplayEndpoints
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /scans/{scanId}/score/history
|
||||
/// Returns deterministic replay history for explainability timelines.
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleGetHistoryAsync(
|
||||
string scanId,
|
||||
IScoreReplayService replayService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = _t("scanner.scan.invalid_identifier"),
|
||||
Detail = _t("scanner.scan.identifier_required"),
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var history = await replayService.GetScoreHistoryAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||
var response = history
|
||||
.Select(entry => new ScoreHistoryResponseItem(
|
||||
RootHash: entry.RootHash,
|
||||
ReplayedAt: entry.ReplayedAt,
|
||||
Score: entry.Score,
|
||||
CanonicalInputHash: entry.CanonicalInputHash,
|
||||
ManifestDigest: entry.ManifestDigest,
|
||||
Factors: entry.Factors
|
||||
.Select(f => new ScoreReplayFactor(f.Name, f.Weight, f.Raw, f.Weighted, f.Source))
|
||||
.ToArray()))
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -239,6 +328,12 @@ public sealed record ScoreReplayRequest(
|
||||
/// <param name="RootHash">Root hash of the proof ledger.</param>
|
||||
/// <param name="BundleUri">URI to the proof bundle.</param>
|
||||
/// <param name="ManifestHash">Hash of the manifest used.</param>
|
||||
/// <param name="ManifestDigest">Digest of canonical manifest input.</param>
|
||||
/// <param name="CanonicalInputHash">Digest of canonical score replay input payload.</param>
|
||||
/// <param name="CanonicalInputPayload">Canonical score replay input payload JSON.</param>
|
||||
/// <param name="SeedHex">Replay seed as hexadecimal.</param>
|
||||
/// <param name="Factors">Factorized score vectors.</param>
|
||||
/// <param name="VerificationStatus">Verification status for replay artifacts.</param>
|
||||
/// <param name="ReplayedAt">When the replay was performed.</param>
|
||||
/// <param name="Deterministic">Whether the replay was deterministic.</param>
|
||||
public sealed record ScoreReplayResponse(
|
||||
@@ -246,9 +341,25 @@ public sealed record ScoreReplayResponse(
|
||||
string RootHash,
|
||||
string BundleUri,
|
||||
string ManifestHash,
|
||||
string ManifestDigest,
|
||||
string CanonicalInputHash,
|
||||
string CanonicalInputPayload,
|
||||
string SeedHex,
|
||||
IReadOnlyList<ScoreReplayFactor> Factors,
|
||||
string VerificationStatus,
|
||||
DateTimeOffset ReplayedAt,
|
||||
bool Deterministic);
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic score factor returned by replay and history APIs.
|
||||
/// </summary>
|
||||
public sealed record ScoreReplayFactor(
|
||||
string Name,
|
||||
double Weight,
|
||||
double Raw,
|
||||
double Weighted,
|
||||
string Source);
|
||||
|
||||
/// <summary>
|
||||
/// Response for bundle retrieval.
|
||||
/// </summary>
|
||||
@@ -266,7 +377,9 @@ public sealed record ScoreBundleResponse(
|
||||
/// <param name="BundleUri">Optional: specific bundle URI to verify.</param>
|
||||
public sealed record ScoreVerifyRequest(
|
||||
string ExpectedRootHash,
|
||||
string? BundleUri = null);
|
||||
string? BundleUri = null,
|
||||
string? ExpectedCanonicalInputHash = null,
|
||||
string? CanonicalInputPayload = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response from bundle verification.
|
||||
@@ -276,6 +389,9 @@ public sealed record ScoreVerifyRequest(
|
||||
/// <param name="ExpectedRootHash">The expected root hash.</param>
|
||||
/// <param name="ManifestValid">Whether the manifest signature is valid.</param>
|
||||
/// <param name="LedgerValid">Whether the ledger integrity is valid.</param>
|
||||
/// <param name="CanonicalInputHashValid">Whether canonical input hash checks passed.</param>
|
||||
/// <param name="ExpectedCanonicalInputHash">Expected canonical hash when provided.</param>
|
||||
/// <param name="CanonicalInputHash">Resolved canonical hash used in verification.</param>
|
||||
/// <param name="VerifiedAtUtc">When verification was performed.</param>
|
||||
/// <param name="ErrorMessage">Error message if verification failed.</param>
|
||||
public sealed record ScoreVerifyResponse(
|
||||
@@ -284,5 +400,19 @@ public sealed record ScoreVerifyResponse(
|
||||
string ExpectedRootHash,
|
||||
bool ManifestValid,
|
||||
bool LedgerValid,
|
||||
bool CanonicalInputHashValid,
|
||||
string? ExpectedCanonicalInputHash,
|
||||
string? CanonicalInputHash,
|
||||
DateTimeOffset VerifiedAtUtc,
|
||||
string? ErrorMessage = null);
|
||||
|
||||
/// <summary>
|
||||
/// Score replay history response item.
|
||||
/// </summary>
|
||||
public sealed record ScoreHistoryResponseItem(
|
||||
string RootHash,
|
||||
DateTimeOffset ReplayedAt,
|
||||
double Score,
|
||||
string CanonicalInputHash,
|
||||
string ManifestDigest,
|
||||
IReadOnlyList<ScoreReplayFactor> Factors);
|
||||
|
||||
@@ -33,11 +33,13 @@ using StellaOps.Scanner.Core.TrustAnchors;
|
||||
using StellaOps.Scanner.Emit.Composition;
|
||||
using StellaOps.Scanner.Gate;
|
||||
using StellaOps.Scanner.ReachabilityDrift.DependencyInjection;
|
||||
using StellaOps.Scanner.Reachability.Slices;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
using StellaOps.Scanner.Sources.DependencyInjection;
|
||||
using StellaOps.Scanner.Sources.Persistence;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Extensions;
|
||||
using StellaOps.Scanner.Storage.Oci;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
@@ -135,6 +137,14 @@ else
|
||||
}
|
||||
builder.Services.AddDeterminismDefaults();
|
||||
builder.Services.AddScannerCache(builder.Configuration);
|
||||
builder.Services.AddOptions<SliceCacheOptions>()
|
||||
.Bind(builder.Configuration.GetSection("scanner:slices:cache"));
|
||||
builder.Services.AddOptions<SliceQueryServiceOptions>()
|
||||
.Bind(builder.Configuration.GetSection("scanner:slices:query"));
|
||||
builder.Services.AddOptions<ReplayCommandServiceOptions>()
|
||||
.Bind(builder.Configuration.GetSection("scanner:replayCommands"));
|
||||
builder.Services.AddOptions<ReachabilityStackRepositoryOptions>()
|
||||
.Bind(builder.Configuration.GetSection("scanner:reachabilityStack"));
|
||||
builder.Services.AddSingleton<ServiceStatus>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSingleton<ScanProgressStream>();
|
||||
@@ -191,6 +201,24 @@ builder.Services.TryAddSingleton<IVexReachabilityDecisionFilter, VexReachability
|
||||
builder.Services.TryAddSingleton<IMaterialRiskChangeRepository, PostgresMaterialRiskChangeRepository>();
|
||||
builder.Services.TryAddSingleton<IVexCandidateStore, PostgresVexCandidateStore>();
|
||||
builder.Services.TryAddSingleton<IScanMetadataRepository, InMemoryScanMetadataRepository>();
|
||||
builder.Services.TryAddSingleton<ISliceCache, SliceCache>();
|
||||
builder.Services.TryAddSingleton<VerdictComputer>();
|
||||
builder.Services.TryAddSingleton<SliceExtractor>();
|
||||
builder.Services.TryAddSingleton<SliceHasher>();
|
||||
builder.Services.TryAddSingleton<StellaOps.Scanner.Reachability.Slices.Replay.SliceDiffComputer>();
|
||||
builder.Services.TryAddSingleton<SliceDsseSigner>();
|
||||
builder.Services.TryAddSingleton<SliceCasStorage>();
|
||||
builder.Services.TryAddScoped<ISliceQueryService, SliceQueryService>();
|
||||
builder.Services.TryAddScoped<IReplayCommandService, ReplayCommandService>();
|
||||
|
||||
var reachabilityStackRepositoryOptions = builder.Configuration
|
||||
.GetSection("scanner:reachabilityStack")
|
||||
.Get<ReachabilityStackRepositoryOptions>() ?? new ReachabilityStackRepositoryOptions();
|
||||
|
||||
if (reachabilityStackRepositoryOptions.Enabled)
|
||||
{
|
||||
builder.Services.TryAddSingleton<IReachabilityStackRepository, FileBackedReachabilityStackRepository>();
|
||||
}
|
||||
|
||||
// Secret Detection Settings (Sprint: SPRINT_20260104_006_BE)
|
||||
builder.Services.AddScoped<ISecretDetectionSettingsService, SecretDetectionSettingsService>();
|
||||
@@ -270,6 +298,68 @@ else
|
||||
builder.Services.AddSingleton<IPlatformEventPublisher, NullPlatformEventPublisher>();
|
||||
}
|
||||
builder.Services.AddSingleton<IReportEventDispatcher, ReportEventDispatcher>();
|
||||
builder.Services.AddHttpClient("ScannerOciAttestationPublisher")
|
||||
.ConfigurePrimaryHttpMessageHandler(() =>
|
||||
{
|
||||
if (!bootstrapOptions.ArtifactStore.AllowInsecureTls)
|
||||
{
|
||||
return new HttpClientHandler();
|
||||
}
|
||||
|
||||
return new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback =
|
||||
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||
};
|
||||
});
|
||||
builder.Services.TryAddSingleton(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value;
|
||||
var defaultRegistry = string.IsNullOrWhiteSpace(options.Registry.DefaultRegistry)
|
||||
? "docker.io"
|
||||
: options.Registry.DefaultRegistry!.Trim();
|
||||
|
||||
var authOptions = new OciRegistryAuthOptions();
|
||||
var credential = options.Registry.Credentials
|
||||
.FirstOrDefault(c => string.Equals(c.Registry?.Trim(), defaultRegistry, StringComparison.OrdinalIgnoreCase))
|
||||
?? options.Registry.Credentials.FirstOrDefault();
|
||||
|
||||
if (credential is not null)
|
||||
{
|
||||
authOptions.Username = credential.Username;
|
||||
authOptions.Password = credential.Password;
|
||||
authOptions.Token = credential.RegistryToken ?? credential.IdentityToken;
|
||||
authOptions.AllowAnonymousFallback = string.IsNullOrWhiteSpace(authOptions.Username)
|
||||
&& string.IsNullOrWhiteSpace(authOptions.Token);
|
||||
}
|
||||
|
||||
var registryOptions = new OciRegistryOptions
|
||||
{
|
||||
DefaultRegistry = defaultRegistry,
|
||||
AllowInsecure = bootstrapOptions.ArtifactStore.AllowInsecureTls,
|
||||
Auth = authOptions
|
||||
};
|
||||
|
||||
var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient("ScannerOciAttestationPublisher");
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(Math.Max(1, options.AttestationAttachment.RegistryTimeoutSeconds));
|
||||
|
||||
return new OciArtifactPusher(
|
||||
httpClient,
|
||||
sp.GetRequiredService<StellaOps.Cryptography.ICryptoHash>(),
|
||||
registryOptions,
|
||||
sp.GetRequiredService<ILogger<OciArtifactPusher>>(),
|
||||
sp.GetService<TimeProvider>());
|
||||
});
|
||||
builder.Services.TryAddSingleton<IOciAttestationPublisher>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value;
|
||||
if (!options.AttestationAttachment.AutoAttach)
|
||||
{
|
||||
return NullOciAttestationPublisher.Instance;
|
||||
}
|
||||
|
||||
return ActivatorUtilities.CreateInstance<OciAttestationPublisher>(sp);
|
||||
});
|
||||
builder.Services.AddScannerStorage(storageOptions =>
|
||||
{
|
||||
storageOptions.Postgres.ConnectionString = bootstrapOptions.Storage.Dsn;
|
||||
@@ -718,6 +808,7 @@ if (resolvedOptions.Features.EnablePolicyPreview)
|
||||
|
||||
apiGroup.MapReportEndpoints(resolvedOptions.Api.ReportsSegment);
|
||||
apiGroup.MapRuntimeEndpoints(resolvedOptions.Api.RuntimeSegment);
|
||||
apiGroup.MapReachabilityStackEndpoints();
|
||||
|
||||
app.MapControllers();
|
||||
app.MapOpenApiIfAvailable();
|
||||
|
||||
@@ -12,7 +12,7 @@ using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Serialization;
|
||||
|
||||
internal static class OrchestratorEventSerializer
|
||||
internal static class JobEngineEventSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = CreateOptions();
|
||||
private static readonly JsonSerializerOptions PrettyOptions = new()
|
||||
@@ -21,10 +21,10 @@ internal static class OrchestratorEventSerializer
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
public static string Serialize(OrchestratorEvent @event)
|
||||
public static string Serialize(JobEngineEvent @event)
|
||||
=> Encoding.UTF8.GetString(CanonJson.Canonicalize(@event, CanonicalOptions));
|
||||
|
||||
public static string SerializeIndented(OrchestratorEvent @event)
|
||||
public static string SerializeIndented(JobEngineEvent @event)
|
||||
{
|
||||
var canonicalBytes = CanonJson.Canonicalize(@event, CanonicalOptions);
|
||||
using var document = JsonDocument.Parse(canonicalBytes);
|
||||
@@ -48,7 +48,7 @@ internal static class OrchestratorEventSerializer
|
||||
{
|
||||
private static readonly ImmutableDictionary<Type, string[]> PropertyOrder = new Dictionary<Type, string[]>
|
||||
{
|
||||
[typeof(OrchestratorEvent)] = new[]
|
||||
[typeof(JobEngineEvent)] = new[]
|
||||
{
|
||||
"eventId",
|
||||
"kind",
|
||||
@@ -65,7 +65,7 @@ internal static class OrchestratorEventSerializer
|
||||
"payload",
|
||||
"attributes"
|
||||
},
|
||||
[typeof(OrchestratorEventScope)] = new[]
|
||||
[typeof(JobEngineEventScope)] = new[]
|
||||
{
|
||||
"namespace",
|
||||
"repo",
|
||||
@@ -212,7 +212,7 @@ internal static class OrchestratorEventSerializer
|
||||
|
||||
private static void ConfigurePolymorphism(JsonTypeInfo info)
|
||||
{
|
||||
if (info.Type != typeof(OrchestratorEventPayload))
|
||||
if (info.Type != typeof(JobEngineEventPayload))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -4,12 +4,18 @@ using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public sealed class DeterministicScoringService : IScoringService
|
||||
{
|
||||
public Task<double> ReplayScoreAsync(
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public Task<DeterministicScoreResult> ReplayScoreAsync(
|
||||
string scanId,
|
||||
string concelierSnapshotHash,
|
||||
string excititorSnapshotHash,
|
||||
@@ -24,18 +30,32 @@ public sealed class DeterministicScoringService : IScoringService
|
||||
ArgumentNullException.ThrowIfNull(ledger);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var input = string.Join(
|
||||
"|",
|
||||
scanId.Trim(),
|
||||
concelierSnapshotHash?.Trim() ?? string.Empty,
|
||||
excititorSnapshotHash?.Trim() ?? string.Empty,
|
||||
latticePolicyHash?.Trim() ?? string.Empty,
|
||||
freezeTimestamp.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
|
||||
Convert.ToHexStringLower(seed));
|
||||
var normalizedScanId = scanId.Trim();
|
||||
var normalizedConcelier = (concelierSnapshotHash ?? string.Empty).Trim();
|
||||
var normalizedExcititor = (excititorSnapshotHash ?? string.Empty).Trim();
|
||||
var normalizedPolicy = (latticePolicyHash ?? string.Empty).Trim();
|
||||
var seedHex = Convert.ToHexStringLower(seed);
|
||||
var freezeTimestampIso = freezeTimestamp.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
var value = BinaryPrimitives.ReadUInt64BigEndian(digest.AsSpan(0, sizeof(ulong)));
|
||||
var score = value / (double)ulong.MaxValue;
|
||||
var canonicalInput = new CanonicalScoreInput(
|
||||
normalizedScanId,
|
||||
normalizedConcelier,
|
||||
normalizedExcititor,
|
||||
normalizedPolicy,
|
||||
freezeTimestampIso,
|
||||
seedHex);
|
||||
var canonicalPayload = JsonSerializer.Serialize(canonicalInput, JsonOptions);
|
||||
var canonicalInputHash = $"sha256:{Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonicalPayload)))}";
|
||||
|
||||
var factors = new List<DeterministicScoreFactor>
|
||||
{
|
||||
BuildFactor("cvss", 0.35, normalizedConcelier, 0, "concelier"),
|
||||
BuildFactor("epss", 0.20, normalizedExcititor, 4, "excititor"),
|
||||
BuildFactor("reachability", 0.25, $"{normalizedScanId}|{normalizedPolicy}", 8, "policy"),
|
||||
BuildFactor("provenance", 0.20, $"{normalizedPolicy}|{seedHex}", 12, "manifest")
|
||||
};
|
||||
|
||||
var score = Math.Round(factors.Sum(f => f.Weighted), 6, MidpointRounding.ToEven);
|
||||
score = Math.Clamp(score, 0.0, 1.0);
|
||||
|
||||
var actor = "scanner.webservice.score";
|
||||
@@ -43,13 +63,14 @@ public sealed class DeterministicScoringService : IScoringService
|
||||
{
|
||||
concelierSnapshotHash,
|
||||
excititorSnapshotHash,
|
||||
latticePolicyHash
|
||||
latticePolicyHash,
|
||||
canonicalInputHash
|
||||
}.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).ToArray();
|
||||
|
||||
var inputNodeId = $"input:{scanId}";
|
||||
var inputNodeId = $"input:{normalizedScanId}";
|
||||
ledger.Append(ProofNode.CreateInput(
|
||||
id: inputNodeId,
|
||||
ruleId: "deterministic",
|
||||
ruleId: "deterministic-v2",
|
||||
actor: actor,
|
||||
tsUtc: freezeTimestamp,
|
||||
seed: seed,
|
||||
@@ -57,15 +78,44 @@ public sealed class DeterministicScoringService : IScoringService
|
||||
evidenceRefs: evidenceRefs));
|
||||
|
||||
ledger.Append(ProofNode.CreateScore(
|
||||
id: $"score:{scanId}",
|
||||
ruleId: "deterministic",
|
||||
id: $"score:{normalizedScanId}",
|
||||
ruleId: "deterministic-v2",
|
||||
actor: actor,
|
||||
tsUtc: freezeTimestamp,
|
||||
seed: seed,
|
||||
finalScore: score,
|
||||
parentIds: new[] { inputNodeId }));
|
||||
|
||||
return Task.FromResult(score);
|
||||
return Task.FromResult(new DeterministicScoreResult(
|
||||
Score: score,
|
||||
CanonicalInputHash: canonicalInputHash,
|
||||
CanonicalInputPayload: canonicalPayload,
|
||||
SeedHex: seedHex,
|
||||
Factors: factors,
|
||||
FormulaVersion: "v2.factorized"));
|
||||
}
|
||||
}
|
||||
|
||||
private static DeterministicScoreFactor BuildFactor(string name, double weight, string source, int offset, string sourceLabel)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(source));
|
||||
var safeOffset = Math.Min(Math.Max(0, offset), hash.Length - sizeof(uint));
|
||||
var raw = BinaryPrimitives.ReadUInt32BigEndian(hash.AsSpan(safeOffset, sizeof(uint))) / (double)uint.MaxValue;
|
||||
raw = Math.Round(raw, 6, MidpointRounding.ToEven);
|
||||
var weighted = Math.Round(raw * weight, 6, MidpointRounding.ToEven);
|
||||
|
||||
return new DeterministicScoreFactor(
|
||||
Name: name,
|
||||
Weight: weight,
|
||||
Raw: raw,
|
||||
Weighted: weighted,
|
||||
Source: sourceLabel);
|
||||
}
|
||||
|
||||
private sealed record CanonicalScoreInput(
|
||||
string scanId,
|
||||
string concelierSnapshotHash,
|
||||
string excititorSnapshotHash,
|
||||
string latticePolicyHash,
|
||||
string freezeTimestamp,
|
||||
string seedHex);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Reachability.Stack;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public sealed class ReachabilityStackRepositoryOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string PersistenceFilePath { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class FileBackedReachabilityStackRepository : IReachabilityStackRepository
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private readonly ReachabilityStackRepositoryOptions _options;
|
||||
private readonly ILogger<FileBackedReachabilityStackRepository> _logger;
|
||||
private readonly ConcurrentDictionary<string, ReachabilityStack> _stacks = new(StringComparer.Ordinal);
|
||||
private readonly SemaphoreSlim _ioGate = new(1, 1);
|
||||
|
||||
public FileBackedReachabilityStackRepository(
|
||||
IOptions<ReachabilityStackRepositoryOptions> options,
|
||||
ILogger<FileBackedReachabilityStackRepository> logger)
|
||||
{
|
||||
_options = options?.Value ?? new ReachabilityStackRepositoryOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
LoadFromDiskIfConfigured();
|
||||
}
|
||||
|
||||
public Task<ReachabilityStack?> TryGetByFindingIdAsync(string findingId, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (string.IsNullOrWhiteSpace(findingId))
|
||||
{
|
||||
return Task.FromResult<ReachabilityStack?>(null);
|
||||
}
|
||||
|
||||
_stacks.TryGetValue(findingId.Trim(), out var stack);
|
||||
return Task.FromResult(stack);
|
||||
}
|
||||
|
||||
public async Task StoreAsync(ReachabilityStack stack, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stack);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
_stacks[stack.FindingId] = stack;
|
||||
await PersistToDiskIfConfiguredAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void LoadFromDiskIfConfigured()
|
||||
{
|
||||
var path = _options.PersistenceFilePath?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllBytes(path);
|
||||
var stacks = JsonSerializer.Deserialize<IReadOnlyList<ReachabilityStack>>(json, SerializerOptions)
|
||||
?? Array.Empty<ReachabilityStack>();
|
||||
|
||||
foreach (var stack in stacks.Where(static s => !string.IsNullOrWhiteSpace(s.FindingId)))
|
||||
{
|
||||
_stacks[stack.FindingId] = stack;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loaded {Count} reachability stack records from {Path}.",
|
||||
_stacks.Count,
|
||||
path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load reachability stack persistence file {Path}.", path);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistToDiskIfConfiguredAsync(CancellationToken ct)
|
||||
{
|
||||
var path = _options.PersistenceFilePath?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _ioGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var ordered = _stacks.Values
|
||||
.OrderBy(stack => stack.FindingId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(ordered, SerializerOptions);
|
||||
await File.WriteAllBytesAsync(path, bytes, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ioGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,12 @@ using System.Threading.Tasks;
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes orchestrator events to the internal bus consumed by downstream services.
|
||||
/// Publishes job engine events to the internal bus consumed by downstream services.
|
||||
/// </summary>
|
||||
internal interface IPlatformEventPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes the supplied event envelope.
|
||||
/// </summary>
|
||||
Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default);
|
||||
Task PublishAsync(JobEngineEvent @event, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -40,21 +40,53 @@ public interface IScoreReplayService
|
||||
string? rootHash = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets deterministic score replay history for a scan.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ScoreHistoryEntry>> GetScoreHistoryAsync(
|
||||
string scanId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a proof bundle against expected root hash.
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan ID.</param>
|
||||
/// <param name="expectedRootHash">The expected root hash.</param>
|
||||
/// <param name="bundleUri">Optional specific bundle URI to verify.</param>
|
||||
/// <param name="expectedCanonicalInputHash">Optional canonical input hash to verify.</param>
|
||||
/// <param name="canonicalInputPayload">Optional canonical payload to hash and verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<BundleVerifyResult> VerifyBundleAsync(
|
||||
string scanId,
|
||||
string expectedRootHash,
|
||||
string? bundleUri = null,
|
||||
string? expectedCanonicalInputHash = null,
|
||||
string? canonicalInputPayload = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic score factor used for explainability and replay.
|
||||
/// </summary>
|
||||
public sealed record DeterministicScoreFactor(
|
||||
string Name,
|
||||
double Weight,
|
||||
double Raw,
|
||||
double Weighted,
|
||||
string Source);
|
||||
|
||||
/// <summary>
|
||||
/// Score history item for a scan replay.
|
||||
/// </summary>
|
||||
public sealed record ScoreHistoryEntry(
|
||||
string RootHash,
|
||||
DateTimeOffset ReplayedAt,
|
||||
double Score,
|
||||
string CanonicalInputHash,
|
||||
string ManifestDigest,
|
||||
IReadOnlyList<DeterministicScoreFactor> Factors);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a score replay operation.
|
||||
/// </summary>
|
||||
@@ -62,6 +94,12 @@ public interface IScoreReplayService
|
||||
/// <param name="RootHash">Root hash of the proof ledger.</param>
|
||||
/// <param name="BundleUri">URI to the proof bundle.</param>
|
||||
/// <param name="ManifestHash">Hash of the manifest used.</param>
|
||||
/// <param name="ManifestDigest">Digest of canonical manifest payload.</param>
|
||||
/// <param name="CanonicalInputHash">Digest of canonical score inputs.</param>
|
||||
/// <param name="CanonicalInputPayload">Canonical payload used for hashing.</param>
|
||||
/// <param name="SeedHex">Replay seed in hexadecimal.</param>
|
||||
/// <param name="Factors">Factorized score vectors.</param>
|
||||
/// <param name="VerificationStatus">Verification status text.</param>
|
||||
/// <param name="ReplayedAt">When the replay was performed.</param>
|
||||
/// <param name="Deterministic">Whether the replay was deterministic.</param>
|
||||
public sealed record ScoreReplayResult(
|
||||
@@ -69,6 +107,12 @@ public sealed record ScoreReplayResult(
|
||||
string RootHash,
|
||||
string BundleUri,
|
||||
string ManifestHash,
|
||||
string ManifestDigest,
|
||||
string CanonicalInputHash,
|
||||
string CanonicalInputPayload,
|
||||
string SeedHex,
|
||||
IReadOnlyList<DeterministicScoreFactor> Factors,
|
||||
string VerificationStatus,
|
||||
DateTimeOffset ReplayedAt,
|
||||
bool Deterministic);
|
||||
|
||||
@@ -79,19 +123,25 @@ public sealed record ScoreReplayResult(
|
||||
/// <param name="ComputedRootHash">The computed root hash.</param>
|
||||
/// <param name="ManifestValid">Whether the manifest signature is valid.</param>
|
||||
/// <param name="LedgerValid">Whether the ledger integrity is valid.</param>
|
||||
/// <param name="CanonicalInputHashValid">Whether canonical hash verification passed.</param>
|
||||
/// <param name="VerifiedAt">When verification was performed.</param>
|
||||
/// <param name="ExpectedCanonicalInputHash">Expected canonical hash when provided.</param>
|
||||
/// <param name="CanonicalInputHash">Computed or stored canonical hash.</param>
|
||||
/// <param name="ErrorMessage">Error message if verification failed.</param>
|
||||
public sealed record BundleVerifyResult(
|
||||
bool Valid,
|
||||
string ComputedRootHash,
|
||||
bool ManifestValid,
|
||||
bool LedgerValid,
|
||||
bool CanonicalInputHashValid,
|
||||
DateTimeOffset VerifiedAt,
|
||||
string? ExpectedCanonicalInputHash = null,
|
||||
string? CanonicalInputHash = null,
|
||||
string? ErrorMessage = null)
|
||||
{
|
||||
public static BundleVerifyResult Success(string computedRootHash, TimeProvider? timeProvider = null) =>
|
||||
new(true, computedRootHash, true, true, (timeProvider ?? TimeProvider.System).GetUtcNow());
|
||||
new(true, computedRootHash, true, true, true, (timeProvider ?? TimeProvider.System).GetUtcNow());
|
||||
|
||||
public static BundleVerifyResult Failure(string error, string computedRootHash = "", TimeProvider? timeProvider = null) =>
|
||||
new(false, computedRootHash, false, false, (timeProvider ?? TimeProvider.System).GetUtcNow(), error);
|
||||
new(false, computedRootHash, false, false, false, (timeProvider ?? TimeProvider.System).GetUtcNow(), null, null, error);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
/// </summary>
|
||||
internal sealed class MessagingPlatformEventPublisher : IPlatformEventPublisher
|
||||
{
|
||||
private readonly IEventStream<OrchestratorEvent> _eventStream;
|
||||
private readonly IEventStream<JobEngineEvent> _eventStream;
|
||||
private readonly ILogger<MessagingPlatformEventPublisher> _logger;
|
||||
private readonly TimeSpan _publishTimeout;
|
||||
private readonly long? _maxStreamLength;
|
||||
@@ -38,7 +38,7 @@ internal sealed class MessagingPlatformEventPublisher : IPlatformEventPublisher
|
||||
_maxStreamLength = eventsOptions.MaxStreamLength > 0 ? eventsOptions.MaxStreamLength : null;
|
||||
_publishTimeout = TimeSpan.FromSeconds(eventsOptions.PublishTimeoutSeconds <= 0 ? 5 : eventsOptions.PublishTimeoutSeconds);
|
||||
|
||||
_eventStream = eventStreamFactory.Create<OrchestratorEvent>(new EventStreamOptions
|
||||
_eventStream = eventStreamFactory.Create<JobEngineEvent>(new EventStreamOptions
|
||||
{
|
||||
StreamName = streamName,
|
||||
MaxLength = _maxStreamLength,
|
||||
@@ -50,7 +50,7 @@ internal sealed class MessagingPlatformEventPublisher : IPlatformEventPublisher
|
||||
_logger.LogInformation("Initialized messaging platform event publisher for stream {Stream}.", streamName);
|
||||
}
|
||||
|
||||
public async Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
|
||||
public async Task PublishAsync(JobEngineEvent @event, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -18,7 +18,7 @@ internal sealed class NullPlatformEventPublisher : IPlatformEventPublisher
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
|
||||
public Task PublishAsync(JobEngineEvent @event, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (@event is null)
|
||||
{
|
||||
@@ -27,7 +27,7 @@ internal sealed class NullPlatformEventPublisher : IPlatformEventPublisher
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("Suppressing publish for orchestrator event {EventKind} (tenant {Tenant}).", @event.Kind, @event.Tenant);
|
||||
_logger.LogDebug("Suppressing publish for job engine event {EventKind} (tenant {Tenant}).", @event.Kind, @event.Tenant);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Storage.Oci;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -29,16 +31,20 @@ internal sealed class OciAttestationPublisher : IOciAttestationPublisher
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.Scanner.WebService.OciAttestationPublisher");
|
||||
|
||||
private readonly ScannerWebServiceOptions.AttestationAttachmentOptions _options;
|
||||
private readonly OciArtifactPusher _artifactPusher;
|
||||
private readonly ILogger<OciAttestationPublisher> _logger;
|
||||
|
||||
public OciAttestationPublisher(
|
||||
IOptions<ScannerWebServiceOptions> options,
|
||||
OciArtifactPusher artifactPusher,
|
||||
ILogger<OciAttestationPublisher> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(artifactPusher);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_options = options.Value.AttestationAttachment ?? new ScannerWebServiceOptions.AttestationAttachmentOptions();
|
||||
_artifactPusher = artifactPusher;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -159,29 +165,59 @@ internal sealed class OciAttestationPublisher : IOciAttestationPublisher
|
||||
activity?.SetTag("repository", repository);
|
||||
activity?.SetTag("predicateType", predicateType);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
_logger.LogWarning("Cannot attach {PredicateType}: image digest is missing.", predicateType);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Attaching {PredicateType} attestation to {Registry}/{Repository}@{Digest} for report {ReportId}.",
|
||||
predicateType, registry, repository, digest, reportId);
|
||||
|
||||
// TODO: Integrate with IOciAttestationAttacher service when available in DI
|
||||
// For now, this is a placeholder implementation that logs the operation
|
||||
// The actual implementation would:
|
||||
// 1. Build OciReference from registry/repository/digest
|
||||
// 2. Convert DsseEnvelopeDto to DsseEnvelope
|
||||
// 3. Configure AttachmentOptions based on _options
|
||||
// 4. Call IOciAttestationAttacher.AttachAsync()
|
||||
// 5. Return the attestation digest
|
||||
var envelopeBytes = SerializeEnvelope(envelope);
|
||||
var reference = $"{registry}/{repository}@{digest}";
|
||||
var tag = BuildAttestationTag(predicateType, reportId);
|
||||
var pushRequest = new OciArtifactPushRequest
|
||||
{
|
||||
Reference = reference,
|
||||
ArtifactType = predicateType,
|
||||
SubjectDigest = digest,
|
||||
Tag = tag,
|
||||
SkipIfTagExists = !_options.ReplaceExisting,
|
||||
Layers =
|
||||
[
|
||||
new OciLayerContent
|
||||
{
|
||||
Content = envelopeBytes,
|
||||
MediaType = OciMediaTypes.DsseEnvelope
|
||||
}
|
||||
],
|
||||
Annotations = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[OciAnnotations.StellaPredicateType] = predicateType,
|
||||
[OciAnnotations.StellaIdempotencyKey] = $"{reportId}:{predicateType}"
|
||||
}
|
||||
};
|
||||
|
||||
await Task.Delay(1, cancellationToken); // Placeholder async operation
|
||||
var result = await _artifactPusher.PushAsync(pushRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.Success || string.IsNullOrWhiteSpace(result.ManifestDigest))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation push failed for {Reference} ({PredicateType}): {Error}",
|
||||
reference,
|
||||
predicateType,
|
||||
result.Error ?? "unknown");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Would attach {PredicateType} attestation to {Registry}/{Repository}@{Digest}. " +
|
||||
"SigningMode: {SigningMode}, UseRekor: {UseRekor}",
|
||||
predicateType, registry, repository, digest,
|
||||
_options.SigningMode, _options.UseRekor);
|
||||
_logger.LogInformation(
|
||||
"Attached {PredicateType} attestation to {Reference} as {ManifestDigest}.",
|
||||
predicateType,
|
||||
reference,
|
||||
result.ManifestDigest);
|
||||
|
||||
// Return placeholder digest - actual implementation would return real digest
|
||||
return $"sha256:placeholder_{predicateType.Replace('/', '_').Replace('@', '_')}_{reportId}";
|
||||
return result.ManifestDigest;
|
||||
}
|
||||
|
||||
private static bool TryParseImageReference(
|
||||
@@ -268,4 +304,62 @@ internal sealed class OciAttestationPublisher : IOciAttestationPublisher
|
||||
|
||||
return !string.IsNullOrWhiteSpace(registry) && !string.IsNullOrWhiteSpace(repository);
|
||||
}
|
||||
|
||||
private static byte[] SerializeEnvelope(DsseEnvelopeDto envelope)
|
||||
{
|
||||
var signatures = envelope.Signatures
|
||||
.Where(static signature => !string.IsNullOrWhiteSpace(signature.Sig))
|
||||
.Select(static signature => new SerializedDsseSignature
|
||||
{
|
||||
KeyId = signature.KeyId,
|
||||
Sig = signature.Sig
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var serialized = new SerializedDsseEnvelope
|
||||
{
|
||||
PayloadType = envelope.PayloadType,
|
||||
Payload = envelope.Payload,
|
||||
Signatures = signatures
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(serialized);
|
||||
}
|
||||
|
||||
private static string BuildAttestationTag(string predicateType, string reportId)
|
||||
{
|
||||
var normalizedPredicate = predicateType
|
||||
.ToLowerInvariant()
|
||||
.Replace(":", "-", StringComparison.Ordinal)
|
||||
.Replace("/", "-", StringComparison.Ordinal)
|
||||
.Replace("@", "-", StringComparison.Ordinal);
|
||||
|
||||
return $"att-{reportId.ToLowerInvariant()}-{normalizedPredicate}";
|
||||
}
|
||||
|
||||
private sealed record SerializedDsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
[JsonPropertyOrder(0)]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public string Payload { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
[JsonPropertyOrder(2)]
|
||||
public IReadOnlyList<SerializedDsseSignature> Signatures { get; init; } = Array.Empty<SerializedDsseSignature>();
|
||||
}
|
||||
|
||||
private sealed record SerializedDsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
[JsonPropertyOrder(0)]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public string Sig { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,13 +51,13 @@ internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAs
|
||||
_maxStreamLength = _options.MaxStreamLength > 0 ? _options.MaxStreamLength : null;
|
||||
}
|
||||
|
||||
public async Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
|
||||
public async Task PublishAsync(JobEngineEvent @event, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var payload = OrchestratorEventSerializer.Serialize(@event);
|
||||
var payload = JobEngineEventSerializer.Serialize(@event);
|
||||
|
||||
var entries = new NameValueEntry[]
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Triage;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
@@ -18,22 +19,29 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
/// </summary>
|
||||
public sealed class ReplayCommandService : IReplayCommandService
|
||||
{
|
||||
private const string DefaultBinary = "stellaops";
|
||||
private const string DefaultShell = "bash";
|
||||
|
||||
private readonly TriageDbContext _dbContext;
|
||||
private readonly ILogger<ReplayCommandService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Configuration (would come from IOptions in real implementation)
|
||||
private const string DefaultBinary = "stellaops";
|
||||
private const string ApiBaseUrl = "https://api.stellaops.local";
|
||||
private readonly string _binary;
|
||||
private readonly string _apiBaseUrl;
|
||||
|
||||
public ReplayCommandService(
|
||||
TriageDbContext dbContext,
|
||||
ILogger<ReplayCommandService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
IOptions<ReplayCommandServiceOptions>? options = null)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
var resolvedOptions = options?.Value ?? new ReplayCommandServiceOptions();
|
||||
_binary = string.IsNullOrWhiteSpace(resolvedOptions.Binary)
|
||||
? DefaultBinary
|
||||
: resolvedOptions.Binary.Trim();
|
||||
_apiBaseUrl = NormalizeApiBaseUrl(resolvedOptions.ApiBaseUrl);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -69,18 +77,20 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
var scan = finding.Scan;
|
||||
var verdictHash = ComputeVerdictHash(finding);
|
||||
var snapshotId = scan?.KnowledgeSnapshotId ?? finding.KnowledgeSnapshotId;
|
||||
var shell = ResolveShell(request.Shells);
|
||||
var binary = ResolveBinaryForShell(shell);
|
||||
|
||||
// Generate full command
|
||||
var fullCommand = BuildFullCommand(finding, scan);
|
||||
var fullCommand = BuildFullCommand(finding, scan, shell, binary);
|
||||
|
||||
// Generate short command if snapshot available
|
||||
var shortCommand = snapshotId is not null
|
||||
? BuildShortCommand(finding, snapshotId)
|
||||
? BuildShortCommand(finding, snapshotId, shell, binary)
|
||||
: null;
|
||||
|
||||
// Generate offline command if requested
|
||||
var offlineCommand = request.IncludeOffline
|
||||
? BuildOfflineCommand(finding, scan)
|
||||
? BuildOfflineCommand(finding, scan, shell, binary)
|
||||
: null;
|
||||
|
||||
// Build snapshot info
|
||||
@@ -136,12 +146,14 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
return null;
|
||||
}
|
||||
|
||||
var fullCommand = BuildScanFullCommand(scan);
|
||||
var shell = ResolveShell(request.Shells);
|
||||
var binary = ResolveBinaryForShell(shell);
|
||||
var fullCommand = BuildScanFullCommand(scan, shell, binary);
|
||||
var shortCommand = scan.KnowledgeSnapshotId is not null
|
||||
? BuildScanShortCommand(scan)
|
||||
? BuildScanShortCommand(scan, shell, binary)
|
||||
: null;
|
||||
var offlineCommand = request.IncludeOffline
|
||||
? BuildScanOfflineCommand(scan)
|
||||
? BuildScanOfflineCommand(scan, shell, binary)
|
||||
: null;
|
||||
var snapshotInfo = scan.KnowledgeSnapshotId is not null
|
||||
? BuildSnapshotInfo(scan.KnowledgeSnapshotId, scan)
|
||||
@@ -163,14 +175,15 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
};
|
||||
}
|
||||
|
||||
private ReplayCommandDto BuildFullCommand(TriageFinding finding, TriageScan? scan)
|
||||
private ReplayCommandDto BuildFullCommand(TriageFinding finding, TriageScan? scan, string shell, string binary)
|
||||
{
|
||||
var target = finding.ComponentPurl ?? finding.ArtifactDigest ?? finding.Id.ToString();
|
||||
var feedSnapshot = scan?.FeedSnapshotHash ?? "latest";
|
||||
var policyHash = scan?.PolicyHash ?? "default";
|
||||
var feedSnapshot = ResolveFeedSnapshotHash(scan);
|
||||
var policyHash = ResolvePolicyHash(scan);
|
||||
var quotedTarget = QuoteValue(target, shell);
|
||||
|
||||
var command = $"{DefaultBinary} replay " +
|
||||
$"--target \"{target}\" " +
|
||||
var command = $"{binary} replay " +
|
||||
$"--target {quotedTarget} " +
|
||||
$"--cve {finding.CveId} " +
|
||||
$"--feed-snapshot {feedSnapshot} " +
|
||||
$"--policy-hash {policyHash} " +
|
||||
@@ -180,11 +193,11 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
{
|
||||
Type = "full",
|
||||
Command = command,
|
||||
Shell = "bash",
|
||||
Shell = shell,
|
||||
RequiresNetwork = true,
|
||||
Parts = new ReplayCommandPartsDto
|
||||
{
|
||||
Binary = DefaultBinary,
|
||||
Binary = binary,
|
||||
Subcommand = "replay",
|
||||
Target = target,
|
||||
Arguments = new Dictionary<string, string>
|
||||
@@ -203,12 +216,13 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
};
|
||||
}
|
||||
|
||||
private ReplayCommandDto BuildShortCommand(TriageFinding finding, string snapshotId)
|
||||
private ReplayCommandDto BuildShortCommand(TriageFinding finding, string snapshotId, string shell, string binary)
|
||||
{
|
||||
var target = finding.ComponentPurl ?? finding.ArtifactDigest ?? finding.Id.ToString();
|
||||
var quotedTarget = QuoteValue(target, shell);
|
||||
|
||||
var command = $"{DefaultBinary} replay " +
|
||||
$"--target \"{target}\" " +
|
||||
var command = $"{binary} replay " +
|
||||
$"--target {quotedTarget} " +
|
||||
$"--cve {finding.CveId} " +
|
||||
$"--snapshot {snapshotId} " +
|
||||
$"--verify";
|
||||
@@ -217,11 +231,11 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
{
|
||||
Type = "short",
|
||||
Command = command,
|
||||
Shell = "bash",
|
||||
Shell = shell,
|
||||
RequiresNetwork = true,
|
||||
Parts = new ReplayCommandPartsDto
|
||||
{
|
||||
Binary = DefaultBinary,
|
||||
Binary = binary,
|
||||
Subcommand = "replay",
|
||||
Target = target,
|
||||
Arguments = new Dictionary<string, string>
|
||||
@@ -239,13 +253,14 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
};
|
||||
}
|
||||
|
||||
private ReplayCommandDto BuildOfflineCommand(TriageFinding finding, TriageScan? scan)
|
||||
private ReplayCommandDto BuildOfflineCommand(TriageFinding finding, TriageScan? scan, string shell, string binary)
|
||||
{
|
||||
var target = finding.ComponentPurl ?? finding.ArtifactDigest ?? finding.Id.ToString();
|
||||
var bundleId = $"{finding.ScanId}-{finding.Id}";
|
||||
var quotedTarget = QuoteValue(target, shell);
|
||||
|
||||
var command = $"{DefaultBinary} replay " +
|
||||
$"--target \"{target}\" " +
|
||||
var command = $"{binary} replay " +
|
||||
$"--target {quotedTarget} " +
|
||||
$"--cve {finding.CveId} " +
|
||||
$"--bundle ./evidence-{bundleId}.tar.gz " +
|
||||
$"--offline " +
|
||||
@@ -255,11 +270,11 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
{
|
||||
Type = "offline",
|
||||
Command = command,
|
||||
Shell = "bash",
|
||||
Shell = shell,
|
||||
RequiresNetwork = false,
|
||||
Parts = new ReplayCommandPartsDto
|
||||
{
|
||||
Binary = DefaultBinary,
|
||||
Binary = binary,
|
||||
Subcommand = "replay",
|
||||
Target = target,
|
||||
Arguments = new Dictionary<string, string>
|
||||
@@ -277,14 +292,15 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
};
|
||||
}
|
||||
|
||||
private ReplayCommandDto BuildScanFullCommand(TriageScan scan)
|
||||
private ReplayCommandDto BuildScanFullCommand(TriageScan scan, string shell, string binary)
|
||||
{
|
||||
var target = scan.TargetDigest ?? scan.TargetReference ?? scan.Id.ToString();
|
||||
var feedSnapshot = scan.FeedSnapshotHash ?? "latest";
|
||||
var policyHash = scan.PolicyHash ?? "default";
|
||||
var feedSnapshot = ResolveFeedSnapshotHash(scan);
|
||||
var policyHash = ResolvePolicyHash(scan);
|
||||
var quotedTarget = QuoteValue(target, shell);
|
||||
|
||||
var command = $"{DefaultBinary} scan replay " +
|
||||
$"--target \"{target}\" " +
|
||||
var command = $"{binary} scan replay " +
|
||||
$"--target {quotedTarget} " +
|
||||
$"--feed-snapshot {feedSnapshot} " +
|
||||
$"--policy-hash {policyHash} " +
|
||||
$"--verify";
|
||||
@@ -293,11 +309,11 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
{
|
||||
Type = "full",
|
||||
Command = command,
|
||||
Shell = "bash",
|
||||
Shell = shell,
|
||||
RequiresNetwork = true,
|
||||
Parts = new ReplayCommandPartsDto
|
||||
{
|
||||
Binary = DefaultBinary,
|
||||
Binary = binary,
|
||||
Subcommand = "scan replay",
|
||||
Target = target,
|
||||
Arguments = new Dictionary<string, string>
|
||||
@@ -310,12 +326,13 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
};
|
||||
}
|
||||
|
||||
private ReplayCommandDto BuildScanShortCommand(TriageScan scan)
|
||||
private ReplayCommandDto BuildScanShortCommand(TriageScan scan, string shell, string binary)
|
||||
{
|
||||
var target = scan.TargetDigest ?? scan.TargetReference ?? scan.Id.ToString();
|
||||
var quotedTarget = QuoteValue(target, shell);
|
||||
|
||||
var command = $"{DefaultBinary} scan replay " +
|
||||
$"--target \"{target}\" " +
|
||||
var command = $"{binary} scan replay " +
|
||||
$"--target {quotedTarget} " +
|
||||
$"--snapshot {scan.KnowledgeSnapshotId} " +
|
||||
$"--verify";
|
||||
|
||||
@@ -323,11 +340,11 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
{
|
||||
Type = "short",
|
||||
Command = command,
|
||||
Shell = "bash",
|
||||
Shell = shell,
|
||||
RequiresNetwork = true,
|
||||
Parts = new ReplayCommandPartsDto
|
||||
{
|
||||
Binary = DefaultBinary,
|
||||
Binary = binary,
|
||||
Subcommand = "scan replay",
|
||||
Target = target,
|
||||
Arguments = new Dictionary<string, string>
|
||||
@@ -339,13 +356,14 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
};
|
||||
}
|
||||
|
||||
private ReplayCommandDto BuildScanOfflineCommand(TriageScan scan)
|
||||
private ReplayCommandDto BuildScanOfflineCommand(TriageScan scan, string shell, string binary)
|
||||
{
|
||||
var target = scan.TargetDigest ?? scan.TargetReference ?? scan.Id.ToString();
|
||||
var bundleId = scan.Id.ToString();
|
||||
var quotedTarget = QuoteValue(target, shell);
|
||||
|
||||
var command = $"{DefaultBinary} scan replay " +
|
||||
$"--target \"{target}\" " +
|
||||
var command = $"{binary} scan replay " +
|
||||
$"--target {quotedTarget} " +
|
||||
$"--bundle ./scan-{bundleId}.tar.gz " +
|
||||
$"--offline " +
|
||||
$"--verify";
|
||||
@@ -354,11 +372,11 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
{
|
||||
Type = "offline",
|
||||
Command = command,
|
||||
Shell = "bash",
|
||||
Shell = shell,
|
||||
RequiresNetwork = false,
|
||||
Parts = new ReplayCommandPartsDto
|
||||
{
|
||||
Binary = DefaultBinary,
|
||||
Binary = binary,
|
||||
Subcommand = "scan replay",
|
||||
Target = target,
|
||||
Arguments = new Dictionary<string, string>
|
||||
@@ -372,16 +390,19 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
|
||||
private SnapshotInfoDto BuildSnapshotInfo(string snapshotId, TriageScan? scan)
|
||||
{
|
||||
var feedVersions = (scan?.FeedVersions is { Count: > 0 } configured)
|
||||
? new Dictionary<string, string>(configured, StringComparer.Ordinal)
|
||||
: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["snapshot"] = ResolveFeedSnapshotHash(scan)
|
||||
};
|
||||
|
||||
return new SnapshotInfoDto
|
||||
{
|
||||
Id = snapshotId,
|
||||
CreatedAt = scan?.SnapshotCreatedAt ?? _timeProvider.GetUtcNow(),
|
||||
FeedVersions = scan?.FeedVersions ?? new Dictionary<string, string>
|
||||
{
|
||||
["nvd"] = "latest",
|
||||
["osv"] = "latest"
|
||||
},
|
||||
DownloadUri = $"{ApiBaseUrl}/snapshots/{snapshotId}",
|
||||
FeedVersions = feedVersions,
|
||||
DownloadUri = BuildApiUri($"/snapshots/{snapshotId}"),
|
||||
ContentHash = scan?.SnapshotContentHash ?? ComputeDigest(snapshotId)
|
||||
};
|
||||
}
|
||||
@@ -394,7 +415,7 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
return new EvidenceBundleInfoDto
|
||||
{
|
||||
Id = bundleId,
|
||||
DownloadUri = $"{ApiBaseUrl}/bundles/{bundleId}",
|
||||
DownloadUri = BuildApiUri($"/bundles/{bundleId}"),
|
||||
SizeBytes = null, // Would be computed when bundle is generated
|
||||
ContentHash = contentHash,
|
||||
Format = "tar.gz",
|
||||
@@ -418,7 +439,7 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
return new EvidenceBundleInfoDto
|
||||
{
|
||||
Id = bundleId,
|
||||
DownloadUri = $"{ApiBaseUrl}/bundles/scan/{bundleId}",
|
||||
DownloadUri = BuildApiUri($"/bundles/scan/{bundleId}"),
|
||||
SizeBytes = null,
|
||||
ContentHash = contentHash,
|
||||
Format = "tar.gz",
|
||||
@@ -446,4 +467,122 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ResolveFeedSnapshotHash(TriageScan? scan)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scan?.FeedSnapshotHash))
|
||||
{
|
||||
return scan.FeedSnapshotHash!;
|
||||
}
|
||||
|
||||
var seed = scan?.Id.ToString() ?? "no-scan";
|
||||
return ComputeDigest($"feed-snapshot:{seed}");
|
||||
}
|
||||
|
||||
private static string ResolvePolicyHash(TriageScan? scan)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scan?.PolicyHash))
|
||||
{
|
||||
return scan.PolicyHash!;
|
||||
}
|
||||
|
||||
var seed = scan?.Id.ToString() ?? "no-scan";
|
||||
return ComputeDigest($"policy-hash:{seed}");
|
||||
}
|
||||
|
||||
private static string NormalizeApiBaseUrl(string? configured)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return configured.Trim().TrimEnd('/');
|
||||
}
|
||||
|
||||
private static string ResolveShell(IReadOnlyList<string>? shells)
|
||||
{
|
||||
if (shells is null || shells.Count == 0)
|
||||
{
|
||||
return DefaultShell;
|
||||
}
|
||||
|
||||
foreach (var shell in shells)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(shell))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = shell.Trim().ToLowerInvariant();
|
||||
if (normalized is "bash")
|
||||
{
|
||||
return "bash";
|
||||
}
|
||||
|
||||
if (normalized is "powershell" or "pwsh" or "ps")
|
||||
{
|
||||
return "powershell";
|
||||
}
|
||||
|
||||
if (normalized is "cmd" or "cmd.exe")
|
||||
{
|
||||
return "cmd";
|
||||
}
|
||||
}
|
||||
|
||||
return DefaultShell;
|
||||
}
|
||||
|
||||
private string ResolveBinaryForShell(string shell)
|
||||
{
|
||||
if (!string.Equals(shell, "powershell", StringComparison.Ordinal) &&
|
||||
!string.Equals(shell, "cmd", StringComparison.Ordinal))
|
||||
{
|
||||
return _binary;
|
||||
}
|
||||
|
||||
if (_binary.Contains('/', StringComparison.Ordinal) ||
|
||||
_binary.Contains('\\', StringComparison.Ordinal) ||
|
||||
_binary.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return _binary;
|
||||
}
|
||||
|
||||
return $"{_binary}.exe";
|
||||
}
|
||||
|
||||
private static string QuoteValue(string value, string shell)
|
||||
{
|
||||
if (string.Equals(shell, "powershell", StringComparison.Ordinal))
|
||||
{
|
||||
return $"'{value.Replace("'", "''", StringComparison.Ordinal)}'";
|
||||
}
|
||||
|
||||
return $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
|
||||
}
|
||||
|
||||
private string BuildApiUri(string relativePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
return _apiBaseUrl;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_apiBaseUrl))
|
||||
{
|
||||
return relativePath.StartsWith("/", StringComparison.Ordinal)
|
||||
? relativePath
|
||||
: $"/{relativePath}";
|
||||
}
|
||||
|
||||
return $"{_apiBaseUrl}/{relativePath.TrimStart('/')}";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ReplayCommandServiceOptions
|
||||
{
|
||||
public string Binary { get; set; } = "stellaops";
|
||||
|
||||
public string ApiBaseUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
|
||||
private readonly IPlatformEventPublisher _publisher;
|
||||
private readonly IClassificationChangeTracker _classificationChangeTracker;
|
||||
private readonly IOciAttestationPublisher _ociAttestationPublisher;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ReportEventDispatcher> _logger;
|
||||
@@ -47,10 +48,12 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
IOptions<ScannerWebServiceOptions> options,
|
||||
IGuidProvider guidProvider,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ReportEventDispatcher> logger)
|
||||
ILogger<ReportEventDispatcher> logger,
|
||||
IOciAttestationPublisher? ociAttestationPublisher = null)
|
||||
{
|
||||
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
_classificationChangeTracker = classificationChangeTracker ?? throw new ArgumentNullException(nameof(classificationChangeTracker));
|
||||
_ociAttestationPublisher = ociAttestationPublisher ?? NullOciAttestationPublisher.Instance;
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
if (options is null)
|
||||
{
|
||||
@@ -104,16 +107,16 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
var correlationId = document.ReportId;
|
||||
var (traceId, spanId) = ResolveTraceContext();
|
||||
|
||||
var reportEvent = new OrchestratorEvent
|
||||
var reportEvent = new JobEngineEvent
|
||||
{
|
||||
EventId = _guidProvider.NewGuid(),
|
||||
Kind = OrchestratorEventKinds.ScannerReportReady,
|
||||
Kind = JobEngineEventKinds.ScannerReportReady,
|
||||
Version = 1,
|
||||
Tenant = tenant,
|
||||
OccurredAt = occurredAt,
|
||||
RecordedAt = now,
|
||||
Source = Source,
|
||||
IdempotencyKey = BuildIdempotencyKey(OrchestratorEventKinds.ScannerReportReady, tenant, document.ReportId),
|
||||
IdempotencyKey = BuildIdempotencyKey(JobEngineEventKinds.ScannerReportReady, tenant, document.ReportId),
|
||||
CorrelationId = correlationId,
|
||||
TraceId = traceId,
|
||||
SpanId = spanId,
|
||||
@@ -126,16 +129,16 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
|
||||
await TrackFnDriftSafelyAsync(request, preview, document, tenant, occurredAt, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var scanCompletedEvent = new OrchestratorEvent
|
||||
var scanCompletedEvent = new JobEngineEvent
|
||||
{
|
||||
EventId = _guidProvider.NewGuid(),
|
||||
Kind = OrchestratorEventKinds.ScannerScanCompleted,
|
||||
Kind = JobEngineEventKinds.ScannerScanCompleted,
|
||||
Version = 1,
|
||||
Tenant = tenant,
|
||||
OccurredAt = occurredAt,
|
||||
RecordedAt = now,
|
||||
Source = Source,
|
||||
IdempotencyKey = BuildIdempotencyKey(OrchestratorEventKinds.ScannerScanCompleted, tenant, correlationId),
|
||||
IdempotencyKey = BuildIdempotencyKey(JobEngineEventKinds.ScannerScanCompleted, tenant, correlationId),
|
||||
CorrelationId = correlationId,
|
||||
TraceId = traceId,
|
||||
SpanId = spanId,
|
||||
@@ -145,6 +148,42 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
};
|
||||
|
||||
await PublishSafelyAsync(scanCompletedEvent, document.ReportId, cancellationToken).ConfigureAwait(false);
|
||||
await PublishOciAttestationSafelyAsync(document, envelope, tenant, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task PublishOciAttestationSafelyAsync(
|
||||
ReportDocumentDto document,
|
||||
DsseEnvelopeDto? envelope,
|
||||
string tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_ociAttestationPublisher.IsEnabled || envelope is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _ociAttestationPublisher
|
||||
.PublishAsync(document, envelope, tenant, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"OCI attestation attachment failed for report {ReportId}: {Error}",
|
||||
document.ReportId,
|
||||
result.Error ?? "unknown");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OCI attestation attachment threw for report {ReportId}.", document.ReportId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TrackFnDriftSafelyAsync(
|
||||
@@ -341,7 +380,7 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
return details.Count == 0 ? null : details;
|
||||
}
|
||||
|
||||
private async Task PublishSafelyAsync(OrchestratorEvent @event, string reportId, CancellationToken cancellationToken)
|
||||
private async Task PublishSafelyAsync(JobEngineEvent @event, string reportId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -366,7 +405,7 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
return ScannerRequestContextResolver.ResolveTenantOrDefault(context, DefaultTenant);
|
||||
}
|
||||
|
||||
private static OrchestratorEventScope BuildScope(ReportRequestDto request, ReportDocumentDto document)
|
||||
private static JobEngineEventScope BuildScope(ReportRequestDto request, ReportDocumentDto document)
|
||||
{
|
||||
var repository = ResolveRepository(request);
|
||||
var (ns, repo) = SplitRepository(repository);
|
||||
@@ -375,7 +414,7 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
|
||||
? request.ImageDigest ?? string.Empty
|
||||
: document.ImageDigest;
|
||||
|
||||
return new OrchestratorEventScope
|
||||
return new JobEngineEventScope
|
||||
{
|
||||
Namespace = ns,
|
||||
Repo = string.IsNullOrWhiteSpace(repo) ? "(unknown)" : repo,
|
||||
|
||||
@@ -19,6 +19,8 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
public sealed class ScoreReplayService : IScoreReplayService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _replayLocks = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, ScoreHistoryEntry>> _historyByScan = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, ReplayMetadata> _metadataByScanAndRoot = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly IScanManifestRepository _manifestRepository;
|
||||
private readonly IProofBundleRepository _bundleRepository;
|
||||
private readonly IProofBundleWriter _bundleWriter;
|
||||
@@ -78,7 +80,7 @@ public sealed class ScoreReplayService : IScoreReplayService
|
||||
|
||||
// Replay scoring with frozen inputs
|
||||
var ledger = new ProofLedger();
|
||||
var score = await _scoringService.ReplayScoreAsync(
|
||||
var scoreResult = await _scoringService.ReplayScoreAsync(
|
||||
manifest.ScanId,
|
||||
manifest.ConcelierSnapshotHash,
|
||||
manifest.ExcititorSnapshotHash,
|
||||
@@ -93,17 +95,43 @@ public sealed class ScoreReplayService : IScoreReplayService
|
||||
|
||||
// Store bundle reference
|
||||
await _bundleRepository.SaveBundleAsync(bundle, cancellationToken).ConfigureAwait(false);
|
||||
var manifestDigest = manifest.ComputeHash();
|
||||
var replayedAt = _timeProvider.GetUtcNow();
|
||||
var historyEntry = new ScoreHistoryEntry(
|
||||
RootHash: bundle.RootHash,
|
||||
ReplayedAt: replayedAt,
|
||||
Score: scoreResult.Score,
|
||||
CanonicalInputHash: scoreResult.CanonicalInputHash,
|
||||
ManifestDigest: manifestDigest,
|
||||
Factors: scoreResult.Factors);
|
||||
|
||||
var historyForScan = _historyByScan.GetOrAdd(
|
||||
scanId.Trim(),
|
||||
_ => new ConcurrentDictionary<string, ScoreHistoryEntry>(StringComparer.OrdinalIgnoreCase));
|
||||
historyForScan[bundle.RootHash] = historyEntry;
|
||||
_metadataByScanAndRoot[$"{scanId.Trim()}::{bundle.RootHash}"] = new ReplayMetadata(
|
||||
bundle.RootHash,
|
||||
bundle.BundleUri,
|
||||
scoreResult.CanonicalInputHash,
|
||||
scoreResult.CanonicalInputPayload,
|
||||
manifestDigest);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Score replay complete for scan {ScanId}: score={Score}, rootHash={RootHash}",
|
||||
scanId, score, bundle.RootHash);
|
||||
scanId, scoreResult.Score, bundle.RootHash);
|
||||
|
||||
return new ScoreReplayResult(
|
||||
Score: score,
|
||||
Score: scoreResult.Score,
|
||||
RootHash: bundle.RootHash,
|
||||
BundleUri: bundle.BundleUri,
|
||||
ManifestHash: manifest.ComputeHash(),
|
||||
ReplayedAt: _timeProvider.GetUtcNow(),
|
||||
ManifestHash: signedManifest.ManifestHash,
|
||||
ManifestDigest: manifestDigest,
|
||||
CanonicalInputHash: scoreResult.CanonicalInputHash,
|
||||
CanonicalInputPayload: scoreResult.CanonicalInputPayload,
|
||||
SeedHex: scoreResult.SeedHex,
|
||||
Factors: scoreResult.Factors,
|
||||
VerificationStatus: "verified",
|
||||
ReplayedAt: replayedAt,
|
||||
Deterministic: manifest.Deterministic);
|
||||
}
|
||||
finally
|
||||
@@ -121,17 +149,41 @@ public sealed class ScoreReplayService : IScoreReplayService
|
||||
return await _bundleRepository.GetBundleAsync(scanId, rootHash, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ScoreHistoryEntry>> GetScoreHistoryAsync(
|
||||
string scanId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var normalizedScanId = scanId.Trim();
|
||||
|
||||
if (!_historyByScan.TryGetValue(normalizedScanId, out var historyByRoot))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ScoreHistoryEntry>>([]);
|
||||
}
|
||||
|
||||
var history = historyByRoot.Values
|
||||
.OrderByDescending(item => item.ReplayedAt)
|
||||
.ThenBy(item => item.RootHash, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ScoreHistoryEntry>>(history);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleVerifyResult> VerifyBundleAsync(
|
||||
string scanId,
|
||||
string expectedRootHash,
|
||||
string? bundleUri = null,
|
||||
string? expectedCanonicalInputHash = null,
|
||||
string? canonicalInputPayload = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Verifying bundle for scan {ScanId}, expected hash {ExpectedHash}", scanId, expectedRootHash);
|
||||
|
||||
try
|
||||
{
|
||||
var normalizedScanId = scanId.Trim();
|
||||
// Get bundle URI if not provided
|
||||
if (string.IsNullOrEmpty(bundleUri))
|
||||
{
|
||||
@@ -155,25 +207,58 @@ public sealed class ScoreReplayService : IScoreReplayService
|
||||
// Compute and compare root hash
|
||||
var computedRootHash = contents.ProofLedger.RootHash();
|
||||
var hashMatch = computedRootHash.Equals(expectedRootHash, StringComparison.Ordinal);
|
||||
var metadataKey = $"{normalizedScanId}::{computedRootHash}";
|
||||
_metadataByScanAndRoot.TryGetValue(metadataKey, out var metadata);
|
||||
var effectiveCanonicalHash = metadata?.CanonicalInputHash;
|
||||
|
||||
if (!manifestVerify.IsValid || !ledgerValid || !hashMatch)
|
||||
if (!string.IsNullOrWhiteSpace(canonicalInputPayload))
|
||||
{
|
||||
effectiveCanonicalHash = $"sha256:{Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(canonicalInputPayload)))}";
|
||||
}
|
||||
|
||||
var canonicalHashExpected = !string.IsNullOrWhiteSpace(expectedCanonicalInputHash)
|
||||
? expectedCanonicalInputHash.Trim()
|
||||
: metadata?.CanonicalInputHash;
|
||||
var canonicalHashValid = string.IsNullOrWhiteSpace(canonicalHashExpected)
|
||||
|| (!string.IsNullOrWhiteSpace(effectiveCanonicalHash)
|
||||
&& string.Equals(
|
||||
canonicalHashExpected.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? canonicalHashExpected : $"sha256:{canonicalHashExpected}",
|
||||
effectiveCanonicalHash,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!manifestVerify.IsValid || !ledgerValid || !hashMatch || !canonicalHashValid)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (!manifestVerify.IsValid) errors.Add($"Manifest: {manifestVerify.ErrorMessage}");
|
||||
if (!ledgerValid) errors.Add("Ledger integrity check failed");
|
||||
if (!hashMatch) errors.Add($"Root hash mismatch: expected {expectedRootHash}, got {computedRootHash}");
|
||||
if (!canonicalHashValid)
|
||||
{
|
||||
errors.Add($"Canonical input hash mismatch: expected {canonicalHashExpected}, got {effectiveCanonicalHash ?? "missing"}");
|
||||
}
|
||||
|
||||
return new BundleVerifyResult(
|
||||
Valid: false,
|
||||
ComputedRootHash: computedRootHash,
|
||||
ManifestValid: manifestVerify.IsValid,
|
||||
LedgerValid: ledgerValid,
|
||||
CanonicalInputHashValid: canonicalHashValid,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
ExpectedCanonicalInputHash: canonicalHashExpected,
|
||||
CanonicalInputHash: effectiveCanonicalHash,
|
||||
ErrorMessage: string.Join("; ", errors));
|
||||
}
|
||||
|
||||
_logger.LogInformation("Bundle verification successful for scan {ScanId}", scanId);
|
||||
return BundleVerifyResult.Success(computedRootHash);
|
||||
return new BundleVerifyResult(
|
||||
Valid: true,
|
||||
ComputedRootHash: computedRootHash,
|
||||
ManifestValid: true,
|
||||
LedgerValid: true,
|
||||
CanonicalInputHashValid: true,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
ExpectedCanonicalInputHash: canonicalHashExpected,
|
||||
CanonicalInputHash: effectiveCanonicalHash);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -210,7 +295,7 @@ public interface IScoringService
|
||||
/// <summary>
|
||||
/// Replay scoring with frozen inputs.
|
||||
/// </summary>
|
||||
Task<double> ReplayScoreAsync(
|
||||
Task<DeterministicScoreResult> ReplayScoreAsync(
|
||||
string scanId,
|
||||
string concelierSnapshotHash,
|
||||
string excititorSnapshotHash,
|
||||
@@ -220,3 +305,21 @@ public interface IScoringService
|
||||
ProofLedger ledger,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic score replay output with explainability vectors.
|
||||
/// </summary>
|
||||
public sealed record DeterministicScoreResult(
|
||||
double Score,
|
||||
string CanonicalInputHash,
|
||||
string CanonicalInputPayload,
|
||||
string SeedHex,
|
||||
IReadOnlyList<DeterministicScoreFactor> Factors,
|
||||
string FormulaVersion);
|
||||
|
||||
internal sealed record ReplayMetadata(
|
||||
string RootHash,
|
||||
string BundleUri,
|
||||
string CanonicalInputHash,
|
||||
string CanonicalInputPayload,
|
||||
string ManifestDigest);
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Cache;
|
||||
using StellaOps.Scanner.Cache.Abstractions;
|
||||
using StellaOps.Scanner.Contracts;
|
||||
using StellaOps.Scanner.Core;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.Reachability.Slices;
|
||||
using StellaOps.Scanner.Reachability.Slices.Replay;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
@@ -40,10 +45,17 @@ public sealed class SliceQueryService : ISliceQueryService
|
||||
private readonly StellaOps.Scanner.Reachability.Slices.Replay.SliceDiffComputer _diffComputer;
|
||||
private readonly SliceHasher _hasher;
|
||||
private readonly IFileContentAddressableStore _cas;
|
||||
private readonly ScannerCacheOptions _scannerCacheOptions;
|
||||
private readonly IScanMetadataRepository _scanRepo;
|
||||
private readonly IScanManifestRepository? _manifestRepo;
|
||||
private readonly ICallGraphSnapshotRepository? _callGraphSnapshotRepo;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SliceQueryServiceOptions _options;
|
||||
private readonly ILogger<SliceQueryService> _logger;
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public SliceQueryService(
|
||||
ISliceCache cache,
|
||||
@@ -52,10 +64,13 @@ public sealed class SliceQueryService : ISliceQueryService
|
||||
StellaOps.Scanner.Reachability.Slices.Replay.SliceDiffComputer diffComputer,
|
||||
SliceHasher hasher,
|
||||
IFileContentAddressableStore cas,
|
||||
IOptions<ScannerCacheOptions> scannerCacheOptions,
|
||||
IScanMetadataRepository scanRepo,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<SliceQueryServiceOptions> options,
|
||||
ILogger<SliceQueryService> logger)
|
||||
ILogger<SliceQueryService> logger,
|
||||
IScanManifestRepository? manifestRepo = null,
|
||||
ICallGraphSnapshotRepository? callGraphSnapshotRepo = null)
|
||||
{
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_extractor = extractor ?? throw new ArgumentNullException(nameof(extractor));
|
||||
@@ -63,7 +78,10 @@ public sealed class SliceQueryService : ISliceQueryService
|
||||
_diffComputer = diffComputer ?? throw new ArgumentNullException(nameof(diffComputer));
|
||||
_hasher = hasher ?? throw new ArgumentNullException(nameof(hasher));
|
||||
_cas = cas ?? throw new ArgumentNullException(nameof(cas));
|
||||
_scannerCacheOptions = scannerCacheOptions?.Value ?? new ScannerCacheOptions();
|
||||
_scanRepo = scanRepo ?? throw new ArgumentNullException(nameof(scanRepo));
|
||||
_manifestRepo = manifestRepo;
|
||||
_callGraphSnapshotRepo = callGraphSnapshotRepo;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options?.Value ?? new SliceQueryServiceOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -142,29 +160,87 @@ public sealed class SliceQueryService : ISliceQueryService
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ReachabilitySlice?> GetSliceAsync(
|
||||
public async Task<ReachabilitySlice?> GetSliceAsync(
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
// TODO: Implement CAS retrieval - interface returns FileCasEntry with path, not stream
|
||||
// For now, return null (slice not found) to allow compilation
|
||||
_logger.LogWarning("GetSliceAsync not fully implemented - CAS interface mismatch");
|
||||
return Task.FromResult<ReachabilitySlice?>(null);
|
||||
var casKey = ExtractDigestHex(digest);
|
||||
var entry = await _cas.TryGetAsync(casKey, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null)
|
||||
{
|
||||
_logger.LogDebug("Slice not found in CAS for digest {Digest}", digest);
|
||||
return null;
|
||||
}
|
||||
|
||||
var contentPath = ResolveCasContentPath(entry);
|
||||
if (!File.Exists(contentPath))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Slice CAS metadata found but content missing for digest {Digest} at {Path}",
|
||||
digest,
|
||||
contentPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(contentPath, cancellationToken).ConfigureAwait(false);
|
||||
var slice = JsonSerializer.Deserialize<ReachabilitySlice>(bytes, SerializerOptions);
|
||||
if (slice is null)
|
||||
{
|
||||
throw new JsonException("Slice JSON deserialized to null.");
|
||||
}
|
||||
|
||||
return slice.Normalize();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Slice object for digest '{digest}' is corrupt.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<object?> GetSliceDsseAsync(
|
||||
public async Task<object?> GetSliceDsseAsync(
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
// TODO: Implement CAS retrieval - interface returns FileCasEntry with path, not stream
|
||||
// For now, return null (DSSE not found) to allow compilation
|
||||
_logger.LogWarning("GetSliceDsseAsync not fully implemented - CAS interface mismatch");
|
||||
return Task.FromResult<object?>(null);
|
||||
var casKey = $"{ExtractDigestHex(digest)}.dsse";
|
||||
var entry = await _cas.TryGetAsync(casKey, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null)
|
||||
{
|
||||
_logger.LogDebug("Slice DSSE not found in CAS for digest {Digest}", digest);
|
||||
return null;
|
||||
}
|
||||
|
||||
var contentPath = ResolveCasContentPath(entry);
|
||||
if (!File.Exists(contentPath))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Slice DSSE CAS metadata found but content missing for digest {Digest} at {Path}",
|
||||
digest,
|
||||
contentPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(contentPath, cancellationToken).ConfigureAwait(false);
|
||||
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(bytes, SerializerOptions);
|
||||
if (envelope is null)
|
||||
{
|
||||
throw new JsonException("DSSE envelope JSON deserialized to null.");
|
||||
}
|
||||
|
||||
return envelope;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Slice DSSE object for digest '{digest}' is corrupt.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -279,40 +355,197 @@ public sealed class SliceQueryService : ISliceQueryService
|
||||
|
||||
private async Task<ScanData?> LoadScanDataAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
// This would load the full scan data including call graph
|
||||
// For now, return a stub - actual implementation depends on scan storage
|
||||
var metadata = await _scanRepo.GetScanMetadataAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||
if (metadata == null) return null;
|
||||
if (metadata == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load call graph from CAS or graph store
|
||||
// This is a placeholder - actual implementation would hydrate the full graph
|
||||
var emptyGraph = new RichGraph(
|
||||
Nodes: Array.Empty<RichGraphNode>(),
|
||||
Edges: Array.Empty<RichGraphEdge>(),
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("scanner", "1.0.0", null));
|
||||
var signedManifest = await TryLoadManifestAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||
var manifest = signedManifest?.Manifest ?? BuildFallbackManifest(scanId, metadata);
|
||||
|
||||
// Create a stub manifest - actual implementation would load from storage
|
||||
var stubManifest = ScanManifest.CreateBuilder(scanId, metadata.TargetDigest ?? "unknown")
|
||||
.WithScannerVersion("1.0.0")
|
||||
.WithWorkerVersion("1.0.0")
|
||||
.WithConcelierSnapshot("")
|
||||
.WithExcititorSnapshot("")
|
||||
.WithLatticePolicyHash("")
|
||||
.Build();
|
||||
var snapshot = await TryLoadCallGraphSnapshotAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||
var graph = snapshot is null
|
||||
? CreateEmptyGraph(manifest.ScannerVersion)
|
||||
: BuildRichGraph(snapshot, manifest.ScannerVersion);
|
||||
|
||||
var graphDigest = snapshot?.GraphDigest ?? string.Empty;
|
||||
var artifactDigest = NormalizeDigest(manifest.ArtifactDigest)
|
||||
?? NormalizeDigest(metadata.TargetDigest)
|
||||
?? NormalizeDigest(metadata.BaseDigest);
|
||||
|
||||
var binaryDigests = artifactDigest is null
|
||||
? ImmutableArray<string>.Empty
|
||||
: ImmutableArray.Create(artifactDigest);
|
||||
|
||||
var sbomDigest = NormalizeDigest(manifest.EvidenceDigests?.SbomDigest);
|
||||
var layerDigests = ExtractLayerDigests(manifest);
|
||||
|
||||
return new ScanData
|
||||
{
|
||||
ScanId = scanId,
|
||||
Graph = emptyGraph,
|
||||
GraphDigest = "",
|
||||
BinaryDigests = ImmutableArray<string>.Empty,
|
||||
SbomDigest = null,
|
||||
LayerDigests = ImmutableArray<string>.Empty,
|
||||
Manifest = stubManifest
|
||||
Graph = graph,
|
||||
GraphDigest = graphDigest,
|
||||
BinaryDigests = binaryDigests,
|
||||
SbomDigest = sbomDigest,
|
||||
LayerDigests = layerDigests,
|
||||
Manifest = manifest
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SignedScanManifest?> TryLoadManifestAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_manifestRepo is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _manifestRepo.GetManifestAsync(scanId, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load scan manifest for scan {ScanId}", scanId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CallGraphSnapshot?> TryLoadCallGraphSnapshotAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_callGraphSnapshotRepo is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Deterministic probe order keeps behavior stable across runs.
|
||||
string[] languages = ["native", "dotnet", "java", "go", "python", "javascript"];
|
||||
foreach (var language in languages)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = await _callGraphSnapshotRepo
|
||||
.TryGetLatestAsync(scanId, language, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (snapshot is not null)
|
||||
{
|
||||
return snapshot.Trimmed();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to load call graph snapshot for scan {ScanId} language {Language}",
|
||||
scanId,
|
||||
language);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ScanManifest BuildFallbackManifest(string scanId, ScanMetadata metadata)
|
||||
{
|
||||
var targetDigest = NormalizeDigest(metadata.TargetDigest)
|
||||
?? NormalizeDigest(metadata.BaseDigest)
|
||||
?? "sha256:unknown";
|
||||
|
||||
return ScanManifest.CreateBuilder(scanId, targetDigest)
|
||||
.WithCreatedAt(metadata.ScanTime)
|
||||
.WithScannerVersion("scanner.webservice")
|
||||
.WithWorkerVersion("unknown")
|
||||
.WithConcelierSnapshot(string.Empty)
|
||||
.WithExcititorSnapshot(string.Empty)
|
||||
.WithLatticePolicyHash(string.Empty)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static RichGraph BuildRichGraph(CallGraphSnapshot snapshot, string scannerVersion)
|
||||
{
|
||||
var nodes = snapshot.Nodes
|
||||
.Select(node => new RichGraphNode(
|
||||
Id: node.NodeId,
|
||||
SymbolId: node.Symbol,
|
||||
CodeId: null,
|
||||
Purl: string.IsNullOrWhiteSpace(node.Package) ? null : node.Package,
|
||||
Lang: snapshot.Language,
|
||||
Kind: node.IsEntrypoint ? "entrypoint" : node.IsSink ? "sink" : "function",
|
||||
Display: string.IsNullOrWhiteSpace(node.Symbol) ? node.NodeId : node.Symbol,
|
||||
BuildId: null,
|
||||
Evidence: Array.Empty<string>(),
|
||||
Attributes: ImmutableDictionary<string, string>.Empty,
|
||||
SymbolDigest: null,
|
||||
Symbol: null,
|
||||
CodeBlockHash: null))
|
||||
.ToArray();
|
||||
|
||||
var edges = snapshot.Edges
|
||||
.Select(edge => new RichGraphEdge(
|
||||
From: edge.SourceId,
|
||||
To: edge.TargetId,
|
||||
Kind: edge.CallKind.ToString().ToLowerInvariant(),
|
||||
Purl: null,
|
||||
SymbolDigest: null,
|
||||
Evidence: Array.Empty<string>(),
|
||||
Confidence: edge.Explanation?.Confidence ?? 1d,
|
||||
Candidates: Array.Empty<string>()))
|
||||
.ToArray();
|
||||
|
||||
var roots = snapshot.EntrypointIds
|
||||
.Select(id => new RichGraphRoot(id, "runtime", "callgraph:entrypoint"))
|
||||
.ToArray();
|
||||
|
||||
return new RichGraph(
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
Roots: roots,
|
||||
Analyzer: new RichGraphAnalyzer("scanner.callgraph", scannerVersion, null))
|
||||
.Trimmed();
|
||||
}
|
||||
|
||||
private static RichGraph CreateEmptyGraph(string scannerVersion)
|
||||
{
|
||||
return new RichGraph(
|
||||
Nodes: Array.Empty<RichGraphNode>(),
|
||||
Edges: Array.Empty<RichGraphEdge>(),
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("scanner.callgraph", scannerVersion, null));
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ExtractLayerDigests(ScanManifest manifest)
|
||||
{
|
||||
if (manifest.Knobs.Count == 0)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return manifest.Knobs
|
||||
.Where(pair => pair.Key.StartsWith("layerDigest.", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair => NormalizeDigest(pair.Value))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string? NormalizeDigest(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (!trimmed.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
trimmed = $"sha256:{trimmed}";
|
||||
}
|
||||
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ExtractScanIdFromManifest(ScanManifest manifest)
|
||||
{
|
||||
return manifest.ScanId;
|
||||
@@ -324,6 +557,16 @@ public sealed class SliceQueryService : ISliceQueryService
|
||||
return colonIndex >= 0 ? prefixed[(colonIndex + 1)..] : prefixed;
|
||||
}
|
||||
|
||||
private string ResolveCasContentPath(FileCasEntry entry)
|
||||
{
|
||||
if (Path.IsPathRooted(entry.RelativePath))
|
||||
{
|
||||
return entry.RelativePath;
|
||||
}
|
||||
|
||||
return Path.Combine(_scannerCacheOptions.FileCasDirectoryPath, entry.RelativePath);
|
||||
}
|
||||
|
||||
private sealed record ScanData
|
||||
{
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Cache/StellaOps.Scanner.Cache.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage.Oci/StellaOps.Scanner.Storage.Oci.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user