tests fixes and sprints work

This commit is contained in:
master
2026-01-22 19:08:46 +02:00
parent c32fff8f86
commit 726d70dc7f
881 changed files with 134434 additions and 6228 deletions

View File

@@ -0,0 +1,150 @@
using System;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Concelier.SbomIntegration.Parsing;
using StellaOps.Scanner.AiMlSecurity;
using StellaOps.Scanner.AiMlSecurity.Policy;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing.AiMlSecurity;
internal sealed class AiMlSecurityStageExecutor : IScanStageExecutor
{
private readonly IAiMlSecurityAnalyzer _analyzer;
private readonly IAiGovernancePolicyLoader _policyLoader;
private readonly IParsedSbomParser _parsedSbomParser;
private readonly ISbomParser _sbomParser;
private readonly ScannerWorkerOptions _options;
private readonly ILogger<AiMlSecurityStageExecutor> _logger;
public AiMlSecurityStageExecutor(
IAiMlSecurityAnalyzer analyzer,
IAiGovernancePolicyLoader policyLoader,
IParsedSbomParser parsedSbomParser,
ISbomParser sbomParser,
IOptions<ScannerWorkerOptions> options,
ILogger<AiMlSecurityStageExecutor> logger)
{
_analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer));
_policyLoader = policyLoader ?? throw new ArgumentNullException(nameof(policyLoader));
_parsedSbomParser = parsedSbomParser ?? throw new ArgumentNullException(nameof(parsedSbomParser));
_sbomParser = sbomParser ?? throw new ArgumentNullException(nameof(sbomParser));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string StageName => ScanStageNames.AiMlSecurity;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var options = _options.AiMlSecurity;
if (!options.Enabled)
{
return;
}
var sbomPath = ResolveSbomPath(context, options);
if (string.IsNullOrWhiteSpace(sbomPath))
{
_logger.LogDebug("No SBOM path provided; skipping AI/ML analysis for job {JobId}.", context.JobId);
return;
}
if (!File.Exists(sbomPath))
{
_logger.LogWarning("SBOM path {Path} not found; skipping AI/ML analysis for job {JobId}.", sbomPath, context.JobId);
return;
}
var policy = await _policyLoader.LoadAsync(options.PolicyPath, cancellationToken).ConfigureAwait(false);
policy = ApplyOptions(policy, options);
await using var stream = File.OpenRead(sbomPath);
var format = ResolveFormat(context, options);
if (format is null)
{
var detected = await _sbomParser.DetectFormatAsync(stream, cancellationToken).ConfigureAwait(false);
if (!detected.IsDetected)
{
_logger.LogWarning("Unable to detect SBOM format for {Path}; skipping AI/ML analysis.", sbomPath);
return;
}
format = detected.Format;
}
var parsed = await _parsedSbomParser.ParseAsync(stream, format.Value, cancellationToken).ConfigureAwait(false);
var hasModelCards = parsed.Components.Any(component => component.ModelCard is not null);
if (!hasModelCards)
{
_logger.LogDebug("SBOM at {Path} contains no model cards; skipping AI/ML analysis.", sbomPath);
return;
}
var report = await _analyzer.AnalyzeAsync(parsed.Components, policy, cancellationToken).ConfigureAwait(false);
context.Analysis.Set(ScanAnalysisKeys.AiMlSecurityReport, report);
context.Analysis.Set(ScanAnalysisKeys.AiMlPolicyVersion, policy.Version ?? "default");
_logger.LogInformation(
"AI/ML analysis completed for job {JobId}: {FindingCount} findings.",
context.JobId,
report.Summary.TotalFindings);
}
private static AiGovernancePolicy ApplyOptions(
AiGovernancePolicy policy,
ScannerWorkerOptions.AiMlSecurityOptions options)
{
if (options.RequireRiskAssessment && !policy.RequireRiskAssessment)
{
policy = policy with { RequireRiskAssessment = true };
}
return policy;
}
private static string? ResolveSbomPath(ScanJobContext context, ScannerWorkerOptions.AiMlSecurityOptions options)
{
return TryGetMetadata(context, options.SbomPathMetadataKey)
?? TryGetMetadata(context, ScanMetadataKeys.SbomPath)
?? TryGetMetadata(context, "sbomPath");
}
private static SbomFormat? ResolveFormat(ScanJobContext context, ScannerWorkerOptions.AiMlSecurityOptions options)
{
var format = TryGetMetadata(context, options.SbomFormatMetadataKey)
?? TryGetMetadata(context, ScanMetadataKeys.SbomFormat);
if (string.IsNullOrWhiteSpace(format))
{
return null;
}
var normalized = format.Trim().ToLowerInvariant();
if (normalized.Contains("spdx", StringComparison.OrdinalIgnoreCase))
{
return SbomFormat.SPDX;
}
return SbomFormat.CycloneDX;
}
private static string? TryGetMetadata(ScanJobContext context, string? key)
{
if (string.IsNullOrWhiteSpace(key))
{
return null;
}
if (context.Lease.Metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
return null;
}
}

View File

