Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.Triage.Entities;
namespace StellaOps.Scanner.WebService.Services;
@@ -94,44 +95,137 @@ public sealed class EvidenceCompositionService : IEvidenceCompositionService
cancellationToken).ConfigureAwait(false);
// Build score explanation (simplified local computation)
var scoreExplanation = BuildScoreExplanation(finding, explanation);
var scoreInfo = BuildScoreInfo(finding, explanation);
// Compose the response
var now = _timeProvider.GetUtcNow();
// Calculate expiry based on evidence sources
var (expiresAt, isStale) = CalculateTtlAndStaleness(now, explanation);
var freshness = BuildFreshnessInfo(now, explanation, observedAt: now);
return new FindingEvidenceResponse
{
FindingId = findingId,
Cve = cveId,
Component = BuildComponentRef(purl),
ReachablePath = explanation?.PathWitness,
Entrypoint = BuildEntrypointProof(explanation),
Component = BuildComponentInfo(purl),
ReachablePath = explanation?.PathWitness ?? Array.Empty<string>(),
Entrypoint = BuildEntrypointInfo(explanation),
Boundary = null, // Boundary extraction requires RichGraph, deferred to SPRINT_3800_0003_0002
Vex = null, // VEX requires Excititor query, deferred to SPRINT_3800_0003_0002
ScoreExplain = scoreExplanation,
Score = scoreInfo,
LastSeen = now,
ExpiresAt = expiresAt,
IsStale = isStale,
AttestationRefs = BuildAttestationRefs(scan, explanation)
AttestationRefs = BuildAttestationRefs(scan, explanation) ?? Array.Empty<string>(),
Freshness = freshness
};
}
/// <inheritdoc />
public Task<FindingEvidenceResponse> ComposeAsync(
TriageFinding finding,
bool includeRaw,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(finding);
var now = _timeProvider.GetUtcNow();
var latestReachability = finding.ReachabilityResults
.OrderByDescending(r => r.ComputedAt)
.FirstOrDefault();
var latestRisk = finding.RiskResults
.OrderByDescending(r => r.ComputedAt)
.FirstOrDefault();
var latestVex = finding.EffectiveVexRecords
.OrderByDescending(r => r.CollectedAt)
.FirstOrDefault();
var attestationRefs = finding.EvidenceArtifacts
.OrderByDescending(a => a.CreatedAt)
.Select(a => a.ContentHash)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var scoreInfo = latestRisk is null
? null
: new ScoreInfo
{
RiskScore = latestRisk.Score,
Contributions = new[]
{
new ScoreContribution
{
Factor = "policy",
Value = latestRisk.Score,
Reason = latestRisk.Why
}
}
};
var vexInfo = latestVex is null
? null
: new VexStatusInfo
{
Status = latestVex.Status.ToString().ToLowerInvariant(),
Justification = latestVex.SourceDomain,
Timestamp = latestVex.ValidFrom,
Issuer = latestVex.Issuer
};
var entrypoint = latestReachability is null
? null
: new EntrypointInfo
{
Type = latestReachability.Reachable switch
{
TriageReachability.Yes => "http",
TriageReachability.No => "internal",
_ => "internal"
},
Route = latestReachability.StaticProofRef,
Auth = null
};
var freshness = BuildFreshnessInfo(
now,
explanation: null,
observedAt: finding.LastSeenAt);
var cve = !string.IsNullOrWhiteSpace(finding.CveId)
? finding.CveId
: finding.RuleId ?? "unknown";
return Task.FromResult(new FindingEvidenceResponse
{
FindingId = finding.Id.ToString(),
Cve = cve,
Component = BuildComponentInfo(finding.Purl),
ReachablePath = Array.Empty<string>(),
Entrypoint = entrypoint,
Vex = vexInfo,
LastSeen = finding.LastSeenAt,
AttestationRefs = attestationRefs,
Score = scoreInfo,
Boundary = null,
Freshness = freshness
});
}
/// <summary>
/// Calculates the evidence expiry time and staleness based on evidence sources.
/// Uses the minimum expiry time from all evidence sources.
/// </summary>
private (DateTimeOffset expiresAt, bool isStale) CalculateTtlAndStaleness(
private FreshnessInfo BuildFreshnessInfo(
DateTimeOffset now,
ReachabilityExplanation? explanation)
ReachabilityExplanation? explanation,
DateTimeOffset? observedAt)
{
var defaultTtl = TimeSpan.FromDays(_options.DefaultEvidenceTtlDays);
var warningThreshold = TimeSpan.FromDays(_options.StaleWarningThresholdDays);
// Default: evidence expires from when it was computed (now)
var reachabilityExpiry = now.Add(defaultTtl);
var baseTimestamp = observedAt ?? now;
var reachabilityExpiry = baseTimestamp.Add(defaultTtl);
// If we have evidence chain with timestamps, use those instead
// For now, we use now as the base timestamp since ReachabilityExplanation
@@ -153,7 +247,16 @@ public sealed class EvidenceCompositionService : IEvidenceCompositionService
_logger.LogDebug("Evidence nearing expiry: expires in {TimeRemaining}", expiresAt - now);
}
return (expiresAt, isStale);
var ttlRemaining = expiresAt > now
? (int)Math.Floor((expiresAt - now).TotalHours)
: 0;
return new FreshnessInfo
{
IsStale = isStale,
ExpiresAt = expiresAt,
TtlRemainingHours = ttlRemaining
};
}
private static (string? cveId, string? purl) ParseFindingId(string findingId)
@@ -183,7 +286,7 @@ public sealed class EvidenceCompositionService : IEvidenceCompositionService
return (cveId, purl);
}
private static ComponentRef BuildComponentRef(string purl)
private static ComponentInfo BuildComponentInfo(string purl)
{
// Parse PURL: "pkg:ecosystem/name@version"
var parts = purl.Replace("pkg:", "", StringComparison.OrdinalIgnoreCase)
@@ -193,16 +296,16 @@ public sealed class EvidenceCompositionService : IEvidenceCompositionService
var name = parts.Length > 1 ? parts[1] : "unknown";
var version = parts.Length > 2 ? parts[2] : "unknown";
return new ComponentRef
return new ComponentInfo
{
Purl = purl,
Name = name,
Version = version,
Type = ecosystem
Ecosystem = ecosystem
};
}
private static EntrypointProof? BuildEntrypointProof(ReachabilityExplanation? explanation)
private static EntrypointInfo? BuildEntrypointInfo(ReachabilityExplanation? explanation)
{
if (explanation?.PathWitness is null || explanation.PathWitness.Count == 0)
{
@@ -212,11 +315,10 @@ public sealed class EvidenceCompositionService : IEvidenceCompositionService
var firstHop = explanation.PathWitness[0];
var entrypointType = InferEntrypointType(firstHop);
return new EntrypointProof
return new EntrypointInfo
{
Type = entrypointType,
Fqn = firstHop,
Phase = "runtime"
Route = firstHop
};
}
@@ -225,25 +327,25 @@ public sealed class EvidenceCompositionService : IEvidenceCompositionService
var lower = fqn.ToLowerInvariant();
if (lower.Contains("controller") || lower.Contains("handler") || lower.Contains("http"))
{
return "http_handler";
return "http";
}
if (lower.Contains("grpc") || lower.Contains("rpc"))
{
return "grpc_method";
return "grpc";
}
if (lower.Contains("main") || lower.Contains("program"))
{
return "cli_command";
return "cli";
}
return "internal";
}
private ScoreExplanationDto BuildScoreExplanation(
private ScoreInfo BuildScoreInfo(
ReachabilityFinding finding,
ReachabilityExplanation? explanation)
{
// Simplified score computation based on reachability status
var contributions = new List<ScoreContributionDto>();
var contributions = new List<ScoreContribution>();
double riskScore = 0.0;
// Reachability contribution (0-25 points)
@@ -258,26 +360,22 @@ public sealed class EvidenceCompositionService : IEvidenceCompositionService
if (reachabilityContribution > 0)
{
contributions.Add(new ScoreContributionDto
contributions.Add(new ScoreContribution
{
Factor = "reachability",
Weight = 1.0,
RawValue = reachabilityContribution,
Contribution = reachabilityContribution,
Explanation = reachabilityExplanation
Value = Convert.ToInt32(Math.Round(reachabilityContribution)),
Reason = reachabilityExplanation
});
riskScore += reachabilityContribution;
}
// Confidence contribution (0-10 points)
var confidenceContribution = finding.Confidence * 10.0;
contributions.Add(new ScoreContributionDto
contributions.Add(new ScoreContribution
{
Factor = "confidence",
Weight = 1.0,
RawValue = finding.Confidence,
Contribution = confidenceContribution,
Explanation = $"Analysis confidence: {finding.Confidence:P0}"
Value = Convert.ToInt32(Math.Round(confidenceContribution)),
Reason = $"Analysis confidence: {finding.Confidence:P0}"
});
riskScore += confidenceContribution;
@@ -289,13 +387,11 @@ public sealed class EvidenceCompositionService : IEvidenceCompositionService
if (gateCount > 0)
{
var gateDiscount = Math.Min(gateCount * -3.0, -10.0);
contributions.Add(new ScoreContributionDto
contributions.Add(new ScoreContribution
{
Factor = "gate_protection",
Weight = 1.0,
RawValue = gateCount,
Contribution = gateDiscount,
Explanation = $"{gateCount} protective gate(s) detected"
Value = Convert.ToInt32(Math.Round(gateDiscount)),
Reason = $"{gateCount} protective gate(s) detected"
});
riskScore += gateDiscount;
}
@@ -304,12 +400,10 @@ public sealed class EvidenceCompositionService : IEvidenceCompositionService
// Clamp to 0-100
riskScore = Math.Clamp(riskScore, 0.0, 100.0);
return new ScoreExplanationDto
return new ScoreInfo
{
Kind = "stellaops_evidence_v1",
RiskScore = riskScore,
Contributions = contributions,
LastSeen = _timeProvider.GetUtcNow()
RiskScore = Convert.ToInt32(Math.Round(riskScore)),
Contributions = contributions
};
}

View File

@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.Triage.Entities;
namespace StellaOps.Scanner.WebService.Services;
@@ -30,4 +31,15 @@ public interface IEvidenceCompositionService
ScanId scanId,
string findingId,
CancellationToken cancellationToken = default);
/// <summary>
/// Composes evidence for a triage finding.
/// </summary>
/// <param name="finding">The triage finding entity.</param>
/// <param name="includeRaw">Whether to include raw evidence pointers.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<FindingEvidenceResponse> ComposeAsync(
TriageFinding finding,
bool includeRaw,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,94 @@
using StellaOps.Scanner.Reachability.Slices;
using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Query request for reachability slices.
/// </summary>
public sealed record SliceQueryRequest
{
public string? CveId { get; init; }
public IReadOnlyList<string>? Symbols { get; init; }
public IReadOnlyList<string>? Entrypoints { get; init; }
public string? PolicyHash { get; init; }
public required string ScanId { get; init; }
}
/// <summary>
/// Response from slice query.
/// </summary>
public sealed record SliceQueryResponse
{
public required string SliceDigest { get; init; }
public required string Verdict { get; init; }
public required double Confidence { get; init; }
public IReadOnlyList<string>? PathWitnesses { get; init; }
public required bool CacheHit { get; init; }
public string? JobId { get; init; }
}
/// <summary>
/// Replay request for slice verification.
/// </summary>
public sealed record SliceReplayRequest
{
public required string SliceDigest { get; init; }
}
/// <summary>
/// Response from slice replay verification.
/// </summary>
public sealed record SliceReplayResponse
{
public required bool Match { get; init; }
public required string OriginalDigest { get; init; }
public required string RecomputedDigest { get; init; }
public SliceDiff? Diff { get; init; }
}
/// <summary>
/// Diff information when replay doesn't match.
/// </summary>
public sealed record SliceDiff
{
public IReadOnlyList<string>? MissingNodes { get; init; }
public IReadOnlyList<string>? ExtraNodes { get; init; }
public IReadOnlyList<string>? MissingEdges { get; init; }
public IReadOnlyList<string>? ExtraEdges { get; init; }
public string? VerdictDiff { get; init; }
}
/// <summary>
/// Service for querying and managing reachability slices.
/// </summary>
public interface ISliceQueryService
{
/// <summary>
/// Query reachability for CVE/symbols and generate slice.
/// </summary>
Task<SliceQueryResponse> QueryAsync(
SliceQueryRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieve an attested slice by digest.
/// </summary>
Task<ReachabilitySlice?> GetSliceAsync(
string digest,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieve DSSE envelope for a slice.
/// </summary>
Task<object?> GetSliceDsseAsync(
string digest,
CancellationToken cancellationToken = default);
/// <summary>
/// Verify slice reproducibility by recomputing.
/// </summary>
Task<SliceReplayResponse> ReplayAsync(
SliceReplayRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
using StellaOps.Scanner.Triage.Entities;
namespace StellaOps.Scanner.WebService.Services;
public interface ITriageQueryService
{
Task<TriageFinding?> GetFindingAsync(string findingId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,640 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Utilities;
namespace StellaOps.Scanner.WebService.Services;
internal interface ISbomByosUploadService
{
Task<(SbomUploadResponseDto Response, SbomValidationSummaryDto Validation)> UploadAsync(
SbomUploadRequestDto request,
CancellationToken cancellationToken);
Task<SbomUploadRecordDto?> GetRecordAsync(string sbomId, CancellationToken cancellationToken);
}
internal sealed class SbomByosUploadService : ISbomByosUploadService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
private readonly IScanCoordinator _scanCoordinator;
private readonly ISbomIngestionService _ingestionService;
private readonly ISbomUploadStore _uploadStore;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SbomByosUploadService> _logger;
public SbomByosUploadService(
IScanCoordinator scanCoordinator,
ISbomIngestionService ingestionService,
ISbomUploadStore uploadStore,
TimeProvider timeProvider,
ILogger<SbomByosUploadService> logger)
{
_scanCoordinator = scanCoordinator ?? throw new ArgumentNullException(nameof(scanCoordinator));
_ingestionService = ingestionService ?? throw new ArgumentNullException(nameof(ingestionService));
_uploadStore = uploadStore ?? throw new ArgumentNullException(nameof(uploadStore));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<(SbomUploadResponseDto Response, SbomValidationSummaryDto Validation)> UploadAsync(
SbomUploadRequestDto request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(request.ArtifactRef))
{
errors.Add("artifactRef is required.");
}
if (!string.IsNullOrWhiteSpace(request.ArtifactDigest) && !request.ArtifactDigest.Contains(':', StringComparison.Ordinal))
{
errors.Add("artifactDigest must include algorithm prefix (e.g. sha256:...).");
}
var document = TryParseDocument(request, out var parseErrors);
if (parseErrors.Count > 0)
{
errors.AddRange(parseErrors);
}
if (errors.Count > 0)
{
var validation = new SbomValidationSummaryDto
{
Valid = false,
Errors = errors
};
return (new SbomUploadResponseDto { ValidationResult = validation }, validation);
}
using (document)
{
var root = document!.RootElement;
var (format, formatVersion) = ResolveFormat(root, request.Format);
var validationWarnings = new List<string>();
var validationErrors = ValidateFormat(root, format, formatVersion, validationWarnings);
if (validationErrors.Count > 0)
{
var invalid = new SbomValidationSummaryDto
{
Valid = false,
Errors = validationErrors
};
return (new SbomUploadResponseDto { ValidationResult = invalid }, invalid);
}
var normalized = Normalize(root, format);
var (qualityScore, qualityWarnings) = Score(normalized);
var digest = ComputeDigest(root);
var sbomId = CatalogIdFactory.CreateArtifactId(ArtifactDocumentType.ImageBom, digest);
var warnings = new List<string>();
warnings.AddRange(validationWarnings);
warnings.AddRange(qualityWarnings);
var metadata = BuildMetadata(request, format, formatVersion, digest, sbomId);
var target = new ScanTarget(request.ArtifactRef.Trim(), request.ArtifactDigest?.Trim()).Normalize();
var scanId = ScanIdGenerator.Create(target, force: false, clientRequestId: null, metadata);
var ingestion = await _ingestionService
.IngestAsync(scanId, document, format, digest, cancellationToken)
.ConfigureAwait(false);
var submission = new ScanSubmission(target, force: false, clientRequestId: null, metadata);
var scanResult = await _scanCoordinator.SubmitAsync(submission, cancellationToken).ConfigureAwait(false);
if (!string.Equals(scanResult.Snapshot.ScanId.Value, scanId.Value, StringComparison.Ordinal))
{
_logger.LogWarning(
"BYOS scan id mismatch. computed={Computed} submitted={Submitted}",
scanId.Value,
scanResult.Snapshot.ScanId.Value);
}
var now = _timeProvider.GetUtcNow();
var validation = new SbomValidationSummaryDto
{
Valid = true,
QualityScore = qualityScore,
Warnings = warnings,
ComponentCount = normalized.Count
};
var response = new SbomUploadResponseDto
{
SbomId = ingestion.SbomId,
ArtifactRef = target.Reference ?? string.Empty,
ArtifactDigest = target.Digest,
Digest = ingestion.Digest,
Format = format,
FormatVersion = formatVersion,
ValidationResult = validation,
AnalysisJobId = scanResult.Snapshot.ScanId.Value,
UploadedAtUtc = now
};
var record = new SbomUploadRecord(
SbomId: ingestion.SbomId,
ArtifactRef: target.Reference ?? string.Empty,
ArtifactDigest: target.Digest,
Digest: ingestion.Digest,
Format: format,
FormatVersion: formatVersion,
AnalysisJobId: scanResult.Snapshot.ScanId.Value,
ComponentCount: normalized.Count,
QualityScore: qualityScore,
Warnings: warnings,
Source: request.Source,
CreatedAtUtc: now);
await _uploadStore.AddAsync(record, cancellationToken).ConfigureAwait(false);
return (response, validation);
}
}
public async Task<SbomUploadRecordDto?> GetRecordAsync(string sbomId, CancellationToken cancellationToken)
{
var record = await _uploadStore.GetAsync(sbomId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return null;
}
return new SbomUploadRecordDto
{
SbomId = record.SbomId,
ArtifactRef = record.ArtifactRef,
ArtifactDigest = record.ArtifactDigest,
Digest = record.Digest,
Format = record.Format,
FormatVersion = record.FormatVersion,
AnalysisJobId = record.AnalysisJobId,
ComponentCount = record.ComponentCount,
QualityScore = record.QualityScore,
Warnings = record.Warnings,
Source = record.Source,
CreatedAtUtc = record.CreatedAtUtc
};
}
private static JsonDocument? TryParseDocument(SbomUploadRequestDto request, out List<string> errors)
{
errors = new List<string>();
if (request.Sbom is { } sbomElement && sbomElement.ValueKind == JsonValueKind.Object)
{
var raw = sbomElement.GetRawText();
return JsonDocument.Parse(raw);
}
if (!string.IsNullOrWhiteSpace(request.SbomBase64))
{
try
{
var bytes = Convert.FromBase64String(request.SbomBase64);
return JsonDocument.Parse(bytes);
}
catch (FormatException)
{
errors.Add("sbomBase64 is not valid base64.");
return null;
}
catch (JsonException ex)
{
errors.Add($"Invalid SBOM JSON: {ex.Message}");
return null;
}
}
errors.Add("sbom or sbomBase64 is required.");
return null;
}
private static (string Format, string FormatVersion) ResolveFormat(JsonElement root, string? requestedFormat)
{
var format = string.IsNullOrWhiteSpace(requestedFormat)
? DetectFormat(root)
: requestedFormat.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(format))
{
return (string.Empty, string.Empty);
}
var formatVersion = format switch
{
SbomFormats.CycloneDx => GetCycloneDxVersion(root),
SbomFormats.Spdx => GetSpdxVersion(root),
_ => string.Empty
};
return (format, formatVersion);
}
private static string? DetectFormat(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
{
return null;
}
if (root.TryGetProperty("bomFormat", out var bomFormat)
&& bomFormat.ValueKind == JsonValueKind.String
&& string.Equals(bomFormat.GetString(), "CycloneDX", StringComparison.OrdinalIgnoreCase))
{
return SbomFormats.CycloneDx;
}
if (root.TryGetProperty("spdxVersion", out var spdxVersion)
&& spdxVersion.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(spdxVersion.GetString()))
{
return SbomFormats.Spdx;
}
return null;
}
private static IReadOnlyList<string> ValidateFormat(
JsonElement root,
string format,
string formatVersion,
List<string> warnings)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(format))
{
errors.Add("Unable to detect SBOM format.");
return errors;
}
if (string.Equals(format, SbomFormats.CycloneDx, StringComparison.OrdinalIgnoreCase))
{
if (!root.TryGetProperty("bomFormat", out var bomFormat) || bomFormat.ValueKind != JsonValueKind.String)
{
errors.Add("CycloneDX SBOM must include bomFormat.");
}
if (string.IsNullOrWhiteSpace(formatVersion))
{
errors.Add("CycloneDX SBOM must include specVersion.");
}
else if (!IsSupportedCycloneDx(formatVersion))
{
errors.Add($"CycloneDX specVersion '{formatVersion}' is not supported (1.4-1.6).");
}
if (!root.TryGetProperty("components", out var components) || components.ValueKind != JsonValueKind.Array)
{
warnings.Add("CycloneDX SBOM does not include a components array.");
}
}
else if (string.Equals(format, SbomFormats.Spdx, StringComparison.OrdinalIgnoreCase))
{
if (!root.TryGetProperty("spdxVersion", out var spdxVersion) || spdxVersion.ValueKind != JsonValueKind.String)
{
errors.Add("SPDX SBOM must include spdxVersion.");
}
if (string.IsNullOrWhiteSpace(formatVersion))
{
errors.Add("SPDX SBOM version could not be determined.");
}
else if (!IsSupportedSpdx(formatVersion))
{
errors.Add($"SPDX version '{formatVersion}' is not supported (2.3, 3.0).");
}
else if (formatVersion.StartsWith("3.0", StringComparison.OrdinalIgnoreCase))
{
warnings.Add("SPDX 3.0 schema validation is pending; structural checks only.");
}
if (!root.TryGetProperty("packages", out var packages) || packages.ValueKind != JsonValueKind.Array)
{
warnings.Add("SPDX SBOM does not include a packages array.");
}
}
else
{
errors.Add($"Unsupported SBOM format '{format}'.");
}
return errors;
}
private static bool IsSupportedCycloneDx(string version)
=> version.StartsWith("1.4", StringComparison.OrdinalIgnoreCase)
|| version.StartsWith("1.5", StringComparison.OrdinalIgnoreCase)
|| version.StartsWith("1.6", StringComparison.OrdinalIgnoreCase);
private static bool IsSupportedSpdx(string version)
=> version.StartsWith("2.3", StringComparison.OrdinalIgnoreCase)
|| version.StartsWith("3.0", StringComparison.OrdinalIgnoreCase);
private static IReadOnlyList<SbomNormalizedComponent> Normalize(JsonElement root, string format)
{
if (string.Equals(format, SbomFormats.CycloneDx, StringComparison.OrdinalIgnoreCase))
{
return NormalizeCycloneDx(root);
}
if (string.Equals(format, SbomFormats.Spdx, StringComparison.OrdinalIgnoreCase))
{
return NormalizeSpdx(root);
}
return Array.Empty<SbomNormalizedComponent>();
}
private static IReadOnlyList<SbomNormalizedComponent> NormalizeCycloneDx(JsonElement root)
{
if (!root.TryGetProperty("components", out var components) || components.ValueKind != JsonValueKind.Array)
{
return Array.Empty<SbomNormalizedComponent>();
}
var results = new List<SbomNormalizedComponent>();
foreach (var component in components.EnumerateArray())
{
if (component.ValueKind != JsonValueKind.Object)
{
continue;
}
var name = GetString(component, "name");
var version = GetString(component, "version");
var purl = GetString(component, "purl");
var license = ExtractCycloneDxLicense(component);
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(purl))
{
continue;
}
var key = NormalizeKey(purl, name);
results.Add(new SbomNormalizedComponent(key, name, version, purl, license));
}
return results
.OrderBy(c => c.Key, StringComparer.Ordinal)
.ThenBy(c => c.Version ?? string.Empty, StringComparer.Ordinal)
.ToList();
}
private static IReadOnlyList<SbomNormalizedComponent> NormalizeSpdx(JsonElement root)
{
if (!root.TryGetProperty("packages", out var packages) || packages.ValueKind != JsonValueKind.Array)
{
return Array.Empty<SbomNormalizedComponent>();
}
var results = new List<SbomNormalizedComponent>();
foreach (var package in packages.EnumerateArray())
{
if (package.ValueKind != JsonValueKind.Object)
{
continue;
}
var name = GetString(package, "name");
var version = GetString(package, "versionInfo");
var purl = ExtractSpdxPurl(package);
var license = GetString(package, "licenseDeclared");
if (string.IsNullOrWhiteSpace(license))
{
license = GetString(package, "licenseConcluded");
}
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(purl))
{
continue;
}
var key = NormalizeKey(purl, name);
results.Add(new SbomNormalizedComponent(key, name, version, purl, license));
}
return results
.OrderBy(c => c.Key, StringComparer.Ordinal)
.ThenBy(c => c.Version ?? string.Empty, StringComparer.Ordinal)
.ToList();
}
private static (double Score, IReadOnlyList<string> Warnings) Score(IReadOnlyList<SbomNormalizedComponent> components)
{
if (components is null || components.Count == 0)
{
return (0.0, new[] { "No components detected in SBOM." });
}
var total = components.Count;
var withPurl = components.Count(c => !string.IsNullOrWhiteSpace(c.Purl));
var withVersion = components.Count(c => !string.IsNullOrWhiteSpace(c.Version));
var withLicense = components.Count(c => !string.IsNullOrWhiteSpace(c.License));
var purlRatio = (double)withPurl / total;
var versionRatio = (double)withVersion / total;
var licenseRatio = (double)withLicense / total;
var score = (purlRatio * 0.4) + (versionRatio * 0.3) + (licenseRatio * 0.3);
var warnings = new List<string>();
if (withPurl < total)
{
warnings.Add($"{total - withPurl} components missing PURL values.");
}
if (withVersion < total)
{
warnings.Add($"{total - withVersion} components missing version values.");
}
if (withLicense < total)
{
warnings.Add($"{total - withLicense} components missing license values.");
}
return (Math.Round(score, 2), warnings);
}
private static string ComputeDigest(JsonElement root)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(root, JsonOptions);
var hash = SHA256.HashData(bytes);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
private static Dictionary<string, string> BuildMetadata(
SbomUploadRequestDto request,
string format,
string formatVersion,
string digest,
string sbomId)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["sbom.digest"] = digest,
["sbom.id"] = sbomId,
["sbom.format"] = format,
["sbom.format_version"] = formatVersion
};
AddIfPresent(metadata, "byos.source.tool", request.Source?.Tool);
AddIfPresent(metadata, "byos.source.version", request.Source?.Version);
AddIfPresent(metadata, "byos.ci.build_id", request.Source?.CiContext?.BuildId);
AddIfPresent(metadata, "byos.ci.repository", request.Source?.CiContext?.Repository);
return metadata;
}
private static void AddIfPresent(Dictionary<string, string> metadata, string key, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
metadata[key] = value.Trim();
}
}
private static string GetCycloneDxVersion(JsonElement root)
{
var spec = GetString(root, "specVersion");
return string.IsNullOrWhiteSpace(spec) ? string.Empty : spec.Trim();
}
private static string GetSpdxVersion(JsonElement root)
{
var version = GetString(root, "spdxVersion");
if (string.IsNullOrWhiteSpace(version))
{
return string.Empty;
}
var trimmed = version.Trim();
return trimmed.StartsWith("SPDX-", StringComparison.OrdinalIgnoreCase)
? trimmed[5..]
: trimmed;
}
private static string NormalizeKey(string? purl, string name)
{
if (!string.IsNullOrWhiteSpace(purl))
{
var trimmed = purl.Trim();
var qualifierIndex = trimmed.IndexOf('?');
if (qualifierIndex > 0)
{
trimmed = trimmed[..qualifierIndex];
}
var atIndex = trimmed.LastIndexOf('@');
if (atIndex > 4)
{
trimmed = trimmed[..atIndex];
}
return trimmed;
}
return name.Trim();
}
private static string? ExtractCycloneDxLicense(JsonElement component)
{
if (!component.TryGetProperty("licenses", out var licenses) || licenses.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var entry in licenses.EnumerateArray())
{
if (entry.ValueKind != JsonValueKind.Object)
{
continue;
}
if (entry.TryGetProperty("license", out var licenseObj) && licenseObj.ValueKind == JsonValueKind.Object)
{
var id = GetString(licenseObj, "id");
if (!string.IsNullOrWhiteSpace(id))
{
return id;
}
var name = GetString(licenseObj, "name");
if (!string.IsNullOrWhiteSpace(name))
{
return name;
}
}
}
return null;
}
private static string? ExtractSpdxPurl(JsonElement package)
{
if (!package.TryGetProperty("externalRefs", out var refs) || refs.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var reference in refs.EnumerateArray())
{
if (reference.ValueKind != JsonValueKind.Object)
{
continue;
}
var referenceType = GetString(reference, "referenceType");
if (!string.Equals(referenceType, "purl", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var locator = GetString(reference, "referenceLocator");
if (!string.IsNullOrWhiteSpace(locator))
{
return locator;
}
}
return null;
}
private static string GetString(JsonElement element, string property)
{
if (element.ValueKind != JsonValueKind.Object)
{
return string.Empty;
}
if (!element.TryGetProperty(property, out var prop))
{
return string.Empty;
}
return prop.ValueKind == JsonValueKind.String ? prop.GetString() ?? string.Empty : string.Empty;
}
private sealed record SbomNormalizedComponent(
string Key,
string Name,
string? Version,
string? Purl,
string? License);
}

View File

@@ -146,7 +146,7 @@ internal sealed class SbomIngestionService : ISbomIngestionService
{
if (string.Equals(format, SbomFormats.CycloneDx, StringComparison.OrdinalIgnoreCase))
{
return (ArtifactDocumentFormat.CycloneDxJson, "application/vnd.cyclonedx+json");
return (ArtifactDocumentFormat.CycloneDxJson, "application/vnd.cyclonedx+json; version=1.7");
}
if (string.Equals(format, SbomFormats.Spdx, StringComparison.OrdinalIgnoreCase))

View File

@@ -0,0 +1,50 @@
using System.Collections.Concurrent;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
internal sealed record SbomUploadRecord(
string SbomId,
string ArtifactRef,
string? ArtifactDigest,
string Digest,
string Format,
string FormatVersion,
string AnalysisJobId,
int ComponentCount,
double QualityScore,
IReadOnlyList<string> Warnings,
SbomUploadSourceDto? Source,
DateTimeOffset CreatedAtUtc);
internal interface ISbomUploadStore
{
Task AddAsync(SbomUploadRecord record, CancellationToken cancellationToken);
Task<SbomUploadRecord?> GetAsync(string sbomId, CancellationToken cancellationToken);
}
internal sealed class InMemorySbomUploadStore : ISbomUploadStore
{
private readonly ConcurrentDictionary<string, SbomUploadRecord> _records = new(StringComparer.OrdinalIgnoreCase);
public Task AddAsync(SbomUploadRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
cancellationToken.ThrowIfCancellationRequested();
_records[record.SbomId] = record;
return Task.CompletedTask;
}
public Task<SbomUploadRecord?> GetAsync(string sbomId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(sbomId))
{
return Task.FromResult<SbomUploadRecord?>(null);
}
_records.TryGetValue(sbomId.Trim(), out var record);
return Task.FromResult(record);
}
}

View File

@@ -0,0 +1,344 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Cache.Abstractions;
using StellaOps.Scanner.Core;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Reachability.Slices;
using StellaOps.Scanner.Reachability.Slices.Replay;
using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Options for slice query service.
/// </summary>
public sealed class SliceQueryServiceOptions
{
/// <summary>
/// Maximum slice size (nodes + edges) for synchronous generation.
/// Larger slices return 202 Accepted with job ID.
/// </summary>
public int MaxSyncSliceSize { get; set; } = 10_000;
/// <summary>
/// Whether to cache generated slices.
/// </summary>
public bool EnableCache { get; set; } = true;
}
/// <summary>
/// Service for querying and managing reachability slices.
/// </summary>
public sealed class SliceQueryService : ISliceQueryService
{
private readonly ISliceCache _cache;
private readonly SliceExtractor _extractor;
private readonly SliceCasStorage _casStorage;
private readonly SliceDiffComputer _diffComputer;
private readonly SliceHasher _hasher;
private readonly IFileContentAddressableStore _cas;
private readonly IScanMetadataRepository _scanRepo;
private readonly SliceQueryServiceOptions _options;
private readonly ILogger<SliceQueryService> _logger;
public SliceQueryService(
ISliceCache cache,
SliceExtractor extractor,
SliceCasStorage casStorage,
SliceDiffComputer diffComputer,
SliceHasher hasher,
IFileContentAddressableStore cas,
IScanMetadataRepository scanRepo,
IOptions<SliceQueryServiceOptions> options,
ILogger<SliceQueryService> logger)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_extractor = extractor ?? throw new ArgumentNullException(nameof(extractor));
_casStorage = casStorage ?? throw new ArgumentNullException(nameof(casStorage));
_diffComputer = diffComputer ?? throw new ArgumentNullException(nameof(diffComputer));
_hasher = hasher ?? throw new ArgumentNullException(nameof(hasher));
_cas = cas ?? throw new ArgumentNullException(nameof(cas));
_scanRepo = scanRepo ?? throw new ArgumentNullException(nameof(scanRepo));
_options = options?.Value ?? new SliceQueryServiceOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<SliceQueryResponse> QueryAsync(
SliceQueryRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
_logger.LogDebug("Processing slice query for scan {ScanId}, CVE {CveId}", request.ScanId, request.CveId);
// Check cache first
var cacheKey = ComputeCacheKey(request);
if (_options.EnableCache)
{
var cached = await _cache.TryGetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cached is not null)
{
_logger.LogDebug("Cache hit for slice query {CacheKey}", cacheKey);
return new SliceQueryResponse
{
SliceDigest = cached.SliceDigest,
Verdict = cached.Verdict,
Confidence = cached.Confidence,
PathWitnesses = cached.PathWitnesses.ToList(),
CacheHit = true
};
}
}
// Load scan data
var scanData = await LoadScanDataAsync(request.ScanId, cancellationToken).ConfigureAwait(false);
if (scanData == null)
{
throw new InvalidOperationException($"Scan {request.ScanId} not found");
}
// Build extraction request
var extractionRequest = BuildExtractionRequest(request, scanData);
// Extract slice
var slice = _extractor.Extract(extractionRequest);
// Store in CAS
var casResult = await _casStorage.StoreAsync(slice, _cas, cancellationToken).ConfigureAwait(false);
// Cache the result
if (_options.EnableCache)
{
var cacheEntry = new CachedSliceResult
{
SliceDigest = casResult.SliceDigest,
Verdict = slice.Verdict.Status.ToString().ToLowerInvariant(),
Confidence = slice.Verdict.Confidence,
PathWitnesses = slice.Verdict.PathWitnesses.IsDefaultOrEmpty
? Array.Empty<string>()
: slice.Verdict.PathWitnesses.ToList(),
CachedAt = DateTimeOffset.UtcNow
};
await _cache.SetAsync(cacheKey, cacheEntry, TimeSpan.FromHours(1), cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation(
"Generated slice {Digest} for scan {ScanId}: {NodeCount} nodes, {EdgeCount} edges, verdict={Verdict}",
casResult.SliceDigest,
request.ScanId,
slice.Subgraph.Nodes.Length,
slice.Subgraph.Edges.Length,
slice.Verdict.Status);
return BuildResponse(slice, casResult.SliceDigest, cacheHit: false);
}
/// <inheritdoc />
public async Task<ReachabilitySlice?> GetSliceAsync(
string digest,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
var casKey = ExtractDigestHex(digest);
var stream = await _cas.GetAsync(new FileCasGetRequest(casKey), cancellationToken).ConfigureAwait(false);
if (stream == null) return null;
await using (stream)
{
return await System.Text.Json.JsonSerializer.DeserializeAsync<ReachabilitySlice>(
stream,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public async Task<object?> GetSliceDsseAsync(
string digest,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
var dsseKey = $"{ExtractDigestHex(digest)}.dsse";
var stream = await _cas.GetAsync(new FileCasGetRequest(dsseKey), cancellationToken).ConfigureAwait(false);
if (stream == null) return null;
await using (stream)
{
return await System.Text.Json.JsonSerializer.DeserializeAsync<object>(
stream,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public async Task<SliceReplayResponse> ReplayAsync(
SliceReplayRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
_logger.LogDebug("Replaying slice {Digest}", request.SliceDigest);
// Load original slice
var original = await GetSliceAsync(request.SliceDigest, cancellationToken).ConfigureAwait(false);
if (original == null)
{
throw new InvalidOperationException($"Slice {request.SliceDigest} not found");
}
// Load scan data for recomputation
var scanId = ExtractScanIdFromManifest(original.Manifest);
var scanData = await LoadScanDataAsync(scanId, cancellationToken).ConfigureAwait(false);
if (scanData == null)
{
throw new InvalidOperationException($"Cannot replay: scan {scanId} not found");
}
// Recompute slice with same parameters
var extractionRequest = new SliceExtractionRequest(
scanData.Graph,
original.Inputs,
original.Query,
original.Manifest);
var recomputed = _extractor.Extract(extractionRequest);
var recomputedDigest = _hasher.ComputeDigest(recomputed);
// Compare
var diffResult = _diffComputer.Compute(original, recomputed);
_logger.LogInformation(
"Replay verification for {Digest}: match={Match}",
request.SliceDigest,
diffResult.Match);
return new SliceReplayResponse
{
Match = diffResult.Match,
OriginalDigest = request.SliceDigest,
RecomputedDigest = recomputedDigest.Digest,
Diff = diffResult.Match ? null : new SliceDiff
{
MissingNodes = diffResult.NodesDiff.Missing.IsDefaultOrEmpty ? null : diffResult.NodesDiff.Missing.ToList(),
ExtraNodes = diffResult.NodesDiff.Extra.IsDefaultOrEmpty ? null : diffResult.NodesDiff.Extra.ToList(),
MissingEdges = diffResult.EdgesDiff.Missing.IsDefaultOrEmpty ? null : diffResult.EdgesDiff.Missing.ToList(),
ExtraEdges = diffResult.EdgesDiff.Extra.IsDefaultOrEmpty ? null : diffResult.EdgesDiff.Extra.ToList(),
VerdictDiff = diffResult.VerdictDiff
}
};
}
private static SliceQueryResponse BuildResponse(ReachabilitySlice slice, string digest, bool cacheHit)
{
return new SliceQueryResponse
{
SliceDigest = digest,
Verdict = slice.Verdict.Status.ToString().ToLowerInvariant(),
Confidence = slice.Verdict.Confidence,
PathWitnesses = slice.Verdict.PathWitnesses.IsDefaultOrEmpty
? null
: slice.Verdict.PathWitnesses.ToList(),
CacheHit = cacheHit,
JobId = null
};
}
private SliceExtractionRequest BuildExtractionRequest(SliceQueryRequest request, ScanData scanData)
{
var query = new SliceQuery
{
CveId = request.CveId,
TargetSymbols = request.Symbols?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
Entrypoints = request.Entrypoints?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
PolicyHash = request.PolicyHash
};
var inputs = new SliceInputs
{
GraphDigest = scanData.GraphDigest,
BinaryDigests = scanData.BinaryDigests,
SbomDigest = scanData.SbomDigest,
LayerDigests = scanData.LayerDigests
};
return new SliceExtractionRequest(scanData.Graph, inputs, query, scanData.Manifest);
}
private static string ComputeCacheKey(SliceQueryRequest request)
{
var keyParts = new[]
{
request.ScanId,
request.CveId ?? "",
string.Join(",", request.Symbols?.OrderBy(s => s, StringComparer.Ordinal) ?? Array.Empty<string>()),
string.Join(",", request.Entrypoints?.OrderBy(e => e, StringComparer.Ordinal) ?? Array.Empty<string>()),
request.PolicyHash ?? ""
};
var combined = string.Join("|", keyParts);
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(combined));
return "slice:" + Convert.ToHexString(hash).ToLowerInvariant();
}
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.GetMetadataAsync(scanId, cancellationToken).ConfigureAwait(false);
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));
return new ScanData
{
ScanId = scanId,
Graph = metadata?.RichGraph ?? emptyGraph,
GraphDigest = metadata?.GraphDigest ?? "",
BinaryDigests = metadata?.BinaryDigests ?? ImmutableArray<string>.Empty,
SbomDigest = metadata?.SbomDigest,
LayerDigests = metadata?.LayerDigests ?? ImmutableArray<string>.Empty,
Manifest = metadata?.Manifest ?? new ScanManifest
{
ScanId = scanId,
Timestamp = DateTimeOffset.UtcNow.ToString("O"),
ScannerVersion = "1.0.0",
Environment = "production"
}
};
}
private static string ExtractScanIdFromManifest(ScanManifest manifest)
{
return manifest.ScanId ?? manifest.Subject?.Digest ?? "unknown";
}
private static string ExtractDigestHex(string prefixed)
{
var colonIndex = prefixed.IndexOf(':');
return colonIndex >= 0 ? prefixed[(colonIndex + 1)..] : prefixed;
}
private sealed record ScanData
{
public required string ScanId { get; init; }
public required RichGraph Graph { get; init; }
public required string GraphDigest { get; init; }
public ImmutableArray<string> BinaryDigests { get; init; }
public string? SbomDigest { get; init; }
public ImmutableArray<string> LayerDigests { get; init; }
public required ScanManifest Manifest { get; init; }
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Scanner.Triage;
using StellaOps.Scanner.Triage.Entities;
namespace StellaOps.Scanner.WebService.Services;
public sealed class TriageQueryService : ITriageQueryService
{
private readonly TriageDbContext _dbContext;
private readonly ILogger<TriageQueryService> _logger;
public TriageQueryService(TriageDbContext dbContext, ILogger<TriageQueryService> logger)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<TriageFinding?> GetFindingAsync(string findingId, CancellationToken cancellationToken = default)
{
if (!Guid.TryParse(findingId, out var id))
{
_logger.LogWarning("Invalid finding id: {FindingId}", findingId);
return null;
}
return await _dbContext.Findings
.Include(f => f.ReachabilityResults)
.Include(f => f.RiskResults)
.Include(f => f.EffectiveVexRecords)
.Include(f => f.EvidenceArtifacts)
.AsNoTracking()
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken)
.ConfigureAwait(false);
}
}