@@ -0,0 +1,154 @@
using System;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Concelier.SbomIntegration.Parsing;
using StellaOps.Scanner.BuildProvenance.Analyzers;
using StellaOps.Scanner.BuildProvenance.Policy;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing.BuildProvenance;
internal sealed class BuildProvenanceStageExecutor : IScanStageExecutor
{
private readonly IBuildProvenanceVerifier _verifier;
private readonly IBuildProvenancePolicyLoader _policyLoader;
private readonly IParsedSbomParser _parsedSbomParser;
private readonly ISbomParser _sbomParser;
private readonly ScannerWorkerOptions _options;
private readonly ILogger<BuildProvenanceStageExecutor> _logger;
public BuildProvenanceStageExecutor(
IBuildProvenanceVerifier verifier,
IBuildProvenancePolicyLoader policyLoader,
IParsedSbomParser parsedSbomParser,
ISbomParser sbomParser,
IOptions<ScannerWorkerOptions> options,
ILogger<BuildProvenanceStageExecutor> logger)
{
_verifier = verifier ?? throw new ArgumentNullException(nameof(verifier));
_policyLoader = policyLoader ?? throw new ArgumentNullException(nameof(policyLoader));
_parsedSbomParser = parsedSbomParser ?? throw new ArgumentNullException(nameof(parsedSbomParser));
_sbomParser = sbomParser ?? throw new ArgumentNullException(nameof(sbomParser));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string StageName => ScanStageNames.BuildProvenance;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var options = _options.BuildProvenance;
if (!options.Enabled)
{
return;
}
var sbomPath = ResolveSbomPath(context, options);
if (string.IsNullOrWhiteSpace(sbomPath))
{
_logger.LogDebug("No SBOM path provided; skipping build provenance for job {JobId}.", context.JobId);
return;
}
if (!File.Exists(sbomPath))
{
_logger.LogWarning("SBOM path {Path} not found; skipping build provenance for job {JobId}.", sbomPath, context.JobId);
return;
}
var policy = await _policyLoader.LoadAsync(options.PolicyPath, cancellationToken).ConfigureAwait(false);
policy = ApplyOptions(policy, options);
await using var stream = File.OpenRead(sbomPath);
var format = ResolveFormat(context, options);
if (format is null)
{
var detected = await _sbomParser.DetectFormatAsync(stream, cancellationToken).ConfigureAwait(false);
if (!detected.IsDetected)
{
_logger.LogWarning("Unable to detect SBOM format for {Path}; skipping build provenance.", sbomPath);
return;
}
format = detected.Format;
}
var parsed = await _parsedSbomParser.ParseAsync(stream, format.Value, cancellationToken).ConfigureAwait(false);
if (parsed.BuildInfo is null && parsed.Formulation is null)
{
_logger.LogDebug("SBOM at {Path} contains no build provenance; skipping build provenance verification.", sbomPath);
return;
}
var report = await _verifier.VerifyAsync(parsed, policy, cancellationToken).ConfigureAwait(false);
context.Analysis.Set(ScanAnalysisKeys.BuildProvenanceReport, report);
context.Analysis.Set(ScanAnalysisKeys.BuildProvenancePolicyVersion, policy.Version ?? "default");
_logger.LogInformation(
"Build provenance verification completed for job {JobId}: {FindingCount} findings.",
context.JobId,
report.Summary.TotalFindings);
}
private static BuildProvenancePolicy ApplyOptions(
BuildProvenancePolicy policy,
ScannerWorkerOptions.BuildProvenanceOptions options)
{
var repro = policy.Reproducibility;
if (!options.VerifyReproducibility && repro.VerifyOnDemand)
{
repro = repro with { VerifyOnDemand = false };
}
if (options.RequireReproducible && !repro.RequireReproducible)
{
repro = repro with { RequireReproducible = true };
}
return policy with { Reproducibility = repro };
}
private static string? ResolveSbomPath(ScanJobContext context, ScannerWorkerOptions.BuildProvenanceOptions options)
{
return TryGetMetadata(context, options.SbomPathMetadataKey)
?? TryGetMetadata(context, ScanMetadataKeys.SbomPath)
?? TryGetMetadata(context, "sbomPath");
}
private static SbomFormat? ResolveFormat(ScanJobContext context, ScannerWorkerOptions.BuildProvenanceOptions options)
{
var format = TryGetMetadata(context, options.SbomFormatMetadataKey)
?? TryGetMetadata(context, ScanMetadataKeys.SbomFormat);
if (string.IsNullOrWhiteSpace(format))
{
return null;
}
var normalized = format.Trim().ToLowerInvariant();
if (normalized.Contains("spdx", StringComparison.OrdinalIgnoreCase))
{
return SbomFormat.SPDX;
}
return SbomFormat.CycloneDX;
}
private static string? TryGetMetadata(ScanJobContext context, string? key)
{
if (string.IsNullOrWhiteSpace(key))
{
return null;
}
if (context.Lease.Metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
return null;
}
}

View File

@@ -0,0 +1,164 @@
using System;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Concelier.SbomIntegration.Parsing;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.CryptoAnalysis;
using StellaOps.Scanner.CryptoAnalysis.Policy;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing.CryptoAnalysis;
internal sealed class CryptoAnalysisStageExecutor : IScanStageExecutor
{
private readonly ICryptoAnalyzer _analyzer;
private readonly ICryptoPolicyLoader _policyLoader;
private readonly IParsedSbomParser _parsedSbomParser;
private readonly ISbomParser _sbomParser;
private readonly ScannerWorkerOptions _options;
private readonly ILogger<CryptoAnalysisStageExecutor> _logger;
public CryptoAnalysisStageExecutor(
ICryptoAnalyzer analyzer,
ICryptoPolicyLoader policyLoader,
IParsedSbomParser parsedSbomParser,
ISbomParser sbomParser,
IOptions<ScannerWorkerOptions> options,
ILogger<CryptoAnalysisStageExecutor> logger)
{
_analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer));
_policyLoader = policyLoader ?? throw new ArgumentNullException(nameof(policyLoader));
_parsedSbomParser = parsedSbomParser ?? throw new ArgumentNullException(nameof(parsedSbomParser));
_sbomParser = sbomParser ?? throw new ArgumentNullException(nameof(sbomParser));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string StageName => ScanStageNames.CryptoAnalysis;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var options = _options.CryptoAnalysis;
if (!options.Enabled)
{
return;
}
var sbomPath = ResolveSbomPath(context, options);
if (string.IsNullOrWhiteSpace(sbomPath))
{
_logger.LogDebug("No SBOM path provided; skipping crypto analysis for job {JobId}.", context.JobId);
return;
}
if (!File.Exists(sbomPath))
{
_logger.LogWarning("SBOM path {Path} not found; skipping crypto analysis for job {JobId}.", sbomPath, context.JobId);
return;
}
var policy = await _policyLoader.LoadAsync(options.PolicyPath, cancellationToken).ConfigureAwait(false);
policy = ApplyOptions(policy, options);
await using var stream = File.OpenRead(sbomPath);
var format = ResolveFormat(context, options);
if (format is null)
{
var detected = await _sbomParser.DetectFormatAsync(stream, cancellationToken).ConfigureAwait(false);
if (!detected.IsDetected)
{
_logger.LogWarning("Unable to detect SBOM format for {Path}; skipping crypto analysis.", sbomPath);
return;
}
format = detected.Format;
}
var parsed = await _parsedSbomParser.ParseAsync(stream, format.Value, cancellationToken).ConfigureAwait(false);
var componentsWithCrypto = parsed.Components
.Where(component => component.CryptoProperties is not null)
.ToImmutableArray();
if (componentsWithCrypto.IsDefaultOrEmpty)
{
_logger.LogDebug("SBOM at {Path} contains no crypto properties; skipping crypto analysis.", sbomPath);
return;
}
var report = await _analyzer.AnalyzeAsync(componentsWithCrypto, policy, cancellationToken).ConfigureAwait(false);
context.Analysis.Set(ScanAnalysisKeys.CryptoAnalysisReport, report);
context.Analysis.Set(ScanAnalysisKeys.CryptoPolicyVersion, policy.Version ?? "default");
_logger.LogInformation(
"Crypto analysis completed for job {JobId}: {FindingCount} findings.",
context.JobId,
report.Summary.TotalFindings);
}
private static CryptoPolicy ApplyOptions(CryptoPolicy policy, ScannerWorkerOptions.CryptoAnalysisOptions options)
{
if (options.RequireFips)
{
var frameworks = policy.ComplianceFrameworks.IsDefault
? ImmutableArray<string>.Empty
: policy.ComplianceFrameworks;
if (!frameworks.Any(framework => framework.Contains("FIPS", StringComparison.OrdinalIgnoreCase)))
{
frameworks = frameworks.Add("FIPS-140-3");
}
policy = policy with { ComplianceFrameworks = frameworks };
}
if (options.EnablePostQuantumAnalysis && !policy.PostQuantum.Enabled)
{
policy = policy with { PostQuantum = policy.PostQuantum with { Enabled = true } };
}
return policy;
}
private static string? ResolveSbomPath(ScanJobContext context, ScannerWorkerOptions.CryptoAnalysisOptions options)
{
return TryGetMetadata(context, options.SbomPathMetadataKey)
?? TryGetMetadata(context, ScanMetadataKeys.SbomPath)
?? TryGetMetadata(context, "sbomPath");
}
private static SbomFormat? ResolveFormat(ScanJobContext context, ScannerWorkerOptions.CryptoAnalysisOptions options)
{
var format = TryGetMetadata(context, options.SbomFormatMetadataKey)
?? TryGetMetadata(context, ScanMetadataKeys.SbomFormat);
if (string.IsNullOrWhiteSpace(format))
{
return null;
}
var normalized = format.Trim().ToLowerInvariant();
if (normalized.Contains("spdx", StringComparison.OrdinalIgnoreCase))
{
return SbomFormat.SPDX;
}
return SbomFormat.CycloneDX;
}
private static string? TryGetMetadata(ScanJobContext context, string? key)
{
if (string.IsNullOrWhiteSpace(key))
{
return null;
}
if (context.Lease.Metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
return null;
}
}

View File

@@ -0,0 +1,27 @@
using StellaOps.Concelier.SbomIntegration;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Scanner.Worker.Processing.Reachability;
internal sealed class NullSbomAdvisoryMatcher : ISbomAdvisoryMatcher
{
public Task<IReadOnlyList<SbomAdvisoryMatch>> MatchAsync(
Guid sbomId,
string sbomDigest,
IEnumerable<string> purls,
IReadOnlyDictionary<string, bool>? reachabilityMap = null,
IReadOnlyDictionary<string, bool>? deploymentMap = null,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<SbomAdvisoryMatch>>(Array.Empty<SbomAdvisoryMatch>());
public Task<IReadOnlyList<Guid>> FindAffectingCanonicalIdsAsync(
string purl,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<Guid>>(Array.Empty<Guid>());
public Task<SbomAdvisoryMatch?> CheckMatchAsync(
string purl,
Guid canonicalId,
CancellationToken cancellationToken = default)
=> Task.FromResult<SbomAdvisoryMatch?>(null);
}

View File

@@ -0,0 +1,395 @@
using System.Collections.Immutable;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor;
using StellaOps.Concelier.Core.Canonical;
using StellaOps.Concelier.SbomIntegration;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Concelier.SbomIntegration.Parsing;
using StellaOps.Cryptography;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Reachability.Dependencies;
using StellaOps.Scanner.Reachability.Dependencies.Reporting;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing.Reachability;
internal sealed class SbomReachabilityStageExecutor : IScanStageExecutor
{
private readonly IParsedSbomParser _parsedSbomParser;
private readonly ISbomParser _sbomParser;
private readonly IReachabilityPolicyLoader _policyLoader;
private readonly ISbomAdvisoryMatcher _advisoryMatcher;
private readonly DependencyReachabilityReporter _reporter;
private readonly ICryptoHash _cryptoHash;
private readonly ScannerWorkerOptions _options;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<SbomReachabilityStageExecutor> _logger;
public SbomReachabilityStageExecutor(
IParsedSbomParser parsedSbomParser,
ISbomParser sbomParser,
IReachabilityPolicyLoader policyLoader,
ISbomAdvisoryMatcher advisoryMatcher,
DependencyReachabilityReporter reporter,
ICryptoHash cryptoHash,
IOptions<ScannerWorkerOptions> options,
IServiceProvider serviceProvider,
ILogger<SbomReachabilityStageExecutor> logger)
{
_parsedSbomParser = parsedSbomParser ?? throw new ArgumentNullException(nameof(parsedSbomParser));
_sbomParser = sbomParser ?? throw new ArgumentNullException(nameof(sbomParser));
_policyLoader = policyLoader ?? throw new ArgumentNullException(nameof(policyLoader));
_advisoryMatcher = advisoryMatcher ?? throw new ArgumentNullException(nameof(advisoryMatcher));
_reporter = reporter ?? throw new ArgumentNullException(nameof(reporter));
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string StageName => ScanStageNames.ReachabilityAnalysis;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var options = _options.Reachability;
if (!options.Enabled)
{
return;
}
var sbomPath = ResolveSbomPath(context, options);
if (string.IsNullOrWhiteSpace(sbomPath))
{
_logger.LogDebug("No SBOM path provided; skipping reachability analysis for job {JobId}.", context.JobId);
return;
}
if (!File.Exists(sbomPath))
{
_logger.LogWarning("SBOM path {Path} not found; skipping reachability analysis for job {JobId}.", sbomPath, context.JobId);
return;
}
var policy = await _policyLoader.LoadAsync(options.PolicyPath, cancellationToken).ConfigureAwait(false);
var sbomDigest = await ComputeDigestAsync(sbomPath, cancellationToken).ConfigureAwait(false);
await using var stream = File.OpenRead(sbomPath);
var format = ResolveFormat(context, options);
if (format is null)
{
var detected = await _sbomParser.DetectFormatAsync(stream, cancellationToken).ConfigureAwait(false);
if (!detected.IsDetected)
{
_logger.LogWarning("Unable to detect SBOM format for {Path}; skipping reachability analysis.", sbomPath);
return;
}
format = detected.Format;
}
var parsed = await _parsedSbomParser.ParseAsync(stream, format.Value, cancellationToken)
.ConfigureAwait(false);
var callGraph = await LoadCallGraphAsync(context, options, cancellationToken).ConfigureAwait(false);
var combiner = new ReachGraphReachabilityCombiner();
var reachabilityReport = combiner.Analyze(parsed, callGraph, policy);
var purlReachability = BuildPurlReachabilityMap(parsed, reachabilityReport.ComponentReachability);
var matcherReachability = BuildReachabilityMapForMatcher(purlReachability, policy);
var purls = parsed.Components
.Select(component => component.Purl)
.Where(purl => !string.IsNullOrWhiteSpace(purl))
.Select(purl => purl!.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(purl => purl, StringComparer.Ordinal)
.ToArray();
IReadOnlyList<SbomAdvisoryMatch> matches = [];
if (purls.Length > 0)
{
matches = await _advisoryMatcher.MatchAsync(
Guid.Empty,
sbomDigest,
purls,
matcherReachability,
deploymentMap: null,
cancellationToken)
.ConfigureAwait(false);
}
var advisorySummaries = await LoadAdvisorySummariesAsync(matches, cancellationToken)
.ConfigureAwait(false);
var severityMap = advisorySummaries.ToDictionary(
entry => entry.Key,
entry => entry.Value.Severity);
var filter = new VulnerabilityReachabilityFilter();
var filterResult = filter.Apply(matches, purlReachability, policy, severityMap);
var report = _reporter.BuildReport(parsed, reachabilityReport, filterResult, advisorySummaries, policy);
var graphViz = _reporter.ExportGraphViz(reachabilityReport.Graph, reachabilityReport.ComponentReachability, BuildComponentPurlLookup(parsed));
var toolVersion = typeof(SbomReachabilityStageExecutor).Assembly.GetName().Version?.ToString() ?? "unknown";
var sarif = await _reporter.ExportSarifAsync(
report,
toolVersion,
includeFiltered: options.IncludeUnreachableVulnerabilities || policy.Reporting.ShowFilteredVulnerabilities,
cancellationToken)
.ConfigureAwait(false);
context.Analysis.Set(ScanAnalysisKeys.DependencyReachabilityReport, report);
context.Analysis.Set(ScanAnalysisKeys.DependencyReachabilityDot, graphViz);
context.Analysis.Set(ScanAnalysisKeys.DependencyReachabilitySarif, sarif);
var vulnerabilityMatches = BuildVulnerabilityMatches(
filterResult,
advisorySummaries,
includeFiltered: options.IncludeUnreachableVulnerabilities);
context.Analysis.Set(ScanAnalysisKeys.VulnerabilityMatches, vulnerabilityMatches);
_logger.LogInformation(
"Dependency reachability completed for job {JobId}: components={Total} reachable={Reachable} unreachable={Unreachable} vulnerabilities={Vulns} filtered={Filtered} reduction={Reduction:0.##}%.",
context.JobId,
report.Summary.ComponentStatistics.TotalComponents,
report.Summary.ComponentStatistics.ReachableComponents,
report.Summary.ComponentStatistics.UnreachableComponents,
report.Summary.VulnerabilityStatistics.TotalVulnerabilities,
report.Summary.VulnerabilityStatistics.FilteredVulnerabilities,
report.Summary.FalsePositiveReductionPercent);
}
private static List<VulnerabilityMatch> BuildVulnerabilityMatches(
VulnerabilityReachabilityFilterResult filterResult,
IReadOnlyDictionary<Guid, DependencyReachabilityAdvisorySummary> advisorySummaries,
bool includeFiltered)
{
var matches = new List<VulnerabilityMatch>();
foreach (var adjustment in filterResult.Adjustments)
{
if (adjustment.IsFiltered && !includeFiltered)
{
continue;
}
advisorySummaries.TryGetValue(adjustment.Match.CanonicalId, out var advisory);
var severity = adjustment.AdjustedSeverity
?? advisory?.Severity
?? adjustment.OriginalSeverity
?? "unknown";
var vulnId = advisory?.VulnerabilityId ?? adjustment.Match.CanonicalId.ToString();
var isReachable = adjustment.EffectiveStatus is ReachabilityStatus.Reachable or
ReachabilityStatus.PotentiallyReachable;
matches.Add(new VulnerabilityMatch(
VulnId: vulnId,
ComponentRef: adjustment.Match.Purl,
IsReachable: isReachable,
Severity: severity));
}
return matches;
}
private static Dictionary<string, string?> BuildComponentPurlLookup(ParsedSbom sbom)
{
var lookup = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var component in sbom.Components)
{
if (string.IsNullOrWhiteSpace(component.BomRef))
{
continue;
}
lookup[component.BomRef] = component.Purl;
}
return lookup;
}
private async Task<IReadOnlyDictionary<Guid, DependencyReachabilityAdvisorySummary>> LoadAdvisorySummariesAsync(
IReadOnlyList<SbomAdvisoryMatch> matches,
CancellationToken cancellationToken)
{
if (matches.Count == 0)
{
return new Dictionary<Guid, DependencyReachabilityAdvisorySummary>();
}
var canonicalService = _serviceProvider.GetService<ICanonicalAdvisoryService>();
if (canonicalService is null)
{
return new Dictionary<Guid, DependencyReachabilityAdvisorySummary>();
}
var summaries = new Dictionary<Guid, DependencyReachabilityAdvisorySummary>();
foreach (var canonicalId in matches.Select(match => match.CanonicalId).Distinct().OrderBy(id => id))
{
var advisory = await canonicalService.GetByIdAsync(canonicalId, cancellationToken).ConfigureAwait(false);
if (advisory is null)
{
continue;
}
summaries[canonicalId] = new DependencyReachabilityAdvisorySummary
{
CanonicalId = canonicalId,
VulnerabilityId = advisory.Cve,
Severity = advisory.Severity,
Title = advisory.Title,
Summary = advisory.Summary,
AffectedVersions = advisory.VersionRange?.RangeExpression
};
}
return summaries;
}
private async Task<string> ComputeDigestAsync(string sbomPath, CancellationToken cancellationToken)
{
await using var digestStream = File.OpenRead(sbomPath);
var hex = await _cryptoHash.ComputeHashHexAsync(digestStream, HashAlgorithms.Sha256, cancellationToken)
.ConfigureAwait(false);
return $"sha256:{hex}";
}
private static Dictionary<string, ReachabilityStatus> BuildPurlReachabilityMap(
ParsedSbom sbom,
IReadOnlyDictionary<string, ReachabilityStatus> componentReachability)
{
var map = new Dictionary<string, ReachabilityStatus>(StringComparer.OrdinalIgnoreCase);
foreach (var component in sbom.Components)
{
if (string.IsNullOrWhiteSpace(component.Purl) || string.IsNullOrWhiteSpace(component.BomRef))
{
continue;
}
var status = componentReachability.TryGetValue(component.BomRef, out var value)
? value
: ReachabilityStatus.Unknown;
if (map.TryGetValue(component.Purl, out var existing))
{
map[component.Purl] = MaxStatus(existing, status);
}
else
{
map[component.Purl] = status;
}
}
return map;
}
private static Dictionary<string, bool> BuildReachabilityMapForMatcher(
IReadOnlyDictionary<string, ReachabilityStatus> purlReachability,
ReachabilityPolicy policy)
{
var map = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in purlReachability.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
var effective = entry.Value == ReachabilityStatus.Unknown
? policy.Confidence.MarkUnknownAs
: entry.Value;
map[entry.Key] = effective is ReachabilityStatus.Reachable or ReachabilityStatus.PotentiallyReachable;
}
return map;
}
private static ReachabilityStatus MaxStatus(ReachabilityStatus left, ReachabilityStatus right)
{
static int Rank(ReachabilityStatus status) => status switch
{
ReachabilityStatus.Reachable => 3,
ReachabilityStatus.PotentiallyReachable => 2,
ReachabilityStatus.Unknown => 1,
_ => 0
};
return Rank(left) >= Rank(right) ? left : right;
}
private async Task<RichGraph?> LoadCallGraphAsync(
ScanJobContext context,
ScannerWorkerOptions.ReachabilityOptions options,
CancellationToken cancellationToken)
{
var path = ResolveCallGraphPath(context, options);
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
if (!File.Exists(path))
{
_logger.LogWarning("Call graph path {Path} not found; reachability analysis will be SBOM-only.", path);
return null;
}
await using var stream = File.OpenRead(path);
var reader = new RichGraphReader();
try
{
return await reader.ReadAsync(stream, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read call graph from {Path}; reachability analysis will be SBOM-only.", path);
return null;
}
}
private static string? ResolveSbomPath(ScanJobContext context, ScannerWorkerOptions.ReachabilityOptions options)
{
return TryGetMetadata(context, options.SbomPathMetadataKey)
?? TryGetMetadata(context, ScanMetadataKeys.SbomPath)
?? TryGetMetadata(context, "sbomPath");
}
private static string? ResolveCallGraphPath(ScanJobContext context, ScannerWorkerOptions.ReachabilityOptions options)
{
return TryGetMetadata(context, options.CallGraphPathMetadataKey)
?? TryGetMetadata(context, ScanMetadataKeys.ReachabilityCallGraphPath)
?? TryGetMetadata(context, "reachability.callgraph.path")
?? TryGetMetadata(context, "reachability.richgraph.path");
}
private static SbomFormat? ResolveFormat(ScanJobContext context, ScannerWorkerOptions.ReachabilityOptions options)
{
var format = TryGetMetadata(context, options.SbomFormatMetadataKey)
?? TryGetMetadata(context, ScanMetadataKeys.SbomFormat);
if (string.IsNullOrWhiteSpace(format))
{
return null;
}
var normalized = format.Trim().ToLowerInvariant();
if (normalized.Contains("spdx", StringComparison.OrdinalIgnoreCase))
{
return SbomFormat.SPDX;
}
return SbomFormat.CycloneDX;
}
private static string? TryGetMetadata(ScanJobContext context, string? key)
{
if (string.IsNullOrWhiteSpace(key))
{
return null;
}
if (context.Lease.Metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
return null;
}
}

View File

@@ -9,6 +9,16 @@ public static class ScanStageNames
public const string PullLayers = "pull-layers";
public const string BuildFilesystem = "build-filesystem";
public const string ExecuteAnalyzers = "execute-analyzers";
// Sprint: SPRINT_20260119_016 - Service Security Analysis
public const string ServiceSecurity = "service-security";
// Sprint: SPRINT_20260119_017 - CBOM Crypto Analysis
public const string CryptoAnalysis = "crypto-analysis";
// Sprint: SPRINT_20260119_018 - AI/ML Supply Chain Security
public const string AiMlSecurity = "ai-ml-security";
// Sprint: SPRINT_20260119_019 - Build Provenance Verification
public const string BuildProvenance = "build-provenance";
// Sprint: SPRINT_20260119_022 - Dependency Reachability
public const string ReachabilityAnalysis = "reachability-analysis";
public const string EpssEnrichment = "epss-enrichment";
public const string ComposeArtifacts = "compose-artifacts";
public const string EmitReports = "emit-reports";
@@ -36,9 +46,14 @@ public static class ScanStageNames
PullLayers,
BuildFilesystem,
ExecuteAnalyzers,
ServiceSecurity,
CryptoAnalysis,
AiMlSecurity,
BuildProvenance,
ScanSecrets,
BinaryLookup,
EpssEnrichment,
ReachabilityAnalysis,
VexGate,
ComposeArtifacts,
Entropy,

View File

@@ -0,0 +1,135 @@
using System;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Concelier.SbomIntegration.Parsing;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.ServiceSecurity;
using StellaOps.Scanner.ServiceSecurity.Policy;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing.ServiceSecurity;
internal sealed class ServiceSecurityStageExecutor : IScanStageExecutor
{
private readonly IServiceSecurityAnalyzer _analyzer;
private readonly IServiceSecurityPolicyLoader _policyLoader;
private readonly IParsedSbomParser _parsedSbomParser;
private readonly ISbomParser _sbomParser;
private readonly ScannerWorkerOptions _options;
private readonly ILogger<ServiceSecurityStageExecutor> _logger;
public ServiceSecurityStageExecutor(
IServiceSecurityAnalyzer analyzer,
IServiceSecurityPolicyLoader policyLoader,
IParsedSbomParser parsedSbomParser,
ISbomParser sbomParser,
IOptions<ScannerWorkerOptions> options,
ILogger<ServiceSecurityStageExecutor> logger)
{
_analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer));
_policyLoader = policyLoader ?? throw new ArgumentNullException(nameof(policyLoader));
_parsedSbomParser = parsedSbomParser ?? throw new ArgumentNullException(nameof(parsedSbomParser));
_sbomParser = sbomParser ?? throw new ArgumentNullException(nameof(sbomParser));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string StageName => ScanStageNames.ServiceSecurity;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var options = _options.ServiceSecurity;
if (!options.Enabled)
{
return;
}
var sbomPath = ResolveSbomPath(context, options);
if (string.IsNullOrWhiteSpace(sbomPath))
{
_logger.LogDebug("No SBOM path provided; skipping service security analysis for job {JobId}.", context.JobId);
return;
}
if (!File.Exists(sbomPath))
{
_logger.LogWarning("SBOM path {Path} not found; skipping service security analysis for job {JobId}.", sbomPath, context.JobId);
return;
}
var policy = await _policyLoader.LoadAsync(options.PolicyPath, cancellationToken).ConfigureAwait(false);
await using var stream = File.OpenRead(sbomPath);
var format = ResolveFormat(context, options);
if (format is null)
{
var detected = await _sbomParser.DetectFormatAsync(stream, cancellationToken).ConfigureAwait(false);
if (!detected.IsDetected)
{
_logger.LogWarning("Unable to detect SBOM format for {Path}; skipping service analysis.", sbomPath);
return;
}
format = detected.Format;
}
var parsed = await _parsedSbomParser.ParseAsync(stream, format.Value, cancellationToken).ConfigureAwait(false);
if (parsed.Services.IsDefaultOrEmpty)
{
_logger.LogDebug("SBOM at {Path} contains no services; skipping service analysis.", sbomPath);
return;
}
var report = await _analyzer.AnalyzeAsync(parsed.Services, policy, cancellationToken).ConfigureAwait(false);
context.Analysis.Set(ScanAnalysisKeys.ServiceSecurityReport, report);
context.Analysis.Set(ScanAnalysisKeys.ServiceSecurityPolicyVersion, policy.Version ?? "default");
_logger.LogInformation(
"Service security analysis completed for job {JobId}: {FindingCount} findings.",
context.JobId,
report.Summary.TotalFindings);
}
private static string? ResolveSbomPath(ScanJobContext context, ScannerWorkerOptions.ServiceSecurityOptions options)
{
return TryGetMetadata(context, options.SbomPathMetadataKey)
?? TryGetMetadata(context, ScanMetadataKeys.SbomPath)
?? TryGetMetadata(context, "sbomPath");
}
private static SbomFormat? ResolveFormat(ScanJobContext context, ScannerWorkerOptions.ServiceSecurityOptions options)
{
var format = TryGetMetadata(context, options.SbomFormatMetadataKey)
?? TryGetMetadata(context, ScanMetadataKeys.SbomFormat);
if (string.IsNullOrWhiteSpace(format))
{
return null;
}
var normalized = format.Trim().ToLowerInvariant();
if (normalized.Contains("spdx", StringComparison.OrdinalIgnoreCase))
{
return SbomFormat.SPDX;
}
return SbomFormat.CycloneDX;
}
private static string? TryGetMetadata(ScanJobContext context, string? key)
{
if (string.IsNullOrWhiteSpace(key))
{
return null;
}
if (context.Lease.Metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
return null;
}
}

View File

@@ -325,6 +325,8 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher
ArtifactDocumentFormat.EntryTraceGraphJson => "entrytrace.graph",
ArtifactDocumentFormat.ComponentFragmentJson => "layer.fragments",
ArtifactDocumentFormat.ObservationJson => "observation.json",
ArtifactDocumentFormat.SarifJson => "sarif-json",
ArtifactDocumentFormat.GraphVizDot => "graphviz-dot",
ArtifactDocumentFormat.SurfaceManifestJson => "surface.manifest",
ArtifactDocumentFormat.CompositionRecipeJson => "composition.recipe",
ArtifactDocumentFormat.CycloneDxJson => "cdx-json",

View File

@@ -10,11 +10,17 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using StellaOps.Canonical.Json;
using StellaOps.Scanner.AiMlSecurity.Models;
using StellaOps.Scanner.CryptoAnalysis.Models;
using StellaOps.Scanner.BuildProvenance.Models;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Entropy;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.EntryTrace.Serialization;
using StellaOps.Scanner.Reachability.Dependencies.Reporting;
using StellaOps.Scanner.Sarif.Models;
using StellaOps.Scanner.ServiceSecurity.Models;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.FS;
using StellaOps.Scanner.Storage.Catalog;
@@ -255,6 +261,177 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
}));
}
if (context.Analysis.TryGet<ServiceSecurityReport>(ScanAnalysisKeys.ServiceSecurityReport, out var serviceReport)
&& serviceReport is not null)
{
var reportBytes = SerializeCanonical(serviceReport);
var metadata = new Dictionary<string, string>
{
["findingCount"] = serviceReport.Summary.TotalFindings.ToString(CultureInfoInvariant)
};
if (!string.IsNullOrWhiteSpace(serviceReport.PolicyVersion))
{
metadata["policyVersion"] = serviceReport.PolicyVersion!;
}
payloads.Add(new SurfaceManifestPayload(
ArtifactDocumentType.SurfaceObservation,
ArtifactDocumentFormat.ObservationJson,
Kind: "service-security.report",
MediaType: "application/json",
Content: reportBytes,
View: "service-security",
Metadata: metadata));
}
if (context.Analysis.TryGet<CryptoAnalysisReport>(ScanAnalysisKeys.CryptoAnalysisReport, out var cryptoReport)
&& cryptoReport is not null)
{
var reportBytes = SerializeCanonical(cryptoReport);
var metadata = new Dictionary<string, string>
{
["findingCount"] = cryptoReport.Summary.TotalFindings.ToString(CultureInfoInvariant)
};
if (!string.IsNullOrWhiteSpace(cryptoReport.PolicyVersion))
{
metadata["policyVersion"] = cryptoReport.PolicyVersion!;
}
if (!cryptoReport.ComplianceStatus.Frameworks.IsDefaultOrEmpty)
{
metadata["frameworks"] = string.Join(
",",
cryptoReport.ComplianceStatus.Frameworks
.Select(framework => framework.Framework)
.OrderBy(framework => framework, StringComparer.OrdinalIgnoreCase));
}
if (cryptoReport.QuantumReadiness.TotalAlgorithms > 0)
{
metadata["quantumReadinessScore"] = cryptoReport.QuantumReadiness.Score.ToString(CultureInfoInvariant);
}
payloads.Add(new SurfaceManifestPayload(
ArtifactDocumentType.SurfaceObservation,
ArtifactDocumentFormat.ObservationJson,
Kind: "crypto-analysis.report",
MediaType: "application/json",
Content: reportBytes,
View: "crypto-analysis",
Metadata: metadata));
}
if (context.Analysis.TryGet<AiMlSecurityReport>(ScanAnalysisKeys.AiMlSecurityReport, out var aiReport)
&& aiReport is not null)
{
var reportBytes = SerializeCanonical(aiReport);
var metadata = new Dictionary<string, string>
{
["findingCount"] = aiReport.Summary.TotalFindings.ToString(CultureInfoInvariant),
["modelCount"] = aiReport.Summary.ModelCount.ToString(CultureInfoInvariant),
["datasetCount"] = aiReport.Summary.DatasetCount.ToString(CultureInfoInvariant)
};
if (!string.IsNullOrWhiteSpace(aiReport.PolicyVersion))
{
metadata["policyVersion"] = aiReport.PolicyVersion!;
}
if (!aiReport.ComplianceStatus.Frameworks.IsDefaultOrEmpty)
{
metadata["frameworks"] = string.Join(
",",
aiReport.ComplianceStatus.Frameworks
.Select(framework => framework.Framework)
.OrderBy(framework => framework, StringComparer.OrdinalIgnoreCase));
}
payloads.Add(new SurfaceManifestPayload(
ArtifactDocumentType.SurfaceObservation,
ArtifactDocumentFormat.ObservationJson,
Kind: "ai-ml-security.report",
MediaType: "application/json",
Content: reportBytes,
View: "ai-ml-security",
Metadata: metadata));
}
if (context.Analysis.TryGet<BuildProvenanceReport>(ScanAnalysisKeys.BuildProvenanceReport, out var provenanceReport)
&& provenanceReport is not null)
{
var reportBytes = SerializeCanonical(provenanceReport);
var metadata = new Dictionary<string, string>
{
["findingCount"] = provenanceReport.Summary.TotalFindings.ToString(CultureInfoInvariant),
["slsaLevel"] = provenanceReport.AchievedLevel.ToString(),
["reproducibility"] = provenanceReport.ReproducibilityStatus.State.ToString()
};
if (!string.IsNullOrWhiteSpace(provenanceReport.PolicyVersion))
{
metadata["policyVersion"] = provenanceReport.PolicyVersion!;
}
payloads.Add(new SurfaceManifestPayload(
ArtifactDocumentType.SurfaceObservation,
ArtifactDocumentFormat.ObservationJson,
Kind: "build-provenance.report",
MediaType: "application/json",
Content: reportBytes,
View: "build-provenance",
Metadata: metadata));
}
if (context.Analysis.TryGet<DependencyReachabilityReport>(ScanAnalysisKeys.DependencyReachabilityReport, out var reachabilityReport)
&& reachabilityReport is not null)
{
var reportBytes = SerializeCanonical(reachabilityReport);
var metadata = new Dictionary<string, string>
{
["componentCount"] = reachabilityReport.Summary.ComponentStatistics.TotalComponents.ToString(CultureInfoInvariant),
["vulnerabilityCount"] = reachabilityReport.Summary.VulnerabilityStatistics.TotalVulnerabilities.ToString(CultureInfoInvariant),
["filteredCount"] = reachabilityReport.Summary.VulnerabilityStatistics.FilteredVulnerabilities.ToString(CultureInfoInvariant),
["reductionPercent"] = reachabilityReport.Summary.FalsePositiveReductionPercent.ToString("0.####", CultureInfoInvariant),
["analysisMode"] = reachabilityReport.AnalysisMode.ToString()
};
payloads.Add(new SurfaceManifestPayload(
ArtifactDocumentType.SurfaceObservation,
ArtifactDocumentFormat.ObservationJson,
Kind: "reachability.report",
MediaType: "application/json",
Content: reportBytes,
View: "reachability",
Metadata: metadata));
}
if (context.Analysis.TryGet<SarifLog>(ScanAnalysisKeys.DependencyReachabilitySarif, out var reachabilitySarif)
&& reachabilitySarif is not null)
{
var sarifBytes = SerializeCanonical(reachabilitySarif);
payloads.Add(new SurfaceManifestPayload(
ArtifactDocumentType.SurfaceObservation,
ArtifactDocumentFormat.SarifJson,
Kind: "reachability.report.sarif",
MediaType: "application/sarif+json",
Content: sarifBytes,
View: "reachability"));
}
if (context.Analysis.TryGet<string>(ScanAnalysisKeys.DependencyReachabilityDot, out var reachabilityDot)
&& !string.IsNullOrWhiteSpace(reachabilityDot))
{
payloads.Add(new SurfaceManifestPayload(
ArtifactDocumentType.SurfaceObservation,
ArtifactDocumentFormat.GraphVizDot,
Kind: "reachability.graph.dot",
MediaType: "text/vnd.graphviz",
Content: Encoding.UTF8.GetBytes(reachabilityDot),
View: "reachability"));
}
return payloads;
}

View File

@@ -8,6 +8,7 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Gate;
using StellaOps.Scanner.Worker.Metrics;
@@ -140,6 +141,19 @@ public sealed class VexGateStageExecutor : IScanStageExecutor
}
}
if (context.Analysis.TryGet<IReadOnlyList<VulnerabilityMatch>>(ScanAnalysisKeys.VulnerabilityMatches, out var matches)
&& matches is not null)
{
foreach (var match in matches)
{
var gateFinding = ConvertToGateFinding(match);
if (gateFinding is not null)
{
findings.Add(gateFinding);
}
}
}
return findings;
}
@@ -215,6 +229,7 @@ public sealed class VexGateStageExecutor : IScanStageExecutor
else
{
var vulnIdProperty = findingType.GetProperty("VulnerabilityId");
vulnIdProperty ??= findingType.GetProperty("VulnId");
if (vulnIdProperty?.GetValue(finding) is string vid && !string.IsNullOrWhiteSpace(vid))
{
vulnId = vid;
@@ -242,6 +257,15 @@ public sealed class VexGateStageExecutor : IScanStageExecutor
}
}
if (string.IsNullOrWhiteSpace(purl))
{
var componentRefProperty = findingType.GetProperty("ComponentRef");
if (componentRefProperty?.GetValue(finding) is string componentRef)
{
purl = componentRef;
}
}
// Extract finding ID
string findingId;
var idProperty = findingType.GetProperty("FindingId") ?? findingType.GetProperty("Id");