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

@@ -658,6 +658,8 @@ internal sealed class RuntimeInventoryReconciler : IRuntimeInventoryReconciler
ArtifactDocumentFormat.EntryTraceNdjson => "entrytrace-ndjson",
ArtifactDocumentFormat.EntryTraceGraphJson => "entrytrace-graph-json",
ArtifactDocumentFormat.ComponentFragmentJson => "component-fragment-json",
ArtifactDocumentFormat.SarifJson => "sarif-json",
ArtifactDocumentFormat.GraphVizDot => "graphviz-dot",
_ => format.ToString().ToLowerInvariant()
};

View File

@@ -191,6 +191,8 @@ internal sealed class SurfacePointerService : ISurfacePointerService
ArtifactDocumentFormat.EntryTraceGraphJson => "entrytrace.graph",
ArtifactDocumentFormat.EntryTraceNdjson => "entrytrace.ndjson",
ArtifactDocumentFormat.ComponentFragmentJson => "layer.fragments",
ArtifactDocumentFormat.SarifJson => "sarif-json",
ArtifactDocumentFormat.GraphVizDot => "graphviz-dot",
_ => format.ToString().ToLowerInvariant()
};

View File

@@ -38,6 +38,36 @@ public sealed class ScannerWorkerOptions
public VerdictPushOptions VerdictPush { get; } = new();
/// <summary>
/// Options for service security analysis.
/// Sprint: SPRINT_20260119_016 - Service Security Analysis
/// </summary>
public ServiceSecurityOptions ServiceSecurity { get; } = new();
/// <summary>
/// Options for crypto (CBOM) analysis.
/// Sprint: SPRINT_20260119_017 - CBOM Crypto Analysis
/// </summary>
public CryptoAnalysisOptions CryptoAnalysis { get; } = new();
/// <summary>
/// Options for AI/ML supply chain analysis.
/// Sprint: SPRINT_20260119_018 - AI/ML Supply Chain Security
/// </summary>
public AiMlSecurityOptions AiMlSecurity { get; } = new();
/// <summary>
/// Options for build provenance verification.
/// Sprint: SPRINT_20260119_019 - Build Provenance Verification
/// </summary>
public BuildProvenanceOptions BuildProvenance { get; } = new();
/// <summary>
/// Options for SBOM dependency reachability analysis.
/// Sprint: SPRINT_20260119_022 - Dependency reachability inference
/// </summary>
public ReachabilityOptions Reachability { get; } = new();
/// <summary>
/// Options for secrets leak detection scanning.
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
@@ -318,6 +348,181 @@ public sealed class ScannerWorkerOptions
public bool AllowAnonymousFallback { get; set; } = true;
}
/// <summary>
/// Options for service security analysis.
/// </summary>
public sealed class ServiceSecurityOptions
{
/// <summary>
/// Enable service security analysis.
/// When disabled, the service security stage will be skipped.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Path to the service security policy file (YAML or JSON).
/// </summary>
public string? PolicyPath { get; set; }
/// <summary>
/// Metadata key used to locate the SBOM file path.
/// </summary>
public string SbomPathMetadataKey { get; set; } = ScanMetadataKeys.SbomPath;
/// <summary>
/// Metadata key used to identify the SBOM format.
/// </summary>
public string SbomFormatMetadataKey { get; set; } = ScanMetadataKeys.SbomFormat;
}
/// <summary>
/// Options for crypto (CBOM) analysis.
/// </summary>
public sealed class CryptoAnalysisOptions
{
/// <summary>
/// Enable crypto analysis.
/// When disabled, the crypto analysis stage will be skipped.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Path to the crypto policy file (YAML or JSON).
/// </summary>
public string? PolicyPath { get; set; }
/// <summary>
/// Require FIPS compliance checks even if policy does not specify a framework.
/// </summary>
public bool RequireFips { get; set; }
/// <summary>
/// Enable post-quantum analysis regardless of policy configuration.
/// </summary>
public bool EnablePostQuantumAnalysis { get; set; }
/// <summary>
/// Metadata key used to locate the SBOM file path.
/// </summary>
public string SbomPathMetadataKey { get; set; } = ScanMetadataKeys.SbomPath;
/// <summary>
/// Metadata key used to identify the SBOM format.
/// </summary>
public string SbomFormatMetadataKey { get; set; } = ScanMetadataKeys.SbomFormat;
}
/// <summary>
/// Options for AI/ML supply chain analysis.
/// </summary>
public sealed class AiMlSecurityOptions
{
/// <summary>
/// Enable AI/ML security analysis.
/// When disabled, the AI/ML stage will be skipped.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Path to AI governance policy file (YAML or JSON).
/// </summary>
public string? PolicyPath { get; set; }
/// <summary>
/// Require explicit risk assessment even if policy does not specify it.
/// </summary>
public bool RequireRiskAssessment { get; set; }
/// <summary>
/// Enable model binary analysis (requires ML embedding services).
/// </summary>
public bool EnableBinaryAnalysis { get; set; }
/// <summary>
/// Metadata key used to locate the SBOM file path.
/// </summary>
public string SbomPathMetadataKey { get; set; } = ScanMetadataKeys.SbomPath;
/// <summary>
/// Metadata key used to identify the SBOM format.
/// </summary>
public string SbomFormatMetadataKey { get; set; } = ScanMetadataKeys.SbomFormat;
}
/// <summary>
/// Options for build provenance verification.
/// </summary>
public sealed class BuildProvenanceOptions
{
/// <summary>
/// Enable build provenance verification.
/// When disabled, the build provenance stage will be skipped.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Path to the build provenance policy file (YAML or JSON).
/// </summary>
public string? PolicyPath { get; set; }
/// <summary>
/// Trigger reproducibility verification (rebuild) when possible.
/// </summary>
public bool VerifyReproducibility { get; set; }
/// <summary>
/// Require reproducible build verification for compliance.
/// </summary>
public bool RequireReproducible { get; set; }
/// <summary>
/// Metadata key used to locate the SBOM file path.
/// </summary>
public string SbomPathMetadataKey { get; set; } = ScanMetadataKeys.SbomPath;
/// <summary>
/// Metadata key used to identify the SBOM format.
/// </summary>
public string SbomFormatMetadataKey { get; set; } = ScanMetadataKeys.SbomFormat;
}
/// <summary>
/// Options for SBOM dependency reachability analysis.
/// </summary>
public sealed class ReachabilityOptions
{
/// <summary>
/// Enable dependency reachability analysis.
/// When disabled, the reachability stage will be skipped.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Path to the reachability policy file (YAML or JSON).
/// </summary>
public string? PolicyPath { get; set; }
/// <summary>
/// Metadata key used to locate the SBOM file path.
/// </summary>
public string SbomPathMetadataKey { get; set; } = ScanMetadataKeys.SbomPath;
/// <summary>
/// Metadata key used to identify the SBOM format.
/// </summary>
public string SbomFormatMetadataKey { get; set; } = ScanMetadataKeys.SbomFormat;
/// <summary>
/// Metadata key used to locate an optional RichGraph call graph.
/// </summary>
public string CallGraphPathMetadataKey { get; set; } = ScanMetadataKeys.ReachabilityCallGraphPath;
/// <summary>
/// Include unreachable vulnerabilities in downstream matches and reports.
/// </summary>
public bool IncludeUnreachableVulnerabilities { get; set; }
}
/// <summary>
/// Options for secrets leak detection scanning.
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection

View File

@@ -105,6 +105,71 @@ public sealed class ScannerWorkerOptionsValidator : IValidateOptions<ScannerWork
}
}
if (options.ServiceSecurity.Enabled)
{
if (string.IsNullOrWhiteSpace(options.ServiceSecurity.SbomPathMetadataKey))
{
failures.Add("Scanner.Worker:ServiceSecurity:SbomPathMetadataKey must be provided when ServiceSecurity is enabled.");
}
if (string.IsNullOrWhiteSpace(options.ServiceSecurity.SbomFormatMetadataKey))
{
failures.Add("Scanner.Worker:ServiceSecurity:SbomFormatMetadataKey must be provided when ServiceSecurity is enabled.");
}
}
if (options.CryptoAnalysis.Enabled)
{
if (string.IsNullOrWhiteSpace(options.CryptoAnalysis.SbomPathMetadataKey))
{
failures.Add("Scanner.Worker:CryptoAnalysis:SbomPathMetadataKey must be provided when CryptoAnalysis is enabled.");
}
if (string.IsNullOrWhiteSpace(options.CryptoAnalysis.SbomFormatMetadataKey))
{
failures.Add("Scanner.Worker:CryptoAnalysis:SbomFormatMetadataKey must be provided when CryptoAnalysis is enabled.");
}
}
if (options.AiMlSecurity.Enabled)
{
if (string.IsNullOrWhiteSpace(options.AiMlSecurity.SbomPathMetadataKey))
{
failures.Add("Scanner.Worker:AiMlSecurity:SbomPathMetadataKey must be provided when AiMlSecurity is enabled.");
}
if (string.IsNullOrWhiteSpace(options.AiMlSecurity.SbomFormatMetadataKey))
{
failures.Add("Scanner.Worker:AiMlSecurity:SbomFormatMetadataKey must be provided when AiMlSecurity is enabled.");
}
}
if (options.BuildProvenance.Enabled)
{
if (string.IsNullOrWhiteSpace(options.BuildProvenance.SbomPathMetadataKey))
{
failures.Add("Scanner.Worker:BuildProvenance:SbomPathMetadataKey must be provided when BuildProvenance is enabled.");
}
if (string.IsNullOrWhiteSpace(options.BuildProvenance.SbomFormatMetadataKey))
{
failures.Add("Scanner.Worker:BuildProvenance:SbomFormatMetadataKey must be provided when BuildProvenance is enabled.");
}
}
if (options.Reachability.Enabled)
{
if (string.IsNullOrWhiteSpace(options.Reachability.SbomPathMetadataKey))
{
failures.Add("Scanner.Worker:Reachability:SbomPathMetadataKey must be provided when Reachability is enabled.");
}
if (string.IsNullOrWhiteSpace(options.Reachability.SbomFormatMetadataKey))
{
failures.Add("Scanner.Worker:Reachability:SbomFormatMetadataKey must be provided when Reachability is enabled.");
}
}
if (options.Telemetry.EnableTelemetry)
{
if (!options.Telemetry.EnableMetrics && !options.Telemetry.EnableTracing)

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");

View File

@@ -6,9 +6,13 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Client;
using StellaOps.Concelier.SbomIntegration;
using StellaOps.Concelier.SbomIntegration.Parsing;
using StellaOps.Configuration;
using StellaOps.Scanner.Cache;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Reachability.Dependencies;
using StellaOps.Scanner.Reachability.Dependencies.Reporting;
using StellaOps.Scanner.Reachability.Gates;
using StellaOps.Scanner.Analyzers.OS.Plugin;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
@@ -22,12 +26,18 @@ using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.FS;
using StellaOps.Scanner.Surface.Secrets;
using StellaOps.Scanner.Surface.Validation;
using StellaOps.Scanner.CryptoAnalysis;
using StellaOps.Scanner.ServiceSecurity;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Hosting;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
using StellaOps.Scanner.Worker.Processing.AiMlSecurity;
using StellaOps.Scanner.Worker.Processing.BuildProvenance;
using StellaOps.Scanner.Worker.Processing.Entropy;
using StellaOps.Scanner.Worker.Processing.Secrets;
using StellaOps.Scanner.Worker.Processing.ServiceSecurity;
using StellaOps.Scanner.Worker.Processing.CryptoAnalysis;
using StellaOps.Scanner.Worker.Determinism;
using StellaOps.Scanner.Analyzers.Secrets;
using StellaOps.Scanner.Worker.Extensions;
@@ -35,6 +45,10 @@ using StellaOps.Scanner.Worker.Processing.Surface;
using StellaOps.Scanner.Storage.Extensions;
using StellaOps.Scanner.Storage;
using StellaOps.Scanner.Storage.Services;
using StellaOps.BinaryIndex.ML;
using StellaOps.Scanner.AiMlSecurity;
using StellaOps.Scanner.BuildProvenance;
using StellaOps.Scanner.Sarif;
using Reachability = StellaOps.Scanner.Worker.Processing.Reachability;
using ReachabilityEvidenceStageExecutor = StellaOps.Scanner.Worker.Processing.Reachability.ReachabilityEvidenceStageExecutor;
using GateDetectors = StellaOps.Scanner.Reachability.Gates.Detectors;
@@ -177,6 +191,58 @@ builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityBuild
builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityPublishStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, EntropyStageExecutor>();
// Service Security Analysis (Sprint: SPRINT_20260119_016)
if (workerOptions.ServiceSecurity.Enabled)
{
builder.Services.TryAddSingleton<ISbomParser, SbomParser>();
builder.Services.TryAddSingleton<IParsedSbomParser, ParsedSbomParser>();
builder.Services.AddServiceSecurity();
builder.Services.AddSingleton<IScanStageExecutor, ServiceSecurityStageExecutor>();
}
// CBOM Crypto Analysis (Sprint: SPRINT_20260119_017)
if (workerOptions.CryptoAnalysis.Enabled)
{
builder.Services.TryAddSingleton<ISbomParser, SbomParser>();
builder.Services.TryAddSingleton<IParsedSbomParser, ParsedSbomParser>();
builder.Services.AddCryptoAnalysis();
builder.Services.AddSingleton<IScanStageExecutor, CryptoAnalysisStageExecutor>();
}
// AI/ML Supply Chain Security (Sprint: SPRINT_20260119_018)
if (workerOptions.AiMlSecurity.Enabled)
{
builder.Services.TryAddSingleton<ISbomParser, SbomParser>();
builder.Services.TryAddSingleton<IParsedSbomParser, ParsedSbomParser>();
builder.Services.AddAiMlSecurity();
if (workerOptions.AiMlSecurity.EnableBinaryAnalysis)
{
builder.Services.AddMlServices();
}
builder.Services.AddSingleton<IScanStageExecutor, AiMlSecurityStageExecutor>();
}
// Build Provenance Verification (Sprint: SPRINT_20260119_019)
if (workerOptions.BuildProvenance.Enabled)
{
builder.Services.TryAddSingleton<ISbomParser, SbomParser>();
builder.Services.TryAddSingleton<IParsedSbomParser, ParsedSbomParser>();
builder.Services.AddBuildProvenance();
builder.Services.AddSingleton<IScanStageExecutor, BuildProvenanceStageExecutor>();
}
// SBOM Dependency Reachability (Sprint: SPRINT_20260119_022)
if (workerOptions.Reachability.Enabled)
{
builder.Services.TryAddSingleton<ISbomParser, SbomParser>();
builder.Services.TryAddSingleton<IParsedSbomParser, ParsedSbomParser>();
builder.Services.TryAddSingleton<IReachabilityPolicyLoader, ReachabilityPolicyLoader>();
builder.Services.TryAddSingleton<ISbomAdvisoryMatcher, Reachability.NullSbomAdvisoryMatcher>();
builder.Services.TryAddSingleton<ISarifExportService, SarifExportService>();
builder.Services.TryAddSingleton<DependencyReachabilityReporter>();
builder.Services.AddSingleton<IScanStageExecutor, Reachability.SbomReachabilityStageExecutor>();
}
// Secrets Leak Detection (Sprint: SPRINT_20251229_046_BE)
if (workerOptions.Secrets.Enabled)
{

View File

@@ -35,7 +35,14 @@
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.ServiceSecurity/StellaOps.Scanner.ServiceSecurity.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Sarif/StellaOps.Scanner.Sarif.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.CryptoAnalysis/StellaOps.Scanner.CryptoAnalysis.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.AiMlSecurity/StellaOps.Scanner.AiMlSecurity.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.BuildProvenance/StellaOps.Scanner.BuildProvenance.csproj" />
<ProjectReference Include="../StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj" />
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.SbomIntegration/StellaOps.Concelier.SbomIntegration.csproj" />
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../../Unknowns/__Libraries/StellaOps.Unknowns.Core/StellaOps.Unknowns.Core.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Core/StellaOps.BinaryIndex.Core.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Persistence/StellaOps.BinaryIndex.Persistence.csproj" />
@@ -43,6 +50,7 @@
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Disassembly/StellaOps.BinaryIndex.Disassembly.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Disassembly.Abstractions/StellaOps.BinaryIndex.Disassembly.Abstractions.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Normalization/StellaOps.BinaryIndex.Normalization.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.ML/StellaOps.BinaryIndex.ML.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestor/StellaOps.Attestor.Core/StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="../../Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
</ItemGroup>

View File

@@ -447,149 +447,149 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Scanner.WebServic
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Scanner.Worker.Tests", "StellaOps.Scanner.Worker.Tests", "{C26F680C-684A-ECC6-BB6C-EBD19DC43B4C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Importer", "E:\dev\git.stella-ops.org\src\AirGap\StellaOps.AirGap.Importer\StellaOps.AirGap.Importer.csproj", "{22B129C7-C609-3B90-AD56-64C746A1505E}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Importer", "..\\AirGap\StellaOps.AirGap.Importer\StellaOps.AirGap.Importer.csproj", "{22B129C7-C609-3B90-AD56-64C746A1505E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Policy", "E:\dev\git.stella-ops.org\src\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj", "{AD31623A-BC43-52C2-D906-AC1D8784A541}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Policy", "..\\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj", "{AD31623A-BC43-52C2-D906-AC1D8784A541}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "E:\dev\git.stella-ops.org\src\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{776E2142-804F-03B9-C804-D061D64C6092}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "..\\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{776E2142-804F-03B9-C804-D061D64C6092}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Core", "E:\dev\git.stella-ops.org\src\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj", "{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Core", "..\\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj", "{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Envelope", "E:\dev\git.stella-ops.org\src\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj", "{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Envelope", "..\\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj", "{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.GraphRoot", "E:\dev\git.stella-ops.org\src\Attestor\__Libraries\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj", "{2609BC1A-6765-29BE-78CC-C0F1D2814F10}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.GraphRoot", "..\\Attestor\__Libraries\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj", "{2609BC1A-6765-29BE-78CC-C0F1D2814F10}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.ProofChain", "E:\dev\git.stella-ops.org\src\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj", "{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.ProofChain", "..\\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj", "{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{DE5BF139-1E5C-D6EA-4FAA-661EF353A194}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..\\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{DE5BF139-1E5C-D6EA-4FAA-661EF353A194}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Security", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj", "{335E62C0-9E69-A952-680B-753B1B17C6D0}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Security", "..\\__Libraries\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj", "{335E62C0-9E69-A952-680B-753B1B17C6D0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Core", "E:\dev\git.stella-ops.org\src\Authority\__Libraries\StellaOps.Authority.Core\StellaOps.Authority.Core.csproj", "{5A6CD890-8142-F920-3734-D67CA3E65F61}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Core", "..\\Authority\__Libraries\StellaOps.Authority.Core\StellaOps.Authority.Core.csproj", "{5A6CD890-8142-F920-3734-D67CA3E65F61}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Persistence", "E:\dev\git.stella-ops.org\src\Authority\__Libraries\StellaOps.Authority.Persistence\StellaOps.Authority.Persistence.csproj", "{A260E14F-DBA4-862E-53CD-18D3B92ADA3D}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Persistence", "..\\Authority\__Libraries\StellaOps.Authority.Persistence\StellaOps.Authority.Persistence.csproj", "{A260E14F-DBA4-862E-53CD-18D3B92ADA3D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{97F94029-5419-6187-5A63-5C8FD9232FAE}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{97F94029-5419-6187-5A63-5C8FD9232FAE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Contracts", "E:\dev\git.stella-ops.org\src\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Contracts\StellaOps.BinaryIndex.Contracts.csproj", "{03DF5914-2390-A82D-7464-642D0B95E068}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Contracts", "..\\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Contracts\StellaOps.BinaryIndex.Contracts.csproj", "{03DF5914-2390-A82D-7464-642D0B95E068}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Core", "E:\dev\git.stella-ops.org\src\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Core\StellaOps.BinaryIndex.Core.csproj", "{CF633BDA-9F2E-D0C8-702F-BC9D27363B4B}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Core", "..\\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Core\StellaOps.BinaryIndex.Core.csproj", "{CF633BDA-9F2E-D0C8-702F-BC9D27363B4B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Corpus", "E:\dev\git.stella-ops.org\src\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Corpus\StellaOps.BinaryIndex.Corpus.csproj", "{73DE9C04-CEFE-53BA-A527-3A36D478DEFE}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Corpus", "..\\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Corpus\StellaOps.BinaryIndex.Corpus.csproj", "{73DE9C04-CEFE-53BA-A527-3A36D478DEFE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Fingerprints", "E:\dev\git.stella-ops.org\src\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Fingerprints\StellaOps.BinaryIndex.Fingerprints.csproj", "{B7DC1B0A-EBD8-B1E8-28C8-9D5F19E118AD}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Fingerprints", "..\\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Fingerprints\StellaOps.BinaryIndex.Fingerprints.csproj", "{B7DC1B0A-EBD8-B1E8-28C8-9D5F19E118AD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.FixIndex", "E:\dev\git.stella-ops.org\src\BinaryIndex\__Libraries\StellaOps.BinaryIndex.FixIndex\StellaOps.BinaryIndex.FixIndex.csproj", "{0B56708E-B56C-E058-DE31-FCDFF30031F7}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.FixIndex", "..\\BinaryIndex\__Libraries\StellaOps.BinaryIndex.FixIndex\StellaOps.BinaryIndex.FixIndex.csproj", "{0B56708E-B56C-E058-DE31-FCDFF30031F7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Persistence", "E:\dev\git.stella-ops.org\src\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Persistence\StellaOps.BinaryIndex.Persistence.csproj", "{78FAD457-CE1B-D78E-A602-510EAD85E0AF}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Persistence", "..\\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Persistence\StellaOps.BinaryIndex.Persistence.csproj", "{78FAD457-CE1B-D78E-A602-510EAD85E0AF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "..\\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Cache.Valkey", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.Cache.Valkey\StellaOps.Concelier.Cache.Valkey.csproj", "{AB6AE2B6-8D6B-2D9F-2A88-7C596C59F4FC}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Cache.Valkey", "..\\Concelier\__Libraries\StellaOps.Concelier.Cache.Valkey\StellaOps.Concelier.Cache.Valkey.csproj", "{AB6AE2B6-8D6B-2D9F-2A88-7C596C59F4FC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Common", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.Connector.Common\StellaOps.Concelier.Connector.Common.csproj", "{375F5AD0-F7EE-1782-7B34-E181CDB61B9F}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Common", "..\\Concelier\__Libraries\StellaOps.Concelier.Connector.Common\StellaOps.Concelier.Connector.Common.csproj", "{375F5AD0-F7EE-1782-7B34-E181CDB61B9F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Core", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj", "{BA45605A-1CCE-6B0C-489D-C113915B243F}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Core", "..\\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj", "{BA45605A-1CCE-6B0C-489D-C113915B243F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Interest", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.Interest\StellaOps.Concelier.Interest.csproj", "{9D31FC8A-2A69-B78A-D3E5-4F867B16D971}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Interest", "..\\Concelier\__Libraries\StellaOps.Concelier.Interest\StellaOps.Concelier.Interest.csproj", "{9D31FC8A-2A69-B78A-D3E5-4F867B16D971}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Merge", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.Merge\StellaOps.Concelier.Merge.csproj", "{92268008-FBB0-C7AD-ECC2-7B75BED9F5E1}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Merge", "..\\Concelier\__Libraries\StellaOps.Concelier.Merge\StellaOps.Concelier.Merge.csproj", "{92268008-FBB0-C7AD-ECC2-7B75BED9F5E1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Models", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj", "{8DCCAF70-D364-4C8B-4E90-AF65091DE0C5}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Models", "..\\Concelier\__Libraries\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj", "{8DCCAF70-D364-4C8B-4E90-AF65091DE0C5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Normalization", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj", "{7828C164-DD01-2809-CCB3-364486834F60}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Normalization", "..\\Concelier\__Libraries\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj", "{7828C164-DD01-2809-CCB3-364486834F60}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Persistence", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.Persistence\StellaOps.Concelier.Persistence.csproj", "{DE95E7B2-0937-A980-441F-829E023BC43E}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Persistence", "..\\Concelier\__Libraries\StellaOps.Concelier.Persistence\StellaOps.Concelier.Persistence.csproj", "{DE95E7B2-0937-A980-441F-829E023BC43E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.ProofService", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.ProofService\StellaOps.Concelier.ProofService.csproj", "{91D69463-23E2-E2C7-AA7E-A78B13CED620}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.ProofService", "..\\Concelier\__Libraries\StellaOps.Concelier.ProofService\StellaOps.Concelier.ProofService.csproj", "{91D69463-23E2-E2C7-AA7E-A78B13CED620}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.RawModels", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj", "{34EFF636-81A7-8DF6-7CC9-4DA784BAC7F3}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.RawModels", "..\\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj", "{34EFF636-81A7-8DF6-7CC9-4DA784BAC7F3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.SbomIntegration", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.SbomIntegration\StellaOps.Concelier.SbomIntegration.csproj", "{5DCF16A8-97C6-2CB4-6A63-0370239039EB}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.SbomIntegration", "..\\Concelier\__Libraries\StellaOps.Concelier.SbomIntegration\StellaOps.Concelier.SbomIntegration.csproj", "{5DCF16A8-97C6-2CB4-6A63-0370239039EB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.SourceIntel", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.SourceIntel\StellaOps.Concelier.SourceIntel.csproj", "{EB093C48-CDAC-106B-1196-AE34809B34C0}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.SourceIntel", "..\\Concelier\__Libraries\StellaOps.Concelier.SourceIntel\StellaOps.Concelier.SourceIntel.csproj", "{EB093C48-CDAC-106B-1196-AE34809B34C0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{92C62F7B-8028-6EE1-B71B-F45F459B8E97}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{92C62F7B-8028-6EE1-B71B-F45F459B8E97}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F664A948-E352-5808-E780-77A03F19E93E}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F664A948-E352-5808-E780-77A03F19E93E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{FA83F778-5252-0B80-5555-E69F790322EA}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "..\\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{FA83F778-5252-0B80-5555-E69F790322EA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Kms", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj", "{F3A27846-6DE0-3448-222C-25A273E86B2E}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Kms", "..\\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj", "{F3A27846-6DE0-3448-222C-25A273E86B2E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.BouncyCastle", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj", "{166F4DEC-9886-92D5-6496-085664E9F08F}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.BouncyCastle", "..\\__Libraries\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj", "{166F4DEC-9886-92D5-6496-085664E9F08F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.CryptoPro", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.CryptoPro\StellaOps.Cryptography.Plugin.CryptoPro.csproj", "{C53E0895-879A-D9E6-0A43-24AD17A2F270}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.CryptoPro", "..\\__Libraries\StellaOps.Cryptography.Plugin.CryptoPro\StellaOps.Cryptography.Plugin.CryptoPro.csproj", "{C53E0895-879A-D9E6-0A43-24AD17A2F270}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.OfflineVerification", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.OfflineVerification\StellaOps.Cryptography.Plugin.OfflineVerification.csproj", "{246FCC7C-1437-742D-BAE5-E77A24164F08}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.OfflineVerification", "..\\__Libraries\StellaOps.Cryptography.Plugin.OfflineVerification\StellaOps.Cryptography.Plugin.OfflineVerification.csproj", "{246FCC7C-1437-742D-BAE5-E77A24164F08}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.OpenSslGost\StellaOps.Cryptography.Plugin.OpenSslGost.csproj", "{0AED303F-69E6-238F-EF80-81985080EDB7}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "..\\__Libraries\StellaOps.Cryptography.Plugin.OpenSslGost\StellaOps.Cryptography.Plugin.OpenSslGost.csproj", "{0AED303F-69E6-238F-EF80-81985080EDB7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.Pkcs11Gost\StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj", "{2904D288-CE64-A565-2C46-C2E85A96A1EE}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "..\\__Libraries\StellaOps.Cryptography.Plugin.Pkcs11Gost\StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj", "{2904D288-CE64-A565-2C46-C2E85A96A1EE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.PqSoft", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.PqSoft\StellaOps.Cryptography.Plugin.PqSoft.csproj", "{A6667CC3-B77F-023E-3A67-05F99E9FF46A}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.PqSoft", "..\\__Libraries\StellaOps.Cryptography.Plugin.PqSoft\StellaOps.Cryptography.Plugin.PqSoft.csproj", "{A6667CC3-B77F-023E-3A67-05F99E9FF46A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SimRemote", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SimRemote\StellaOps.Cryptography.Plugin.SimRemote.csproj", "{A26E2816-F787-F76B-1D6C-E086DD3E19CE}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SimRemote", "..\\__Libraries\StellaOps.Cryptography.Plugin.SimRemote\StellaOps.Cryptography.Plugin.SimRemote.csproj", "{A26E2816-F787-F76B-1D6C-E086DD3E19CE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmRemote", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SmRemote\StellaOps.Cryptography.Plugin.SmRemote.csproj", "{B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmRemote", "..\\__Libraries\StellaOps.Cryptography.Plugin.SmRemote\StellaOps.Cryptography.Plugin.SmRemote.csproj", "{B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmSoft", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj", "{90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmSoft", "..\\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj", "{90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.WineCsp", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.WineCsp\StellaOps.Cryptography.Plugin.WineCsp.csproj", "{059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.WineCsp", "..\\__Libraries\StellaOps.Cryptography.Plugin.WineCsp\StellaOps.Cryptography.Plugin.WineCsp.csproj", "{059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.PluginLoader", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.PluginLoader\StellaOps.Cryptography.PluginLoader.csproj", "{8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.PluginLoader", "..\\__Libraries\StellaOps.Cryptography.PluginLoader\StellaOps.Cryptography.PluginLoader.csproj", "{8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DeltaVerdict", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.DeltaVerdict\StellaOps.DeltaVerdict.csproj", "{EA0974E3-CD2B-5792-EF1E-9B5B7CCBDF00}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DeltaVerdict", "..\\__Libraries\StellaOps.DeltaVerdict\StellaOps.DeltaVerdict.csproj", "{EA0974E3-CD2B-5792-EF1E-9B5B7CCBDF00}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{632A1F0D-1BA5-C84B-B716-2BE638A92780}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{632A1F0D-1BA5-C84B-B716-2BE638A92780}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Evidence.Bundle", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Evidence.Bundle\StellaOps.Evidence.Bundle.csproj", "{9DE7852B-7E2D-257E-B0F1-45D2687854ED}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Evidence.Bundle", "..\\__Libraries\StellaOps.Evidence.Bundle\StellaOps.Evidence.Bundle.csproj", "{9DE7852B-7E2D-257E-B0F1-45D2687854ED}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Evidence.Core", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Evidence.Core\StellaOps.Evidence.Core.csproj", "{DC2AFC89-C3C8-4E9B-13A7-027EB6386EFA}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Evidence.Core", "..\\__Libraries\StellaOps.Evidence.Core\StellaOps.Evidence.Core.csproj", "{DC2AFC89-C3C8-4E9B-13A7-027EB6386EFA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Core", "E:\dev\git.stella-ops.org\src\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj", "{9151601C-8784-01A6-C2E7-A5C0FAAB0AEF}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Core", "..\\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj", "{9151601C-8784-01A6-C2E7-A5C0FAAB0AEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.BinaryAnalysis", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj", "{CB296A20-2732-77C1-7F23-27D5BAEDD0C7}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.BinaryAnalysis", "..\\Feedser\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj", "{CB296A20-2732-77C1-7F23-27D5BAEDD0C7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj", "{0DBEC9BA-FE1D-3898-B2C6-E4357DC23E0F}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core", "..\\Feedser\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj", "{0DBEC9BA-FE1D-3898-B2C6-E4357DC23E0F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.EfCore", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj", "{A63897D9-9531-989B-7309-E384BCFC2BB9}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.EfCore", "..\\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj", "{A63897D9-9531-989B-7309-E384BCFC2BB9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj", "{8C594D82-3463-3367-4F06-900AC707753D}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres", "..\\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj", "{8C594D82-3463-3367-4F06-900AC707753D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres.Testing", "E:\dev\git.stella-ops.org\src\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj", "{52F400CD-D473-7A1F-7986-89011CD2A887}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres.Testing", "..\\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj", "{52F400CD-D473-7A1F-7986-89011CD2A887}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{9588FBF9-C37E-D16E-2E8F-CFA226EAC01D}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "..\\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{9588FBF9-C37E-D16E-2E8F-CFA226EAC01D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging", "E:\dev\git.stella-ops.org\src\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj", "{97998C88-E6E1-D5E2-B632-537B58E00CBF}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging", "..\\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj", "{97998C88-E6E1-D5E2-B632-537B58E00CBF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice", "E:\dev\git.stella-ops.org\src\Router\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj", "{BAD08D96-A80A-D27F-5D9C-656AEEB3D568}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice", "..\\Router\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj", "{BAD08D96-A80A-D27F-5D9C-656AEEB3D568}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice.AspNetCore", "E:\dev\git.stella-ops.org\src\Router\__Libraries\StellaOps.Microservice.AspNetCore\StellaOps.Microservice.AspNetCore.csproj", "{F63694F1-B56D-6E72-3F5D-5D38B1541F0F}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice.AspNetCore", "..\\Router\__Libraries\StellaOps.Microservice.AspNetCore\StellaOps.Microservice.AspNetCore.csproj", "{F63694F1-B56D-6E72-3F5D-5D38B1541F0F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models", "E:\dev\git.stella-ops.org\src\Notify\__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj", "{20D1569C-2A47-38B8-075E-47225B674394}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models", "..\\Notify\__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj", "{20D1569C-2A47-38B8-075E-47225B674394}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{38A9EE9B-6FC8-93BC-0D43-2A906E678D66}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{38A9EE9B-6FC8-93BC-0D43-2A906E678D66}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy", "E:\dev\git.stella-ops.org\src\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj", "{19868E2D-7163-2108-1094-F13887C4F070}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy", "..\\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj", "{19868E2D-7163-2108-1094-F13887C4F070}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.RiskProfile", "E:\dev\git.stella-ops.org\src\Policy\StellaOps.Policy.RiskProfile\StellaOps.Policy.RiskProfile.csproj", "{CC319FC5-F4B1-C3DD-7310-4DAD343E0125}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.RiskProfile", "..\\Policy\StellaOps.Policy.RiskProfile\StellaOps.Policy.RiskProfile.csproj", "{CC319FC5-F4B1-C3DD-7310-4DAD343E0125}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provcache", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Provcache\StellaOps.Provcache.csproj", "{84F711C2-C210-28D2-F0D9-B13733FEE23D}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provcache", "..\\__Libraries\StellaOps.Provcache\StellaOps.Provcache.csproj", "{84F711C2-C210-28D2-F0D9-B13733FEE23D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Provenance\StellaOps.Provenance.csproj", "{CBB14B90-27F9-8DD6-DFC4-3507DBD1FBC6}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance", "..\\__Libraries\StellaOps.Provenance\StellaOps.Provenance.csproj", "{CBB14B90-27F9-8DD6-DFC4-3507DBD1FBC6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance.Attestation", "E:\dev\git.stella-ops.org\src\Provenance\StellaOps.Provenance.Attestation\StellaOps.Provenance.Attestation.csproj", "{A78EBC0F-C62C-8F56-95C0-330E376242A2}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance.Attestation", "..\\Provenance\StellaOps.Provenance.Attestation\StellaOps.Provenance.Attestation.csproj", "{A78EBC0F-C62C-8F56-95C0-330E376242A2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Replay.Core", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj", "{6D26FB21-7E48-024B-E5D4-E3F0F31976BB}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Replay.Core", "..\\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj", "{6D26FB21-7E48-024B-E5D4-E3F0F31976BB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.AspNet", "E:\dev\git.stella-ops.org\src\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj", "{79104479-B087-E5D0-5523-F1803282A246}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.AspNet", "..\\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj", "{79104479-B087-E5D0-5523-F1803282A246}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Common", "E:\dev\git.stella-ops.org\src\Router\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj", "{F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Common", "..\\Router\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj", "{F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Advisory", "__Libraries\StellaOps.Scanner.Advisory\StellaOps.Scanner.Advisory.csproj", "{FBD908D6-AF93-CC62-C09D-F0BB3E0CEA7F}"
EndProject
@@ -803,17 +803,17 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Worker",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Worker.Tests", "__Tests\StellaOps.Scanner.Worker.Tests\StellaOps.Scanner.Worker.Tests.csproj", "{505C6840-5113-26EC-CEDB-D07EEABEF94B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signals", "E:\dev\git.stella-ops.org\src\Signals\StellaOps.Signals\StellaOps.Signals.csproj", "{A79CBC0C-5313-4ECF-A24E-27CE236BCF2C}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signals", "..\\Signals\StellaOps.Signals\StellaOps.Signals.csproj", "{A79CBC0C-5313-4ECF-A24E-27CE236BCF2C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signer.Core", "E:\dev\git.stella-ops.org\src\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj", "{0AF13355-173C-3128-5AFC-D32E540DA3EF}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signer.Core", "..\\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj", "{0AF13355-173C-3128-5AFC-D32E540DA3EF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj", "{AF043113-CCE3-59C1-DF71-9804155F26A8}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "..\\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj", "{AF043113-CCE3-59C1-DF71-9804155F26A8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Unknowns.Core", "E:\dev\git.stella-ops.org\src\Unknowns\__Libraries\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj", "{15602821-2ABA-14BB-738D-1A53E1976E07}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Unknowns.Core", "..\\Unknowns\__Libraries\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj", "{15602821-2ABA-14BB-738D-1A53E1976E07}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VersionComparison", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.VersionComparison\StellaOps.VersionComparison.csproj", "{1D761F8B-921C-53BF-DCF5-5ABD329EEB0C}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VersionComparison", "..\\__Libraries\StellaOps.VersionComparison\StellaOps.VersionComparison.csproj", "{1D761F8B-921C-53BF-DCF5-5ABD329EEB0C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Core", "E:\dev\git.stella-ops.org\src\Zastava\__Libraries\StellaOps.Zastava.Core\StellaOps.Zastava.Core.csproj", "{DA7634C2-9156-9B79-7A1D-90D8E605DC8A}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Core", "..\\Zastava\__Libraries\StellaOps.Zastava.Core\StellaOps.Zastava.Core.csproj", "{DA7634C2-9156-9B79-7A1D-90D8E605DC8A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Analyzers.Native.Library.Tests", "__Tests\StellaOps.Scanner.Analyzers.Native.Library.Tests\StellaOps.Scanner.Analyzers.Native.Library.Tests.csproj", "{5C4EF841-B039-4899-BF6F-32DC4FDB7AE5}"
EndProject
@@ -883,6 +883,42 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VersionComparison
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres.Testing", "..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj", "{C6087B8C-3C57-4593-A340-A4D7BDCD8259}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.ServiceSecurity", "__Libraries\StellaOps.Scanner.ServiceSecurity\StellaOps.Scanner.ServiceSecurity.csproj", "{B8F48A1F-F911-455B-81E5-4E8180405D12}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Sarif", "__Libraries\StellaOps.Scanner.Sarif\StellaOps.Scanner.Sarif.csproj", "{37495115-54C5-4198-BB7B-4AD795421061}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.ServiceSecurity.Tests", "__Tests\StellaOps.Scanner.ServiceSecurity.Tests\StellaOps.Scanner.ServiceSecurity.Tests.csproj", "{2949EF87-5DC2-4399-B4C6-63E6992072A8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj", "{D91C82B9-D4D0-4AF9-B535-0DA1A3538DE6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.CryptoAnalysis", "__Libraries\StellaOps.Scanner.CryptoAnalysis\StellaOps.Scanner.CryptoAnalysis.csproj", "{3622AA85-EE4E-412C-93AE-D3B221EAF453}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.CryptoAnalysis.Tests", "__Tests\StellaOps.Scanner.CryptoAnalysis.Tests\StellaOps.Scanner.CryptoAnalysis.Tests.csproj", "{96C9DE89-5BCD-489F-9654-CE904480DDC6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.AiMlSecurity", "__Libraries\StellaOps.Scanner.AiMlSecurity\StellaOps.Scanner.AiMlSecurity.csproj", "{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.ML", "..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.ML\StellaOps.BinaryIndex.ML.csproj", "{D66D65AB-90F8-4E30-A91F-88F48200B6A5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Decompiler", "..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Decompiler\StellaOps.BinaryIndex.Decompiler.csproj", "{F139C3CD-09E3-4E7E-A475-4F25E43071DD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Ghidra", "..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Ghidra\StellaOps.BinaryIndex.Ghidra.csproj", "{553E0796-DC73-4F67-9FFD-E3B1369D5F6E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Disassembly.Abstractions", "..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Disassembly.Abstractions\StellaOps.BinaryIndex.Disassembly.Abstractions.csproj", "{4BC59250-7EA1-459B-9BE1-50EA8E8F623C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Contracts", "..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Contracts\StellaOps.BinaryIndex.Contracts.csproj", "{0C16255D-8995-40E5-90DF-326F55D66260}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Semantic", "..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Semantic\StellaOps.BinaryIndex.Semantic.csproj", "{84778A2B-C034-4D44-ABD7-A282EEA98080}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Disassembly", "..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Disassembly\StellaOps.BinaryIndex.Disassembly.csproj", "{AACD4F04-5572-487A-9BFE-ED1BF67B61F8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.AiMlSecurity.Tests", "__Tests\StellaOps.Scanner.AiMlSecurity.Tests\StellaOps.Scanner.AiMlSecurity.Tests.csproj", "{1990A8B0-12A9-4720-B569-97453B1879DC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.BuildProvenance", "__Libraries\StellaOps.Scanner.BuildProvenance\StellaOps.Scanner.BuildProvenance.csproj", "{54DE90D4-74F1-4198-8B30-B36418ECC79F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.GroundTruth.Reproducible", "..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.GroundTruth.Reproducible\StellaOps.BinaryIndex.GroundTruth.Reproducible.csproj", "{753DE639-AEC9-496C-B0D8-1B141B6D487E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.BuildProvenance.Tests", "__Tests\StellaOps.Scanner.BuildProvenance.Tests\StellaOps.Scanner.BuildProvenance.Tests.csproj", "{E97E3B77-7766-4C18-8558-0B06DE967A1D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -3509,6 +3545,222 @@ Global
{C6087B8C-3C57-4593-A340-A4D7BDCD8259}.Release|x64.Build.0 = Release|Any CPU
{C6087B8C-3C57-4593-A340-A4D7BDCD8259}.Release|x86.ActiveCfg = Release|Any CPU
{C6087B8C-3C57-4593-A340-A4D7BDCD8259}.Release|x86.Build.0 = Release|Any CPU
{B8F48A1F-F911-455B-81E5-4E8180405D12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8F48A1F-F911-455B-81E5-4E8180405D12}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8F48A1F-F911-455B-81E5-4E8180405D12}.Debug|x64.ActiveCfg = Debug|Any CPU
{B8F48A1F-F911-455B-81E5-4E8180405D12}.Debug|x64.Build.0 = Debug|Any CPU
{B8F48A1F-F911-455B-81E5-4E8180405D12}.Debug|x86.ActiveCfg = Debug|Any CPU
{B8F48A1F-F911-455B-81E5-4E8180405D12}.Debug|x86.Build.0 = Debug|Any CPU
{B8F48A1F-F911-455B-81E5-4E8180405D12}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8F48A1F-F911-455B-81E5-4E8180405D12}.Release|Any CPU.Build.0 = Release|Any CPU
{B8F48A1F-F911-455B-81E5-4E8180405D12}.Release|x64.ActiveCfg = Release|Any CPU
{B8F48A1F-F911-455B-81E5-4E8180405D12}.Release|x64.Build.0 = Release|Any CPU
{B8F48A1F-F911-455B-81E5-4E8180405D12}.Release|x86.ActiveCfg = Release|Any CPU
{B8F48A1F-F911-455B-81E5-4E8180405D12}.Release|x86.Build.0 = Release|Any CPU
{37495115-54C5-4198-BB7B-4AD795421061}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{37495115-54C5-4198-BB7B-4AD795421061}.Debug|Any CPU.Build.0 = Debug|Any CPU
{37495115-54C5-4198-BB7B-4AD795421061}.Debug|x64.ActiveCfg = Debug|Any CPU
{37495115-54C5-4198-BB7B-4AD795421061}.Debug|x64.Build.0 = Debug|Any CPU
{37495115-54C5-4198-BB7B-4AD795421061}.Debug|x86.ActiveCfg = Debug|Any CPU
{37495115-54C5-4198-BB7B-4AD795421061}.Debug|x86.Build.0 = Debug|Any CPU
{37495115-54C5-4198-BB7B-4AD795421061}.Release|Any CPU.ActiveCfg = Release|Any CPU
{37495115-54C5-4198-BB7B-4AD795421061}.Release|Any CPU.Build.0 = Release|Any CPU
{37495115-54C5-4198-BB7B-4AD795421061}.Release|x64.ActiveCfg = Release|Any CPU
{37495115-54C5-4198-BB7B-4AD795421061}.Release|x64.Build.0 = Release|Any CPU
{37495115-54C5-4198-BB7B-4AD795421061}.Release|x86.ActiveCfg = Release|Any CPU
{37495115-54C5-4198-BB7B-4AD795421061}.Release|x86.Build.0 = Release|Any CPU
{2949EF87-5DC2-4399-B4C6-63E6992072A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2949EF87-5DC2-4399-B4C6-63E6992072A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2949EF87-5DC2-4399-B4C6-63E6992072A8}.Debug|x64.ActiveCfg = Debug|Any CPU
{2949EF87-5DC2-4399-B4C6-63E6992072A8}.Debug|x64.Build.0 = Debug|Any CPU
{2949EF87-5DC2-4399-B4C6-63E6992072A8}.Debug|x86.ActiveCfg = Debug|Any CPU
{2949EF87-5DC2-4399-B4C6-63E6992072A8}.Debug|x86.Build.0 = Debug|Any CPU
{2949EF87-5DC2-4399-B4C6-63E6992072A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2949EF87-5DC2-4399-B4C6-63E6992072A8}.Release|Any CPU.Build.0 = Release|Any CPU
{2949EF87-5DC2-4399-B4C6-63E6992072A8}.Release|x64.ActiveCfg = Release|Any CPU
{2949EF87-5DC2-4399-B4C6-63E6992072A8}.Release|x64.Build.0 = Release|Any CPU
{2949EF87-5DC2-4399-B4C6-63E6992072A8}.Release|x86.ActiveCfg = Release|Any CPU
{2949EF87-5DC2-4399-B4C6-63E6992072A8}.Release|x86.Build.0 = Release|Any CPU
{D91C82B9-D4D0-4AF9-B535-0DA1A3538DE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D91C82B9-D4D0-4AF9-B535-0DA1A3538DE6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D91C82B9-D4D0-4AF9-B535-0DA1A3538DE6}.Debug|x64.ActiveCfg = Debug|Any CPU
{D91C82B9-D4D0-4AF9-B535-0DA1A3538DE6}.Debug|x64.Build.0 = Debug|Any CPU
{D91C82B9-D4D0-4AF9-B535-0DA1A3538DE6}.Debug|x86.ActiveCfg = Debug|Any CPU
{D91C82B9-D4D0-4AF9-B535-0DA1A3538DE6}.Debug|x86.Build.0 = Debug|Any CPU
{D91C82B9-D4D0-4AF9-B535-0DA1A3538DE6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D91C82B9-D4D0-4AF9-B535-0DA1A3538DE6}.Release|Any CPU.Build.0 = Release|Any CPU
{D91C82B9-D4D0-4AF9-B535-0DA1A3538DE6}.Release|x64.ActiveCfg = Release|Any CPU
{D91C82B9-D4D0-4AF9-B535-0DA1A3538DE6}.Release|x64.Build.0 = Release|Any CPU
{D91C82B9-D4D0-4AF9-B535-0DA1A3538DE6}.Release|x86.ActiveCfg = Release|Any CPU
{D91C82B9-D4D0-4AF9-B535-0DA1A3538DE6}.Release|x86.Build.0 = Release|Any CPU
{3622AA85-EE4E-412C-93AE-D3B221EAF453}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3622AA85-EE4E-412C-93AE-D3B221EAF453}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3622AA85-EE4E-412C-93AE-D3B221EAF453}.Debug|x64.ActiveCfg = Debug|Any CPU
{3622AA85-EE4E-412C-93AE-D3B221EAF453}.Debug|x64.Build.0 = Debug|Any CPU
{3622AA85-EE4E-412C-93AE-D3B221EAF453}.Debug|x86.ActiveCfg = Debug|Any CPU
{3622AA85-EE4E-412C-93AE-D3B221EAF453}.Debug|x86.Build.0 = Debug|Any CPU
{3622AA85-EE4E-412C-93AE-D3B221EAF453}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3622AA85-EE4E-412C-93AE-D3B221EAF453}.Release|Any CPU.Build.0 = Release|Any CPU
{3622AA85-EE4E-412C-93AE-D3B221EAF453}.Release|x64.ActiveCfg = Release|Any CPU
{3622AA85-EE4E-412C-93AE-D3B221EAF453}.Release|x64.Build.0 = Release|Any CPU
{3622AA85-EE4E-412C-93AE-D3B221EAF453}.Release|x86.ActiveCfg = Release|Any CPU
{3622AA85-EE4E-412C-93AE-D3B221EAF453}.Release|x86.Build.0 = Release|Any CPU
{96C9DE89-5BCD-489F-9654-CE904480DDC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{96C9DE89-5BCD-489F-9654-CE904480DDC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{96C9DE89-5BCD-489F-9654-CE904480DDC6}.Debug|x64.ActiveCfg = Debug|Any CPU
{96C9DE89-5BCD-489F-9654-CE904480DDC6}.Debug|x64.Build.0 = Debug|Any CPU
{96C9DE89-5BCD-489F-9654-CE904480DDC6}.Debug|x86.ActiveCfg = Debug|Any CPU
{96C9DE89-5BCD-489F-9654-CE904480DDC6}.Debug|x86.Build.0 = Debug|Any CPU
{96C9DE89-5BCD-489F-9654-CE904480DDC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{96C9DE89-5BCD-489F-9654-CE904480DDC6}.Release|Any CPU.Build.0 = Release|Any CPU
{96C9DE89-5BCD-489F-9654-CE904480DDC6}.Release|x64.ActiveCfg = Release|Any CPU
{96C9DE89-5BCD-489F-9654-CE904480DDC6}.Release|x64.Build.0 = Release|Any CPU
{96C9DE89-5BCD-489F-9654-CE904480DDC6}.Release|x86.ActiveCfg = Release|Any CPU
{96C9DE89-5BCD-489F-9654-CE904480DDC6}.Release|x86.Build.0 = Release|Any CPU
{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53}.Debug|x64.ActiveCfg = Debug|Any CPU
{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53}.Debug|x64.Build.0 = Debug|Any CPU
{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53}.Debug|x86.ActiveCfg = Debug|Any CPU
{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53}.Debug|x86.Build.0 = Debug|Any CPU
{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53}.Release|Any CPU.Build.0 = Release|Any CPU
{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53}.Release|x64.ActiveCfg = Release|Any CPU
{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53}.Release|x64.Build.0 = Release|Any CPU
{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53}.Release|x86.ActiveCfg = Release|Any CPU
{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53}.Release|x86.Build.0 = Release|Any CPU
{D66D65AB-90F8-4E30-A91F-88F48200B6A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D66D65AB-90F8-4E30-A91F-88F48200B6A5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D66D65AB-90F8-4E30-A91F-88F48200B6A5}.Debug|x64.ActiveCfg = Debug|Any CPU
{D66D65AB-90F8-4E30-A91F-88F48200B6A5}.Debug|x64.Build.0 = Debug|Any CPU
{D66D65AB-90F8-4E30-A91F-88F48200B6A5}.Debug|x86.ActiveCfg = Debug|Any CPU
{D66D65AB-90F8-4E30-A91F-88F48200B6A5}.Debug|x86.Build.0 = Debug|Any CPU
{D66D65AB-90F8-4E30-A91F-88F48200B6A5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D66D65AB-90F8-4E30-A91F-88F48200B6A5}.Release|Any CPU.Build.0 = Release|Any CPU
{D66D65AB-90F8-4E30-A91F-88F48200B6A5}.Release|x64.ActiveCfg = Release|Any CPU
{D66D65AB-90F8-4E30-A91F-88F48200B6A5}.Release|x64.Build.0 = Release|Any CPU
{D66D65AB-90F8-4E30-A91F-88F48200B6A5}.Release|x86.ActiveCfg = Release|Any CPU
{D66D65AB-90F8-4E30-A91F-88F48200B6A5}.Release|x86.Build.0 = Release|Any CPU
{F139C3CD-09E3-4E7E-A475-4F25E43071DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F139C3CD-09E3-4E7E-A475-4F25E43071DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F139C3CD-09E3-4E7E-A475-4F25E43071DD}.Debug|x64.ActiveCfg = Debug|Any CPU
{F139C3CD-09E3-4E7E-A475-4F25E43071DD}.Debug|x64.Build.0 = Debug|Any CPU
{F139C3CD-09E3-4E7E-A475-4F25E43071DD}.Debug|x86.ActiveCfg = Debug|Any CPU
{F139C3CD-09E3-4E7E-A475-4F25E43071DD}.Debug|x86.Build.0 = Debug|Any CPU
{F139C3CD-09E3-4E7E-A475-4F25E43071DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F139C3CD-09E3-4E7E-A475-4F25E43071DD}.Release|Any CPU.Build.0 = Release|Any CPU
{F139C3CD-09E3-4E7E-A475-4F25E43071DD}.Release|x64.ActiveCfg = Release|Any CPU
{F139C3CD-09E3-4E7E-A475-4F25E43071DD}.Release|x64.Build.0 = Release|Any CPU
{F139C3CD-09E3-4E7E-A475-4F25E43071DD}.Release|x86.ActiveCfg = Release|Any CPU
{F139C3CD-09E3-4E7E-A475-4F25E43071DD}.Release|x86.Build.0 = Release|Any CPU
{553E0796-DC73-4F67-9FFD-E3B1369D5F6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{553E0796-DC73-4F67-9FFD-E3B1369D5F6E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{553E0796-DC73-4F67-9FFD-E3B1369D5F6E}.Debug|x64.ActiveCfg = Debug|Any CPU
{553E0796-DC73-4F67-9FFD-E3B1369D5F6E}.Debug|x64.Build.0 = Debug|Any CPU
{553E0796-DC73-4F67-9FFD-E3B1369D5F6E}.Debug|x86.ActiveCfg = Debug|Any CPU
{553E0796-DC73-4F67-9FFD-E3B1369D5F6E}.Debug|x86.Build.0 = Debug|Any CPU
{553E0796-DC73-4F67-9FFD-E3B1369D5F6E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{553E0796-DC73-4F67-9FFD-E3B1369D5F6E}.Release|Any CPU.Build.0 = Release|Any CPU
{553E0796-DC73-4F67-9FFD-E3B1369D5F6E}.Release|x64.ActiveCfg = Release|Any CPU
{553E0796-DC73-4F67-9FFD-E3B1369D5F6E}.Release|x64.Build.0 = Release|Any CPU
{553E0796-DC73-4F67-9FFD-E3B1369D5F6E}.Release|x86.ActiveCfg = Release|Any CPU
{553E0796-DC73-4F67-9FFD-E3B1369D5F6E}.Release|x86.Build.0 = Release|Any CPU
{4BC59250-7EA1-459B-9BE1-50EA8E8F623C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4BC59250-7EA1-459B-9BE1-50EA8E8F623C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4BC59250-7EA1-459B-9BE1-50EA8E8F623C}.Debug|x64.ActiveCfg = Debug|Any CPU
{4BC59250-7EA1-459B-9BE1-50EA8E8F623C}.Debug|x64.Build.0 = Debug|Any CPU
{4BC59250-7EA1-459B-9BE1-50EA8E8F623C}.Debug|x86.ActiveCfg = Debug|Any CPU
{4BC59250-7EA1-459B-9BE1-50EA8E8F623C}.Debug|x86.Build.0 = Debug|Any CPU
{4BC59250-7EA1-459B-9BE1-50EA8E8F623C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4BC59250-7EA1-459B-9BE1-50EA8E8F623C}.Release|Any CPU.Build.0 = Release|Any CPU
{4BC59250-7EA1-459B-9BE1-50EA8E8F623C}.Release|x64.ActiveCfg = Release|Any CPU
{4BC59250-7EA1-459B-9BE1-50EA8E8F623C}.Release|x64.Build.0 = Release|Any CPU
{4BC59250-7EA1-459B-9BE1-50EA8E8F623C}.Release|x86.ActiveCfg = Release|Any CPU
{4BC59250-7EA1-459B-9BE1-50EA8E8F623C}.Release|x86.Build.0 = Release|Any CPU
{0C16255D-8995-40E5-90DF-326F55D66260}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0C16255D-8995-40E5-90DF-326F55D66260}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0C16255D-8995-40E5-90DF-326F55D66260}.Debug|x64.ActiveCfg = Debug|Any CPU
{0C16255D-8995-40E5-90DF-326F55D66260}.Debug|x64.Build.0 = Debug|Any CPU
{0C16255D-8995-40E5-90DF-326F55D66260}.Debug|x86.ActiveCfg = Debug|Any CPU
{0C16255D-8995-40E5-90DF-326F55D66260}.Debug|x86.Build.0 = Debug|Any CPU
{0C16255D-8995-40E5-90DF-326F55D66260}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0C16255D-8995-40E5-90DF-326F55D66260}.Release|Any CPU.Build.0 = Release|Any CPU
{0C16255D-8995-40E5-90DF-326F55D66260}.Release|x64.ActiveCfg = Release|Any CPU
{0C16255D-8995-40E5-90DF-326F55D66260}.Release|x64.Build.0 = Release|Any CPU
{0C16255D-8995-40E5-90DF-326F55D66260}.Release|x86.ActiveCfg = Release|Any CPU
{0C16255D-8995-40E5-90DF-326F55D66260}.Release|x86.Build.0 = Release|Any CPU
{84778A2B-C034-4D44-ABD7-A282EEA98080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{84778A2B-C034-4D44-ABD7-A282EEA98080}.Debug|Any CPU.Build.0 = Debug|Any CPU
{84778A2B-C034-4D44-ABD7-A282EEA98080}.Debug|x64.ActiveCfg = Debug|Any CPU
{84778A2B-C034-4D44-ABD7-A282EEA98080}.Debug|x64.Build.0 = Debug|Any CPU
{84778A2B-C034-4D44-ABD7-A282EEA98080}.Debug|x86.ActiveCfg = Debug|Any CPU
{84778A2B-C034-4D44-ABD7-A282EEA98080}.Debug|x86.Build.0 = Debug|Any CPU
{84778A2B-C034-4D44-ABD7-A282EEA98080}.Release|Any CPU.ActiveCfg = Release|Any CPU
{84778A2B-C034-4D44-ABD7-A282EEA98080}.Release|Any CPU.Build.0 = Release|Any CPU
{84778A2B-C034-4D44-ABD7-A282EEA98080}.Release|x64.ActiveCfg = Release|Any CPU
{84778A2B-C034-4D44-ABD7-A282EEA98080}.Release|x64.Build.0 = Release|Any CPU
{84778A2B-C034-4D44-ABD7-A282EEA98080}.Release|x86.ActiveCfg = Release|Any CPU
{84778A2B-C034-4D44-ABD7-A282EEA98080}.Release|x86.Build.0 = Release|Any CPU
{AACD4F04-5572-487A-9BFE-ED1BF67B61F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AACD4F04-5572-487A-9BFE-ED1BF67B61F8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AACD4F04-5572-487A-9BFE-ED1BF67B61F8}.Debug|x64.ActiveCfg = Debug|Any CPU
{AACD4F04-5572-487A-9BFE-ED1BF67B61F8}.Debug|x64.Build.0 = Debug|Any CPU
{AACD4F04-5572-487A-9BFE-ED1BF67B61F8}.Debug|x86.ActiveCfg = Debug|Any CPU
{AACD4F04-5572-487A-9BFE-ED1BF67B61F8}.Debug|x86.Build.0 = Debug|Any CPU
{AACD4F04-5572-487A-9BFE-ED1BF67B61F8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AACD4F04-5572-487A-9BFE-ED1BF67B61F8}.Release|Any CPU.Build.0 = Release|Any CPU
{AACD4F04-5572-487A-9BFE-ED1BF67B61F8}.Release|x64.ActiveCfg = Release|Any CPU
{AACD4F04-5572-487A-9BFE-ED1BF67B61F8}.Release|x64.Build.0 = Release|Any CPU
{AACD4F04-5572-487A-9BFE-ED1BF67B61F8}.Release|x86.ActiveCfg = Release|Any CPU
{AACD4F04-5572-487A-9BFE-ED1BF67B61F8}.Release|x86.Build.0 = Release|Any CPU
{1990A8B0-12A9-4720-B569-97453B1879DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1990A8B0-12A9-4720-B569-97453B1879DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1990A8B0-12A9-4720-B569-97453B1879DC}.Debug|x64.ActiveCfg = Debug|Any CPU
{1990A8B0-12A9-4720-B569-97453B1879DC}.Debug|x64.Build.0 = Debug|Any CPU
{1990A8B0-12A9-4720-B569-97453B1879DC}.Debug|x86.ActiveCfg = Debug|Any CPU
{1990A8B0-12A9-4720-B569-97453B1879DC}.Debug|x86.Build.0 = Debug|Any CPU
{1990A8B0-12A9-4720-B569-97453B1879DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1990A8B0-12A9-4720-B569-97453B1879DC}.Release|Any CPU.Build.0 = Release|Any CPU
{1990A8B0-12A9-4720-B569-97453B1879DC}.Release|x64.ActiveCfg = Release|Any CPU
{1990A8B0-12A9-4720-B569-97453B1879DC}.Release|x64.Build.0 = Release|Any CPU
{1990A8B0-12A9-4720-B569-97453B1879DC}.Release|x86.ActiveCfg = Release|Any CPU
{1990A8B0-12A9-4720-B569-97453B1879DC}.Release|x86.Build.0 = Release|Any CPU
{54DE90D4-74F1-4198-8B30-B36418ECC79F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{54DE90D4-74F1-4198-8B30-B36418ECC79F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{54DE90D4-74F1-4198-8B30-B36418ECC79F}.Debug|x64.ActiveCfg = Debug|Any CPU
{54DE90D4-74F1-4198-8B30-B36418ECC79F}.Debug|x64.Build.0 = Debug|Any CPU
{54DE90D4-74F1-4198-8B30-B36418ECC79F}.Debug|x86.ActiveCfg = Debug|Any CPU
{54DE90D4-74F1-4198-8B30-B36418ECC79F}.Debug|x86.Build.0 = Debug|Any CPU
{54DE90D4-74F1-4198-8B30-B36418ECC79F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{54DE90D4-74F1-4198-8B30-B36418ECC79F}.Release|Any CPU.Build.0 = Release|Any CPU
{54DE90D4-74F1-4198-8B30-B36418ECC79F}.Release|x64.ActiveCfg = Release|Any CPU
{54DE90D4-74F1-4198-8B30-B36418ECC79F}.Release|x64.Build.0 = Release|Any CPU
{54DE90D4-74F1-4198-8B30-B36418ECC79F}.Release|x86.ActiveCfg = Release|Any CPU
{54DE90D4-74F1-4198-8B30-B36418ECC79F}.Release|x86.Build.0 = Release|Any CPU
{753DE639-AEC9-496C-B0D8-1B141B6D487E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{753DE639-AEC9-496C-B0D8-1B141B6D487E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{753DE639-AEC9-496C-B0D8-1B141B6D487E}.Debug|x64.ActiveCfg = Debug|Any CPU
{753DE639-AEC9-496C-B0D8-1B141B6D487E}.Debug|x64.Build.0 = Debug|Any CPU
{753DE639-AEC9-496C-B0D8-1B141B6D487E}.Debug|x86.ActiveCfg = Debug|Any CPU
{753DE639-AEC9-496C-B0D8-1B141B6D487E}.Debug|x86.Build.0 = Debug|Any CPU
{753DE639-AEC9-496C-B0D8-1B141B6D487E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{753DE639-AEC9-496C-B0D8-1B141B6D487E}.Release|Any CPU.Build.0 = Release|Any CPU
{753DE639-AEC9-496C-B0D8-1B141B6D487E}.Release|x64.ActiveCfg = Release|Any CPU
{753DE639-AEC9-496C-B0D8-1B141B6D487E}.Release|x64.Build.0 = Release|Any CPU
{753DE639-AEC9-496C-B0D8-1B141B6D487E}.Release|x86.ActiveCfg = Release|Any CPU
{753DE639-AEC9-496C-B0D8-1B141B6D487E}.Release|x86.Build.0 = Release|Any CPU
{E97E3B77-7766-4C18-8558-0B06DE967A1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E97E3B77-7766-4C18-8558-0B06DE967A1D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E97E3B77-7766-4C18-8558-0B06DE967A1D}.Debug|x64.ActiveCfg = Debug|Any CPU
{E97E3B77-7766-4C18-8558-0B06DE967A1D}.Debug|x64.Build.0 = Debug|Any CPU
{E97E3B77-7766-4C18-8558-0B06DE967A1D}.Debug|x86.ActiveCfg = Debug|Any CPU
{E97E3B77-7766-4C18-8558-0B06DE967A1D}.Debug|x86.Build.0 = Debug|Any CPU
{E97E3B77-7766-4C18-8558-0B06DE967A1D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E97E3B77-7766-4C18-8558-0B06DE967A1D}.Release|Any CPU.Build.0 = Release|Any CPU
{E97E3B77-7766-4C18-8558-0B06DE967A1D}.Release|x64.ActiveCfg = Release|Any CPU
{E97E3B77-7766-4C18-8558-0B06DE967A1D}.Release|x64.Build.0 = Release|Any CPU
{E97E3B77-7766-4C18-8558-0B06DE967A1D}.Release|x86.ActiveCfg = Release|Any CPU
{E97E3B77-7766-4C18-8558-0B06DE967A1D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -3914,6 +4166,15 @@ Global
{DA7634C2-9156-9B79-7A1D-90D8E605DC8A} = {0910C958-24C8-947F-359A-218ED1199AAE}
{5C4EF841-B039-4899-BF6F-32DC4FDB7AE5} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{44A3DE13-CC1A-4331-8551-30F52E67510C} = {A5C98087-E847-D2C4-2143-20869479839D}
{B8F48A1F-F911-455B-81E5-4E8180405D12} = {A5C98087-E847-D2C4-2143-20869479839D}
{37495115-54C5-4198-BB7B-4AD795421061} = {A5C98087-E847-D2C4-2143-20869479839D}
{2949EF87-5DC2-4399-B4C6-63E6992072A8} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{3622AA85-EE4E-412C-93AE-D3B221EAF453} = {A5C98087-E847-D2C4-2143-20869479839D}
{96C9DE89-5BCD-489F-9654-CE904480DDC6} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{C6CF3E64-AF7D-4895-B6FB-D13A7DB80D53} = {A5C98087-E847-D2C4-2143-20869479839D}
{1990A8B0-12A9-4720-B569-97453B1879DC} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{54DE90D4-74F1-4198-8B30-B36418ECC79F} = {A5C98087-E847-D2C4-2143-20869479839D}
{E97E3B77-7766-4C18-8558-0B06DE967A1D} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C9C08EA6-E174-0E6C-3FFC-FC856E9A6EC2}

View File

@@ -0,0 +1,20 @@
# Scanner.AiMlSecurity - Agent Instructions
## Module Overview
This library evaluates AI/ML supply chain metadata (model cards, training data,
provenance, bias/fairness, and safety claims) from parsed SBOMs.
## Key Components
- **AiMlSecurityContext** - Aggregates parsed SBOM data and options.
- **IAiMlSecurityCheck** - Analyzer contract for AI/ML checks.
- **AiMlSecurityReportFormatter** - JSON/text/PDF reporting.
- **AiGovernancePolicyLoader** - Loads AI governance policies (YAML/JSON).
## Required Reading
- `docs/modules/scanner/architecture.md`
- `src/Scanner/docs/ai-ml-security.md`
## Working Agreement
- Keep outputs deterministic (stable ordering, UTC timestamps).
- Avoid new external network calls; use offline fixtures for tests.
- Update sprint status and module docs when contracts change.

View File

@@ -0,0 +1,172 @@
using System.Collections.Immutable;
using StellaOps.BinaryIndex.ML;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Analyzers;
using StellaOps.Scanner.AiMlSecurity.Models;
using StellaOps.Scanner.AiMlSecurity.Policy;
namespace StellaOps.Scanner.AiMlSecurity;
public interface IAiMlSecurityAnalyzer
{
Task<AiMlSecurityReport> AnalyzeAsync(
IReadOnlyList<ParsedComponent> mlComponents,
AiGovernancePolicy policy,
CancellationToken ct = default);
}
public sealed class AiMlSecurityAnalyzer : IAiMlSecurityAnalyzer
{
private readonly IReadOnlyList<IAiMlSecurityCheck> _checks;
private readonly TimeProvider _timeProvider;
private readonly IEmbeddingService? _embeddingService;
public AiMlSecurityAnalyzer(
IEnumerable<IAiMlSecurityCheck> checks,
TimeProvider? timeProvider = null,
IEmbeddingService? embeddingService = null)
{
_checks = (checks ?? Array.Empty<IAiMlSecurityCheck>()).ToList();
_timeProvider = timeProvider ?? TimeProvider.System;
_embeddingService = embeddingService;
}
public async Task<AiMlSecurityReport> AnalyzeAsync(
IReadOnlyList<ParsedComponent> mlComponents,
AiGovernancePolicy policy,
CancellationToken ct = default)
{
var context = AiMlSecurityContext.Create(mlComponents, policy, _timeProvider, _embeddingService);
var findings = new List<AiSecurityFinding>();
var riskAssessments = new List<AiRiskAssessment>();
AiModelInventory? inventory = null;
foreach (var check in _checks)
{
ct.ThrowIfCancellationRequested();
var result = await check.AnalyzeAsync(context, ct).ConfigureAwait(false);
if (!result.Findings.IsDefaultOrEmpty)
{
findings.AddRange(result.Findings);
}
if (!result.RiskAssessments.IsDefaultOrEmpty)
{
riskAssessments.AddRange(result.RiskAssessments);
}
inventory ??= result.Inventory;
}
var orderedFindings = findings
.OrderByDescending(f => f.Severity)
.ThenBy(f => f.Title, StringComparer.Ordinal)
.ThenBy(f => f.ComponentName ?? f.ComponentBomRef, StringComparer.Ordinal)
.ToImmutableArray();
var orderedAssessments = riskAssessments
.OrderBy(a => a.Category, StringComparer.Ordinal)
.ThenBy(a => a.ModelBomRef, StringComparer.Ordinal)
.ToImmutableArray();
var summary = BuildSummary(orderedFindings, inventory);
var complianceStatus = BuildComplianceStatus(policy, orderedFindings);
return new AiMlSecurityReport
{
Inventory = inventory ?? new AiModelInventory(),
Findings = orderedFindings,
RiskAssessments = orderedAssessments,
ComplianceStatus = complianceStatus,
Summary = summary,
PolicyVersion = policy.Version,
GeneratedAtUtc = _timeProvider.GetUtcNow()
};
}
private static AiMlSummary BuildSummary(
ImmutableArray<AiSecurityFinding> findings,
AiModelInventory? inventory)
{
if (findings.IsDefaultOrEmpty)
{
return new AiMlSummary
{
ModelCount = inventory?.Models.Length ?? 0,
DatasetCount = inventory?.TrainingDatasets.Length ?? 0
};
}
var bySeverity = findings
.GroupBy(f => f.Severity)
.ToImmutableDictionary(g => g.Key, g => g.Count());
var highRiskModels = inventory?.Models.Count(m =>
!string.IsNullOrWhiteSpace(m.RiskCategory)
&& m.RiskCategory!.Equals("high", StringComparison.OrdinalIgnoreCase)) ?? 0;
return new AiMlSummary
{
TotalFindings = findings.Length,
ModelCount = inventory?.Models.Length ?? 0,
DatasetCount = inventory?.TrainingDatasets.Length ?? 0,
HighRiskModelCount = highRiskModels,
FindingsBySeverity = bySeverity
};
}
private static AiComplianceStatus BuildComplianceStatus(
AiGovernancePolicy policy,
ImmutableArray<AiSecurityFinding> findings)
{
var frameworks = GetFrameworks(policy);
var violations = findings.Length;
var isCompliant = violations == 0;
var statuses = frameworks
.Select(framework => new AiComplianceFrameworkStatus
{
Framework = framework,
IsCompliant = isCompliant,
ViolationCount = violations
})
.ToImmutableArray();
return new AiComplianceStatus
{
Frameworks = statuses
};
}
private static ImmutableArray<string> GetFrameworks(AiGovernancePolicy policy)
{
var frameworks = new List<string>();
if (!policy.ComplianceFrameworks.IsDefaultOrEmpty)
{
frameworks.AddRange(policy.ComplianceFrameworks
.Where(f => !string.IsNullOrWhiteSpace(f))
.Select(f => f.Trim()));
}
if (!string.IsNullOrWhiteSpace(policy.ComplianceFramework))
{
var framework = policy.ComplianceFramework!.Trim();
if (!frameworks.Any(existing => existing.Equals(framework, StringComparison.OrdinalIgnoreCase)))
{
frameworks.Add(framework);
}
}
if (frameworks.Count == 0)
{
frameworks.Add("custom");
}
return frameworks
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
}

View File

@@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.AiMlSecurity.Analyzers;
using StellaOps.Scanner.AiMlSecurity.Policy;
namespace StellaOps.Scanner.AiMlSecurity;
public static class AiMlSecurityServiceCollectionExtensions
{
public static IServiceCollection AddAiMlSecurity(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IAiGovernancePolicyLoader, AiGovernancePolicyLoader>();
services.AddSingleton<IAiMlSecurityCheck, AiModelInventoryGenerator>();
services.AddSingleton<IAiMlSecurityCheck, ModelCardCompletenessAnalyzer>();
services.AddSingleton<IAiMlSecurityCheck, TrainingDataProvenanceAnalyzer>();
services.AddSingleton<IAiMlSecurityCheck, BiasFairnessAnalyzer>();
services.AddSingleton<IAiMlSecurityCheck, AiSafetyRiskAnalyzer>();
services.AddSingleton<IAiMlSecurityCheck, ModelProvenanceVerifier>();
services.AddSingleton<IAiMlSecurityCheck, ModelBinaryAnalyzer>();
services.AddSingleton<IAiMlSecurityAnalyzer, AiMlSecurityAnalyzer>();
return services;
}
}

View File

@@ -0,0 +1,176 @@
using System.Collections.Immutable;
using StellaOps.BinaryIndex.ML;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Policy;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class AiMlSecurityContext
{
private readonly ImmutableArray<AiGovernanceExemption> _exemptions;
private readonly DateOnly _todayUtc;
private AiMlSecurityContext(
ImmutableArray<ParsedComponent> components,
ImmutableArray<ParsedComponent> modelComponents,
ImmutableArray<ParsedComponent> datasetComponents,
AiGovernancePolicy policy,
TimeProvider timeProvider,
IEmbeddingService? embeddingService)
{
Components = components;
ModelComponents = modelComponents;
DatasetComponents = datasetComponents;
Policy = policy;
TimeProvider = timeProvider;
EmbeddingService = embeddingService;
_exemptions = policy.Exemptions.IsDefault ? [] : policy.Exemptions;
_todayUtc = DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime);
}
public ImmutableArray<ParsedComponent> Components { get; }
public ImmutableArray<ParsedComponent> ModelComponents { get; }
public ImmutableArray<ParsedComponent> DatasetComponents { get; }
public AiGovernancePolicy Policy { get; }
public TimeProvider TimeProvider { get; }
public IEmbeddingService? EmbeddingService { get; }
public bool IsExempted(ParsedComponent component)
{
if (_exemptions.IsDefaultOrEmpty)
{
return false;
}
var name = component.Name ?? string.Empty;
var bomRef = component.BomRef ?? string.Empty;
foreach (var exemption in _exemptions)
{
if (exemption.ExpirationDate.HasValue && exemption.ExpirationDate.Value < _todayUtc)
{
continue;
}
var pattern = exemption.ModelPattern;
if (string.IsNullOrWhiteSpace(pattern))
{
continue;
}
if (MatchesPattern(name, pattern) || MatchesPattern(bomRef, pattern))
{
return true;
}
}
return false;
}
public static AiMlSecurityContext Create(
IReadOnlyList<ParsedComponent> components,
AiGovernancePolicy policy,
TimeProvider? timeProvider = null,
IEmbeddingService? embeddingService = null)
{
var allComponents = components?.ToImmutableArray() ?? [];
var models = allComponents.Where(IsModelComponent).ToImmutableArray();
var datasets = allComponents.Where(IsDatasetComponent).ToImmutableArray();
return new AiMlSecurityContext(
allComponents,
models,
datasets,
policy,
timeProvider ?? TimeProvider.System,
embeddingService);
}
private static bool IsModelComponent(ParsedComponent component)
{
if (component.ModelCard is not null)
{
return true;
}
var normalized = NormalizeType(component.Type);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
return normalized.Contains("machinelearning", StringComparison.Ordinal)
|| normalized.Contains("mlmodel", StringComparison.Ordinal)
|| normalized.Contains("aimodel", StringComparison.Ordinal);
}
private static bool IsDatasetComponent(ParsedComponent component)
{
if (component.DatasetMetadata is not null)
{
return true;
}
var normalized = NormalizeType(component.Type);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
return normalized.Contains("dataset", StringComparison.Ordinal);
}
private static string NormalizeType(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return value.Trim().Replace("-", string.Empty).Replace("_", string.Empty)
.Replace(" ", string.Empty)
.ToLowerInvariant();
}
private static bool MatchesPattern(string input, string pattern)
{
if (string.IsNullOrEmpty(pattern))
{
return false;
}
var normalizedInput = input ?? string.Empty;
var normalizedPattern = pattern.Trim();
if (normalizedPattern == "*")
{
return true;
}
var wildcardIndex = normalizedPattern.IndexOf('*');
if (wildcardIndex < 0)
{
return normalizedInput.Equals(normalizedPattern, StringComparison.OrdinalIgnoreCase);
}
var parts = normalizedPattern.Split('*', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
{
return true;
}
var position = 0;
foreach (var part in parts)
{
var matchIndex = normalizedInput.IndexOf(part, position, StringComparison.OrdinalIgnoreCase);
if (matchIndex < 0)
{
return false;
}
position = matchIndex + part.Length;
}
return true;
}
}

View File

@@ -0,0 +1,20 @@
using System.Collections.Immutable;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed record AiMlSecurityResult
{
public static AiMlSecurityResult Empty { get; } = new();
public ImmutableArray<AiSecurityFinding> Findings { get; init; } = [];
public ImmutableArray<AiRiskAssessment> RiskAssessments { get; init; } = [];
public AiModelInventory? Inventory { get; init; }
}
public interface IAiMlSecurityCheck
{
Task<AiMlSecurityResult> AnalyzeAsync(
AiMlSecurityContext context,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,215 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class AiModelInventoryGenerator : IAiMlSecurityCheck
{
public Task<AiMlSecurityResult> AnalyzeAsync(AiMlSecurityContext context, CancellationToken ct = default)
{
var modelEntries = new List<AiModelEntry>();
var datasetEntries = new Dictionary<string, DatasetEntry>(StringComparer.OrdinalIgnoreCase);
var dependencies = new List<AiModelDependency>();
foreach (var datasetComponent in context.DatasetComponents)
{
var entry = BuildDatasetEntry(datasetComponent, null);
if (!string.IsNullOrWhiteSpace(entry.Name) && !datasetEntries.ContainsKey(entry.Name!))
{
datasetEntries[entry.Name!] = entry;
}
}
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
var card = component.ModelCard;
var datasets = card?.ModelParameters?.Datasets ?? [];
var datasetCount = datasets.IsDefaultOrEmpty ? 0 : datasets.Length;
foreach (var dataset in datasets)
{
var entry = BuildDatasetEntry(null, dataset);
if (!string.IsNullOrWhiteSpace(entry.Name) && !datasetEntries.ContainsKey(entry.Name!))
{
datasetEntries[entry.Name!] = entry;
}
if (!string.IsNullOrWhiteSpace(entry.Name))
{
dependencies.Add(new AiModelDependency
{
ModelBomRef = component.BomRef,
DependencyBomRef = entry.ComponentBomRef ?? entry.Name,
Relation = "dataset",
DependencyType = "training-data"
});
}
}
AppendLineageDependencies(component, dependencies);
var completeness = ModelCardScoring.GetCompleteness(card);
var hasSafety = ModelCardScoring.HasSafetyAssessment(card);
var hasFairness = ModelCardScoring.HasFairnessAssessment(card);
var hasProvenance = !component.Hashes.IsDefaultOrEmpty || component.ExternalReferences.Any();
modelEntries.Add(new AiModelEntry
{
BomRef = component.BomRef,
Name = component.Name,
Version = component.Version,
Type = component.Type,
Source = component.Publisher ?? component.Supplier?.Name,
HasModelCard = card is not null,
Completeness = completeness,
DatasetCount = datasetCount,
RiskCategory = ResolveRiskCategory(component, context.Policy.RiskCategories.HighRisk),
HasSafetyAssessment = hasSafety,
HasFairnessAssessment = hasFairness,
HasProvenanceEvidence = hasProvenance
});
}
return Task.FromResult(new AiMlSecurityResult
{
Inventory = new AiModelInventory
{
Models = modelEntries
.OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.Version, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray(),
TrainingDatasets = datasetEntries.Values
.OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.Version, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray(),
ModelDependencies = dependencies
.OrderBy(entry => entry.ModelBomRef, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.DependencyBomRef, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray()
}
});
}
private static DatasetEntry BuildDatasetEntry(
ParsedComponent? datasetComponent,
ParsedDatasetRef? datasetRef)
{
if (datasetComponent is not null)
{
var metadata = datasetComponent.DatasetMetadata;
return new DatasetEntry
{
Name = datasetComponent.Name,
Version = datasetComponent.Version,
Url = datasetComponent.ExternalReferences
.Select(reference => reference.Url)
.FirstOrDefault(url => !string.IsNullOrWhiteSpace(url)),
HasProvenance = metadata is not null
&& (!string.IsNullOrWhiteSpace(metadata.DataCollectionProcess)
|| !string.IsNullOrWhiteSpace(metadata.IntendedUse)),
HasSensitiveData = metadata?.HasSensitivePersonalInformation == true
|| (metadata is not null && !metadata.SensitivePersonalInformation.IsDefaultOrEmpty),
ConfidentialityLevel = metadata?.ConfidentialityLevel,
DatasetType = metadata?.DatasetType,
ComponentBomRef = datasetComponent.BomRef
};
}
return new DatasetEntry
{
Name = datasetRef?.Name,
Version = datasetRef?.Version,
Url = datasetRef?.Url,
HasProvenance = datasetRef is not null
&& (!string.IsNullOrWhiteSpace(datasetRef.Url) || !datasetRef.Hashes.IsDefaultOrEmpty),
ComponentBomRef = datasetRef?.Name
};
}
private static void AppendLineageDependencies(
ParsedComponent component,
List<AiModelDependency> dependencies)
{
var pedigree = component.Pedigree;
if (pedigree is null)
{
return;
}
foreach (var ancestor in pedigree.Ancestors)
{
if (string.IsNullOrWhiteSpace(ancestor.BomRef))
{
continue;
}
dependencies.Add(new AiModelDependency
{
ModelBomRef = component.BomRef,
DependencyBomRef = ancestor.BomRef,
Relation = "ancestor",
DependencyType = "model-lineage"
});
}
foreach (var variant in pedigree.Variants)
{
if (string.IsNullOrWhiteSpace(variant.BomRef))
{
continue;
}
dependencies.Add(new AiModelDependency
{
ModelBomRef = component.BomRef,
DependencyBomRef = variant.BomRef,
Relation = "variant",
DependencyType = "model-lineage"
});
}
}
private static string? ResolveRiskCategory(
ParsedComponent component,
ImmutableArray<string> highRiskCategories)
{
if (highRiskCategories.IsDefaultOrEmpty)
{
return null;
}
var candidates = new List<string>();
if (!string.IsNullOrWhiteSpace(component.Type))
{
candidates.Add(component.Type);
}
var parameters = component.ModelCard?.ModelParameters;
if (!string.IsNullOrWhiteSpace(parameters?.Domain))
{
candidates.Add(parameters.Domain!);
}
if (component.ModelCard?.Considerations?.UseCases is { } useCases && !useCases.IsDefaultOrEmpty)
{
candidates.AddRange(useCases);
}
foreach (var category in highRiskCategories)
{
foreach (var candidate in candidates)
{
if (!string.IsNullOrWhiteSpace(candidate)
&& candidate.Contains(category, StringComparison.OrdinalIgnoreCase))
{
return "high";
}
}
}
return null;
}
}

View File

@@ -0,0 +1,136 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class AiSafetyRiskAnalyzer : IAiMlSecurityCheck
{
public Task<AiMlSecurityResult> AnalyzeAsync(AiMlSecurityContext context, CancellationToken ct = default)
{
var findings = new List<AiSecurityFinding>();
var assessments = new List<AiRiskAssessment>();
var highRiskCategories = context.Policy.RiskCategories.HighRisk;
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
if (context.IsExempted(component))
{
continue;
}
var riskCategory = ResolveRiskCategory(component, highRiskCategories);
if (!string.IsNullOrWhiteSpace(riskCategory))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.HighRiskAiCategory,
Severity = Severity.High,
Title = "High-risk AI category",
Description = $"Model classified as high-risk category '{riskCategory}'.",
Remediation = "Ensure compliance with high-risk AI requirements and document oversight.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name,
Metadata = ImmutableDictionary<string, string>.Empty
.Add("riskCategory", riskCategory)
});
assessments.Add(new AiRiskAssessment
{
Category = "eu-ai-act",
Level = "high",
Description = $"Model falls into high-risk category '{riskCategory}'.",
ModelBomRef = component.BomRef,
Evidence = [riskCategory]
});
}
if (context.Policy.SafetyRequirements.RequireSafetyAssessment
&& !ModelCardScoring.HasSafetyAssessment(component.ModelCard))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.SafetyAssessmentMissing,
Severity = Severity.High,
Title = "Safety assessment missing",
Description = "Model card does not include safety risk assessment details.",
Remediation = "Provide safety risk assessment and mitigation details.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
}
if (context.Policy.RequireRiskAssessment && string.IsNullOrWhiteSpace(riskCategory))
{
assessments.Add(new AiRiskAssessment
{
Category = "risk-assessment",
Level = "unspecified",
Description = "Policy requires explicit risk assessment; none detected.",
ModelBomRef = component.BomRef,
Evidence = []
});
}
}
return Task.FromResult(new AiMlSecurityResult
{
Findings = findings.ToImmutableArray(),
RiskAssessments = assessments.ToImmutableArray()
});
}
private static string? ResolveRiskCategory(
ParsedComponent component,
ImmutableArray<string> highRiskCategories)
{
if (highRiskCategories.IsDefaultOrEmpty)
{
return null;
}
var candidates = new List<string>();
if (!string.IsNullOrWhiteSpace(component.Type))
{
candidates.Add(component.Type);
}
var parameters = component.ModelCard?.ModelParameters;
if (!string.IsNullOrWhiteSpace(parameters?.Domain))
{
candidates.Add(parameters.Domain!);
}
if (!string.IsNullOrWhiteSpace(parameters?.InformationAboutApplication))
{
candidates.Add(parameters.InformationAboutApplication!);
}
if (component.ModelCard?.Considerations?.UseCases is { } useCases && !useCases.IsDefaultOrEmpty)
{
candidates.AddRange(useCases);
}
foreach (var category in highRiskCategories)
{
if (string.IsNullOrWhiteSpace(category))
{
continue;
}
foreach (var candidate in candidates)
{
if (candidate.Contains(category, StringComparison.OrdinalIgnoreCase))
{
return category.Trim();
}
}
}
return null;
}
}

View File

@@ -0,0 +1,48 @@
using System.Collections.Immutable;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class BiasFairnessAnalyzer : IAiMlSecurityCheck
{
public Task<AiMlSecurityResult> AnalyzeAsync(AiMlSecurityContext context, CancellationToken ct = default)
{
if (!context.Policy.TrainingDataRequirements.RequireBiasAssessment)
{
return Task.FromResult(AiMlSecurityResult.Empty);
}
var findings = new List<AiSecurityFinding>();
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
if (context.IsExempted(component))
{
continue;
}
if (ModelCardScoring.HasFairnessAssessment(component.ModelCard))
{
continue;
}
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.BiasAssessmentMissing,
Severity = Severity.High,
Title = "Bias assessment missing",
Description = "Model card lacks fairness or bias assessment details.",
Remediation = "Document bias evaluation and mitigation strategies in modelCard.considerations.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
}
return Task.FromResult(new AiMlSecurityResult
{
Findings = findings.ToImmutableArray()
});
}
}

View File

@@ -0,0 +1,111 @@
using System.Collections.Immutable;
using StellaOps.BinaryIndex.ML;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class ModelBinaryAnalyzer : IAiMlSecurityCheck
{
private static readonly string[] BinaryPathKeys =
{
"model:binaryPath",
"model:artifactPath",
"modelBinaryPath",
"modelFilePath"
};
private const long MaxBinaryBytes = 2 * 1024 * 1024;
public async Task<AiMlSecurityResult> AnalyzeAsync(AiMlSecurityContext context, CancellationToken ct = default)
{
if (context.EmbeddingService is null)
{
return AiMlSecurityResult.Empty;
}
var assessments = new List<AiRiskAssessment>();
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
if (context.IsExempted(component))
{
continue;
}
var path = ResolveBinaryPath(component);
if (string.IsNullOrWhiteSpace(path))
{
continue;
}
if (!File.Exists(path))
{
assessments.Add(new AiRiskAssessment
{
Category = "binary-analysis",
Level = "missing",
Description = $"Model binary path not found: {path}.",
ModelBomRef = component.BomRef,
Evidence = []
});
continue;
}
var bytes = await ReadBinaryAsync(path, ct).ConfigureAwait(false);
var embedding = await context.EmbeddingService.GenerateEmbeddingAsync(
new EmbeddingInput(null, null, bytes, EmbeddingInputType.Instructions),
null,
ct).ConfigureAwait(false);
var matches = await context.EmbeddingService.FindSimilarAsync(embedding, ct: ct).ConfigureAwait(false);
var evidence = matches
.Select(match => $"{match.FunctionName}:{match.Similarity:F2}")
.ToImmutableArray();
assessments.Add(new AiRiskAssessment
{
Category = "binary-analysis",
Level = "completed",
Description = $"Computed embedding for model binary {Path.GetFileName(path)}.",
ModelBomRef = component.BomRef,
Evidence = evidence
});
}
return new AiMlSecurityResult
{
RiskAssessments = assessments.ToImmutableArray()
};
}
private static string? ResolveBinaryPath(ParsedComponent component)
{
foreach (var key in BinaryPathKeys)
{
if (component.Properties.TryGetValue(key, out var value)
&& !string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
private static async Task<byte[]> ReadBinaryAsync(string path, CancellationToken ct)
{
var info = new FileInfo(path);
if (info.Length <= MaxBinaryBytes)
{
return await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
}
var buffer = new byte[MaxBinaryBytes];
await using var stream = File.OpenRead(path);
var read = await stream.ReadAsync(buffer, ct).ConfigureAwait(false);
return read == buffer.Length ? buffer : buffer[..read];
}
}

View File

@@ -0,0 +1,145 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class ModelCardCompletenessAnalyzer : IAiMlSecurityCheck
{
public Task<AiMlSecurityResult> AnalyzeAsync(
AiMlSecurityContext context,
CancellationToken ct = default)
{
var findings = new List<AiSecurityFinding>();
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
if (context.IsExempted(component))
{
continue;
}
var card = component.ModelCard;
if (card is null)
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.MissingModelCard,
Severity = Severity.High,
Title = "Missing model card",
Description = "Model component does not provide a model card.",
Remediation = "Attach a modelCard section with required metadata.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
continue;
}
var completeness = ModelCardScoring.GetCompleteness(card);
var minimum = context.Policy.ModelCardRequirements.MinimumCompleteness;
if (minimum != AiModelCardCompleteness.None && completeness < minimum)
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.IncompleteModelCard,
Severity = completeness <= AiModelCardCompleteness.Minimal ? Severity.High : Severity.Medium,
Title = "Incomplete model card",
Description = $"Model card completeness is {completeness} but policy requires {minimum}.",
Remediation = "Populate missing model card sections to meet policy requirements.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name,
Metadata = ImmutableDictionary<string, string>.Empty
.Add("completeness", completeness.ToString())
.Add("required", minimum.ToString())
});
}
var missingSections = GetMissingSections(card, context.Policy.ModelCardRequirements.RequiredSections);
if (!missingSections.IsDefaultOrEmpty)
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.IncompleteModelCard,
Severity = Severity.Medium,
Title = "Model card missing required sections",
Description = "Missing required model card sections: " + string.Join(", ", missingSections),
Remediation = "Provide the required sections in modelCard.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name,
Metadata = ImmutableDictionary<string, string>.Empty
.Add("missingSections", string.Join(",", missingSections))
});
}
if (!ModelCardScoring.HasPerformanceMetrics(card))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.MissingPerformanceMetrics,
Severity = Severity.Medium,
Title = "Missing performance metrics",
Description = "Model card does not include performance metrics in quantitative analysis.",
Remediation = "Add performance metrics and evaluation results.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
}
}
return Task.FromResult(new AiMlSecurityResult
{
Findings = findings.ToImmutableArray()
});
}
private static ImmutableArray<string> GetMissingSections(
ParsedModelCard card,
ImmutableArray<string> requiredSections)
{
if (requiredSections.IsDefaultOrEmpty)
{
return [];
}
var missing = new List<string>();
foreach (var section in requiredSections)
{
if (string.IsNullOrWhiteSpace(section))
{
continue;
}
if (!HasSection(card, section.Trim()))
{
missing.Add(section.Trim());
}
}
return missing
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static bool HasSection(ParsedModelCard card, string section)
{
var normalized = section.Replace(" ", string.Empty).ToLowerInvariant();
return normalized switch
{
"modelparameters" => card.ModelParameters is not null,
"quantitativeanalysis" => card.QuantitativeAnalysis is not null,
"considerations" => card.Considerations is not null,
"considerations.ethicalconsiderations" =>
card.Considerations is not null && !card.Considerations.EthicalConsiderations.IsDefaultOrEmpty,
"considerations.fairnessassessments" =>
card.Considerations is not null && !card.Considerations.FairnessAssessments.IsDefaultOrEmpty,
_ => false
};
}
}

View File

@@ -0,0 +1,110 @@
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
internal static class ModelCardScoring
{
public static AiModelCardCompleteness GetCompleteness(ParsedModelCard? card)
{
if (card is null)
{
return AiModelCardCompleteness.None;
}
var hasParameters = HasModelParameters(card.ModelParameters);
var hasQuantitative = HasQuantitativeAnalysis(card.QuantitativeAnalysis);
var hasConsiderations = HasConsiderations(card.Considerations);
if (!hasParameters && !hasQuantitative && !hasConsiderations)
{
return AiModelCardCompleteness.Minimal;
}
if (hasParameters && !hasQuantitative && !hasConsiderations)
{
return AiModelCardCompleteness.Basic;
}
if (hasParameters && hasQuantitative && !hasConsiderations)
{
return AiModelCardCompleteness.Standard;
}
if (hasParameters && hasQuantitative && hasConsiderations)
{
return AiModelCardCompleteness.Complete;
}
if (hasParameters && hasConsiderations && !hasQuantitative)
{
return AiModelCardCompleteness.Basic;
}
if (hasQuantitative && hasConsiderations && !hasParameters)
{
return AiModelCardCompleteness.Standard;
}
return AiModelCardCompleteness.Basic;
}
public static bool HasPerformanceMetrics(ParsedModelCard? card)
{
var metrics = card?.QuantitativeAnalysis?.PerformanceMetrics;
return metrics is not null && !metrics.Value.IsDefaultOrEmpty;
}
public static bool HasFairnessAssessment(ParsedModelCard? card)
{
var fairness = card?.Considerations?.FairnessAssessments;
return fairness is not null && !fairness.Value.IsDefaultOrEmpty;
}
public static bool HasSafetyAssessment(ParsedModelCard? card)
{
return !string.IsNullOrWhiteSpace(card?.ModelParameters?.SafetyRiskAssessment);
}
private static bool HasModelParameters(ParsedModelParameters? parameters)
{
if (parameters is null)
{
return false;
}
return !string.IsNullOrWhiteSpace(parameters.Task)
|| !string.IsNullOrWhiteSpace(parameters.ArchitectureFamily)
|| !string.IsNullOrWhiteSpace(parameters.ModelArchitecture)
|| !parameters.Datasets.IsDefaultOrEmpty
|| !parameters.Inputs.IsDefaultOrEmpty
|| !parameters.Outputs.IsDefaultOrEmpty
|| !string.IsNullOrWhiteSpace(parameters.TypeOfModel)
|| !string.IsNullOrWhiteSpace(parameters.Domain);
}
private static bool HasQuantitativeAnalysis(ParsedQuantitativeAnalysis? analysis)
{
if (analysis is null)
{
return false;
}
return !analysis.PerformanceMetrics.IsDefaultOrEmpty
|| !analysis.Graphics.IsDefaultOrEmpty;
}
private static bool HasConsiderations(ParsedConsiderations? considerations)
{
if (considerations is null)
{
return false;
}
return !considerations.Users.IsDefaultOrEmpty
|| !considerations.UseCases.IsDefaultOrEmpty
|| !considerations.TechnicalLimitations.IsDefaultOrEmpty
|| !considerations.EthicalConsiderations.IsDefaultOrEmpty
|| !considerations.FairnessAssessments.IsDefaultOrEmpty;
}
}

View File

@@ -0,0 +1,188 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
using StellaOps.Scanner.AiMlSecurity.Policy;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class ModelProvenanceVerifier : IAiMlSecurityCheck
{
public Task<AiMlSecurityResult> AnalyzeAsync(AiMlSecurityContext context, CancellationToken ct = default)
{
var findings = new List<AiSecurityFinding>();
var provenancePolicy = context.Policy.ProvenanceRequirements;
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
if (context.IsExempted(component))
{
continue;
}
var hasHash = !component.Hashes.IsDefaultOrEmpty;
var hasSignature = HasSignature(component);
var source = ResolveSource(component);
var hasTrustedSource = HasTrustedSource(source, provenancePolicy);
if ((provenancePolicy.RequireHash && !hasHash)
|| (provenancePolicy.RequireSignature && !hasSignature)
|| (!provenancePolicy.TrustedSources.IsDefaultOrEmpty && !hasTrustedSource))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.UnverifiedModelProvenance,
Severity = provenancePolicy.RequireSignature ? Severity.High : Severity.Medium,
Title = "Unverified model provenance",
Description = "Model provenance does not meet policy requirements.",
Remediation = "Provide hashes/signatures and trusted source references.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name,
Metadata = ImmutableDictionary<string, string>.Empty
.Add("hasHash", hasHash.ToString())
.Add("hasSignature", hasSignature.ToString())
.Add("source", source ?? string.Empty)
});
}
if (component.Modified || HasLineage(component))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.ModelDriftRisk,
Severity = Severity.Medium,
Title = "Model drift risk",
Description = "Model indicates modifications or fine-tuning lineage.",
Remediation = "Review fine-tuning lineage and validate drift monitoring.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
}
if (IsAdversarialVulnerable(component))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.AdversarialVulnerability,
Severity = Severity.High,
Title = "Adversarial vulnerability flagged",
Description = "Model indicates adversarial robustness concerns.",
Remediation = "Perform adversarial testing and mitigation.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
}
}
return Task.FromResult(new AiMlSecurityResult
{
Findings = findings.ToImmutableArray()
});
}
private static bool HasSignature(ParsedComponent component)
{
if (component.ExternalReferences.Any(reference =>
(reference.Type ?? string.Empty).Contains("signature", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
foreach (var pair in component.Properties)
{
if (pair.Key.Contains("signature", StringComparison.OrdinalIgnoreCase)
&& IsTruthy(pair.Value))
{
return true;
}
}
return false;
}
private static string? ResolveSource(ParsedComponent component)
{
if (!string.IsNullOrWhiteSpace(component.Publisher))
{
return component.Publisher;
}
if (!string.IsNullOrWhiteSpace(component.Supplier?.Name))
{
return component.Supplier?.Name;
}
var external = component.ExternalReferences
.Select(reference => reference.Url)
.FirstOrDefault(url => !string.IsNullOrWhiteSpace(url));
return external;
}
private static bool HasTrustedSource(string? source, AiProvenanceRequirements policy)
{
if (string.IsNullOrWhiteSpace(source))
{
return false;
}
var normalized = source.ToLowerInvariant();
return policy.TrustedSources.Any(entry => normalized.Contains(entry, StringComparison.OrdinalIgnoreCase))
|| policy.KnownModelHubs.Any(entry => normalized.Contains(entry, StringComparison.OrdinalIgnoreCase));
}
private static bool HasLineage(ParsedComponent component)
{
var pedigree = component.Pedigree;
if (pedigree is null)
{
return false;
}
return !pedigree.Ancestors.IsDefaultOrEmpty || !pedigree.Variants.IsDefaultOrEmpty;
}
private static bool IsAdversarialVulnerable(ParsedComponent component)
{
if (component.Properties.TryGetValue("ai:adversarialVulnerability", out var value)
&& IsTruthy(value))
{
return true;
}
if (component.Properties.TryGetValue("ai:adversarial", out var shorthand)
&& IsTruthy(shorthand))
{
return true;
}
if (component.ModelCard?.Considerations?.TechnicalLimitations is { } limitations)
{
foreach (var limitation in limitations)
{
if (limitation.Contains("adversarial", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
private static bool IsTruthy(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
return value.Equals("true", StringComparison.OrdinalIgnoreCase)
|| value.Equals("yes", StringComparison.OrdinalIgnoreCase)
|| value.Equals("1", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,165 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Analyzers;
public sealed class TrainingDataProvenanceAnalyzer : IAiMlSecurityCheck
{
public Task<AiMlSecurityResult> AnalyzeAsync(
AiMlSecurityContext context,
CancellationToken ct = default)
{
var findings = new List<AiSecurityFinding>();
var datasetIndex = BuildDatasetIndex(context.DatasetComponents);
foreach (var component in context.ModelComponents)
{
ct.ThrowIfCancellationRequested();
if (context.IsExempted(component))
{
continue;
}
var card = component.ModelCard;
var datasets = card?.ModelParameters?.Datasets ?? [];
if (context.Policy.TrainingDataRequirements.RequireProvenance
&& datasets.IsDefaultOrEmpty)
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.UnknownTrainingData,
Severity = Severity.High,
Title = "Unknown training data",
Description = "Model card does not list any training datasets.",
Remediation = "Provide dataset provenance in modelCard.modelParameters.datasets.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name
});
continue;
}
foreach (var dataset in datasets)
{
var name = dataset.Name ?? string.Empty;
var hasProvenance = HasDatasetProvenance(dataset, datasetIndex, name);
if (context.Policy.TrainingDataRequirements.RequireProvenance && !hasProvenance)
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.UnknownTrainingData,
Severity = Severity.Medium,
Title = "Incomplete training data provenance",
Description = $"Dataset '{name}' is missing provenance details.",
Remediation = "Add dataset source, collection process, or hashes.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name,
DatasetName = name
});
}
if (!context.Policy.TrainingDataRequirements.SensitiveDataAllowed
&& HasSensitiveData(datasetIndex, name, card))
{
findings.Add(new AiSecurityFinding
{
Type = AiSecurityFindingType.SensitiveDataInTraining,
Severity = Severity.High,
Title = "Sensitive data in training set",
Description = $"Dataset '{name}' indicates sensitive personal information.",
Remediation = "Remove sensitive data or document allowed processing.",
ComponentName = component.Name,
ComponentBomRef = component.BomRef,
ModelName = component.Name,
DatasetName = name
});
}
}
}
return Task.FromResult(new AiMlSecurityResult
{
Findings = findings.ToImmutableArray()
});
}
private static Dictionary<string, ParsedComponent> BuildDatasetIndex(
ImmutableArray<ParsedComponent> datasets)
{
var index = new Dictionary<string, ParsedComponent>(StringComparer.OrdinalIgnoreCase);
foreach (var dataset in datasets)
{
var name = dataset.Name;
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
if (!index.ContainsKey(name))
{
index[name] = dataset;
}
}
return index;
}
private static bool HasDatasetProvenance(
ParsedDatasetRef dataset,
Dictionary<string, ParsedComponent> datasetIndex,
string name)
{
if (!string.IsNullOrWhiteSpace(dataset.Url) || !dataset.Hashes.IsDefaultOrEmpty)
{
return true;
}
if (datasetIndex.TryGetValue(name, out var component)
&& component.DatasetMetadata is { } metadata)
{
return !string.IsNullOrWhiteSpace(metadata.DataCollectionProcess)
|| !string.IsNullOrWhiteSpace(metadata.DatasetType)
|| !string.IsNullOrWhiteSpace(metadata.DataPreprocessing)
|| !string.IsNullOrWhiteSpace(metadata.IntendedUse);
}
return false;
}
private static bool HasSensitiveData(
Dictionary<string, ParsedComponent> datasetIndex,
string name,
ParsedModelCard? card)
{
if (card?.ModelParameters?.UseSensitivePersonalInformation == true)
{
return true;
}
if (card?.ModelParameters?.SensitivePersonalInformation is { } modelSensitive
&& !modelSensitive.IsDefaultOrEmpty)
{
return true;
}
if (datasetIndex.TryGetValue(name, out var dataset)
&& dataset.DatasetMetadata is { } metadata)
{
if (metadata.HasSensitivePersonalInformation == true)
{
return true;
}
if (!metadata.SensitivePersonalInformation.IsDefaultOrEmpty)
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,137 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.AiMlSecurity.Models;
public enum Severity
{
Critical,
High,
Medium,
Low,
Info,
Unknown
}
public enum AiSecurityFindingType
{
MissingModelCard,
IncompleteModelCard,
UnknownTrainingData,
BiasAssessmentMissing,
SafetyAssessmentMissing,
UnverifiedModelProvenance,
SensitiveDataInTraining,
HighRiskAiCategory,
MissingPerformanceMetrics,
ModelDriftRisk,
AdversarialVulnerability
}
public enum AiModelCardCompleteness
{
None,
Minimal,
Basic,
Standard,
Complete
}
public sealed record AiMlSecurityReport
{
public string? PolicyVersion { get; init; }
public DateTimeOffset GeneratedAtUtc { get; init; }
public AiModelInventory Inventory { get; init; } = new();
public ImmutableArray<AiSecurityFinding> Findings { get; init; } = [];
public ImmutableArray<AiRiskAssessment> RiskAssessments { get; init; } = [];
public AiComplianceStatus ComplianceStatus { get; init; } = new();
public AiMlSummary Summary { get; init; } = new();
}
public sealed record AiModelInventory
{
public ImmutableArray<AiModelEntry> Models { get; init; } = [];
public ImmutableArray<DatasetEntry> TrainingDatasets { get; init; } = [];
public ImmutableArray<AiModelDependency> ModelDependencies { get; init; } = [];
}
public sealed record AiModelEntry
{
public string? BomRef { get; init; }
public string? Name { get; init; }
public string? Version { get; init; }
public string? Type { get; init; }
public string? Source { get; init; }
public bool HasModelCard { get; init; }
public AiModelCardCompleteness Completeness { get; init; } = AiModelCardCompleteness.None;
public int DatasetCount { get; init; }
public string? RiskCategory { get; init; }
public bool HasSafetyAssessment { get; init; }
public bool HasFairnessAssessment { get; init; }
public bool HasProvenanceEvidence { get; init; }
}
public sealed record DatasetEntry
{
public string? Name { get; init; }
public string? Version { get; init; }
public string? Url { get; init; }
public bool HasProvenance { get; init; }
public bool HasSensitiveData { get; init; }
public string? ConfidentialityLevel { get; init; }
public string? DatasetType { get; init; }
public string? ComponentBomRef { get; init; }
}
public sealed record AiModelDependency
{
public string? ModelBomRef { get; init; }
public string? DependencyBomRef { get; init; }
public string? Relation { get; init; }
public string? DependencyType { get; init; }
}
public sealed record AiSecurityFinding
{
public AiSecurityFindingType Type { get; init; }
public Severity Severity { get; init; } = Severity.Unknown;
public string Title { get; init; } = string.Empty;
public string? Description { get; init; }
public string? Remediation { get; init; }
public string? ComponentName { get; init; }
public string? ComponentBomRef { get; init; }
public string? ModelName { get; init; }
public string? DatasetName { get; init; }
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
public sealed record AiRiskAssessment
{
public string Category { get; init; } = string.Empty;
public string Level { get; init; } = string.Empty;
public string? Description { get; init; }
public string? ModelBomRef { get; init; }
public ImmutableArray<string> Evidence { get; init; } = [];
}
public sealed record AiComplianceStatus
{
public ImmutableArray<AiComplianceFrameworkStatus> Frameworks { get; init; } = [];
}
public sealed record AiComplianceFrameworkStatus
{
public string Framework { get; init; } = string.Empty;
public bool IsCompliant { get; init; }
public int ViolationCount { get; init; }
}
public sealed record AiMlSummary
{
public int TotalFindings { get; init; }
public int ModelCount { get; init; }
public int DatasetCount { get; init; }
public int HighRiskModelCount { get; init; }
public ImmutableDictionary<Severity, int> FindingsBySeverity { get; init; } =
ImmutableDictionary<Severity, int>.Empty;
}

View File

@@ -0,0 +1,63 @@
using System.Collections.Immutable;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Policy;
public sealed record AiGovernancePolicy
{
public string? Version { get; init; }
public string? ComplianceFramework { get; init; }
public ImmutableArray<string> ComplianceFrameworks { get; init; } = [];
public AiModelCardRequirements ModelCardRequirements { get; init; } = new();
public AiTrainingDataRequirements TrainingDataRequirements { get; init; } = new();
public AiRiskCategories RiskCategories { get; init; } = new();
public AiSafetyRequirements SafetyRequirements { get; init; } = new();
public AiProvenanceRequirements ProvenanceRequirements { get; init; } = new();
public bool RequireRiskAssessment { get; init; }
public ImmutableArray<AiGovernanceExemption> Exemptions { get; init; } = [];
}
public sealed record AiModelCardRequirements
{
public AiModelCardCompleteness MinimumCompleteness { get; init; } = AiModelCardCompleteness.Basic;
public ImmutableArray<string> RequiredSections { get; init; } = [];
}
public sealed record AiTrainingDataRequirements
{
public bool RequireProvenance { get; init; } = true;
public bool SensitiveDataAllowed { get; init; }
public bool RequireBiasAssessment { get; init; } = true;
}
public sealed record AiRiskCategories
{
public ImmutableArray<string> HighRisk { get; init; } = [];
}
public sealed record AiSafetyRequirements
{
public bool RequireSafetyAssessment { get; init; } = true;
public AiHumanOversightRequirements HumanOversightRequired { get; init; } = new();
}
public sealed record AiHumanOversightRequirements
{
public bool ForHighRisk { get; init; } = true;
}
public sealed record AiProvenanceRequirements
{
public bool RequireHash { get; init; }
public bool RequireSignature { get; init; }
public ImmutableArray<string> TrustedSources { get; init; } = [];
public ImmutableArray<string> KnownModelHubs { get; init; } = [];
}
public sealed record AiGovernanceExemption
{
public string? ModelPattern { get; init; }
public string? Reason { get; init; }
public bool RiskAccepted { get; init; }
public DateOnly? ExpirationDate { get; init; }
}

View File

@@ -0,0 +1,152 @@
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.AiMlSecurity.Models;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Scanner.AiMlSecurity.Policy;
public interface IAiGovernancePolicyLoader
{
Task<AiGovernancePolicy> LoadAsync(string? path, CancellationToken ct = default);
}
public static class AiGovernancePolicyDefaults
{
public static AiGovernancePolicy Default { get; } = new()
{
ComplianceFrameworks = ["EU-AI-Act", "NIST-AI-RMF"],
ModelCardRequirements = new AiModelCardRequirements
{
MinimumCompleteness = AiModelCardCompleteness.Standard,
RequiredSections = [
"modelParameters",
"quantitativeAnalysis",
"considerations"
]
},
TrainingDataRequirements = new AiTrainingDataRequirements
{
RequireProvenance = true,
SensitiveDataAllowed = false,
RequireBiasAssessment = true
},
RiskCategories = new AiRiskCategories
{
HighRisk = [
"biometricIdentification",
"criticalInfrastructure",
"employmentDecisions",
"creditScoring",
"lawEnforcement"
]
},
SafetyRequirements = new AiSafetyRequirements
{
RequireSafetyAssessment = true,
HumanOversightRequired = new AiHumanOversightRequirements
{
ForHighRisk = true
}
},
ProvenanceRequirements = new AiProvenanceRequirements
{
RequireHash = false,
RequireSignature = false,
TrustedSources = ["huggingface", "modelzoo"],
KnownModelHubs = ["huggingface", "tensorflowhub", "pytorchhub"]
}
};
}
public sealed class AiGovernancePolicyLoader : IAiGovernancePolicyLoader
{
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
private readonly IDeserializer _yamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
public async Task<AiGovernancePolicy> LoadAsync(string? path, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return AiGovernancePolicyDefaults.Default;
}
var extension = Path.GetExtension(path).ToLowerInvariant();
await using var stream = File.OpenRead(path);
return extension switch
{
".yaml" or ".yml" => LoadFromYaml(stream),
_ => await LoadFromJsonAsync(stream, ct).ConfigureAwait(false)
};
}
private AiGovernancePolicy LoadFromYaml(Stream stream)
{
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var yamlObject = _yamlDeserializer.Deserialize(reader);
if (yamlObject is null)
{
return AiGovernancePolicyDefaults.Default;
}
var payload = JsonSerializer.Serialize(yamlObject);
using var document = JsonDocument.Parse(payload);
return ExtractPolicy(document.RootElement);
}
private static async Task<AiGovernancePolicy> LoadFromJsonAsync(Stream stream, CancellationToken ct)
{
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct)
.ConfigureAwait(false);
return ExtractPolicy(document.RootElement);
}
private static AiGovernancePolicy ExtractPolicy(JsonElement root)
{
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty("aiGovernancePolicy", out var policyElement))
{
return JsonSerializer.Deserialize<AiGovernancePolicy>(policyElement, JsonOptions)
?? AiGovernancePolicyDefaults.Default;
}
return JsonSerializer.Deserialize<AiGovernancePolicy>(root, JsonOptions)
?? AiGovernancePolicyDefaults.Default;
}
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
options.Converters.Add(new FlexibleBooleanConverter());
return options;
}
private sealed class FlexibleBooleanConverter : JsonConverter<bool>
{
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.True => true,
JsonTokenType.False => false,
JsonTokenType.String when bool.TryParse(reader.GetString(), out var value) => value,
_ => throw new JsonException($"Expected boolean value or boolean string, got {reader.TokenType}.")
};
}
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
{
writer.WriteBooleanValue(value);
}
}
}

View File

@@ -0,0 +1,165 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.AiMlSecurity.Models;
namespace StellaOps.Scanner.AiMlSecurity.Reporting;
public static class AiMlSecurityReportFormatter
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
public static byte[] ToJsonBytes(AiMlSecurityReport report)
{
return JsonSerializer.SerializeToUtf8Bytes(report, JsonOptions);
}
public static string ToText(AiMlSecurityReport report)
{
var builder = new StringBuilder();
builder.AppendLine("AI/ML Security Report");
builder.AppendLine($"Findings: {report.Summary.TotalFindings}");
builder.AppendLine($"Models: {report.Summary.ModelCount}");
builder.AppendLine($"Datasets: {report.Summary.DatasetCount}");
if (report.Summary.FindingsBySeverity.Count > 0)
{
builder.AppendLine();
builder.AppendLine("Findings by Severity:");
foreach (var severityGroup in report.Summary.FindingsBySeverity.OrderByDescending(kvp => kvp.Key))
{
builder.AppendLine($" {severityGroup.Key}: {severityGroup.Value}");
}
}
if (!report.ComplianceStatus.Frameworks.IsDefaultOrEmpty)
{
builder.AppendLine();
builder.AppendLine("Compliance:");
foreach (var framework in report.ComplianceStatus.Frameworks)
{
builder.AppendLine($" {framework.Framework}: {(framework.IsCompliant ? "Compliant" : "Non-compliant")} ({framework.ViolationCount} violations)");
}
}
if (!report.RiskAssessments.IsDefaultOrEmpty)
{
builder.AppendLine();
builder.AppendLine("Risk Assessments:");
foreach (var assessment in report.RiskAssessments)
{
builder.AppendLine($" {assessment.Category}: {assessment.Level}");
}
}
if (!report.Findings.IsDefaultOrEmpty)
{
builder.AppendLine();
foreach (var finding in report.Findings)
{
builder.AppendLine($"- [{finding.Severity}] {finding.Title} ({finding.ComponentName ?? finding.ComponentBomRef})");
if (!string.IsNullOrWhiteSpace(finding.Description))
{
builder.AppendLine($" {finding.Description}");
}
if (!string.IsNullOrWhiteSpace(finding.Remediation))
{
builder.AppendLine($" Remediation: {finding.Remediation}");
}
}
}
return builder.ToString();
}
public static byte[] ToPdfBytes(AiMlSecurityReport report)
{
return SimplePdfBuilder.Build(ToText(report));
}
}
internal static class SimplePdfBuilder
{
public static byte[] Build(string text)
{
var lines = text.Replace("\r", string.Empty).Split('\n');
var contentStream = BuildContentStream(lines);
var objects = new List<string>
{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
$"<< /Length {contentStream.Length} >>\nstream\n{contentStream}\nendstream",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
};
using var stream = new MemoryStream();
WriteLine(stream, "%PDF-1.4");
var offsets = new List<long> { 0 };
for (var i = 0; i < objects.Count; i++)
{
offsets.Add(stream.Position);
WriteLine(stream, $"{i + 1} 0 obj");
WriteLine(stream, objects[i]);
WriteLine(stream, "endobj");
}
var xrefStart = stream.Position;
WriteLine(stream, "xref");
WriteLine(stream, $"0 {objects.Count + 1}");
WriteLine(stream, "0000000000 65535 f ");
for (var i = 1; i < offsets.Count; i++)
{
WriteLine(stream, $"{offsets[i]:0000000000} 00000 n ");
}
WriteLine(stream, "trailer");
WriteLine(stream, $"<< /Size {objects.Count + 1} /Root 1 0 R >>");
WriteLine(stream, "startxref");
WriteLine(stream, xrefStart.ToString());
WriteLine(stream, "%%EOF");
return stream.ToArray();
}
private static string BuildContentStream(IEnumerable<string> lines)
{
var builder = new StringBuilder();
builder.AppendLine("BT");
builder.AppendLine("/F1 10 Tf");
var y = 760;
foreach (var line in lines)
{
var escaped = EscapeText(line);
builder.AppendLine($"72 {y} Td ({escaped}) Tj");
y -= 14;
if (y < 60)
{
break;
}
}
builder.AppendLine("ET");
return builder.ToString();
}
private static string EscapeText(string value)
{
return value.Replace("\\", "\\\\")
.Replace("(", "\\(")
.Replace(")", "\\)");
}
private static void WriteLine(Stream stream, string line)
{
var bytes = Encoding.ASCII.GetBytes(line + "\n");
stream.Write(bytes, 0, bytes.Length);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.ML/StellaOps.BinaryIndex.ML.csproj" />
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.SbomIntegration/StellaOps.Concelier.SbomIntegration.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,652 @@
// -----------------------------------------------------------------------------
// DotNetLicenseDetector.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-010 - Add .NET/NuGet license detector
// Description: Enhanced .NET license detection returning LicenseDetectionResult
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
using StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.BuildMetadata;
namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal.Licensing;
/// <summary>
/// Enhanced .NET/NuGet license detector that returns full LicenseDetectionResult.
/// Supports .csproj, .nuspec, AssemblyInfo, and LICENSE file extraction.
/// </summary>
internal sealed partial class DotNetLicenseDetector
{
private readonly ILicenseCategorizationService _categorizationService;
private readonly ILicenseTextExtractor _textExtractor;
private readonly ICopyrightExtractor _copyrightExtractor;
/// <summary>
/// Creates a new .NET license detector with the specified services.
/// </summary>
public DotNetLicenseDetector(
ILicenseCategorizationService categorizationService,
ILicenseTextExtractor textExtractor,
ICopyrightExtractor copyrightExtractor)
{
_categorizationService = categorizationService;
_textExtractor = textExtractor;
_copyrightExtractor = copyrightExtractor;
}
/// <summary>
/// Creates a new .NET license detector with default services.
/// </summary>
public DotNetLicenseDetector()
{
_categorizationService = new LicenseCategorizationService();
_textExtractor = new LicenseTextExtractor();
_copyrightExtractor = new CopyrightExtractor();
}
/// <summary>
/// Detects license information from .NET project metadata.
/// </summary>
/// <param name="projectMetadata">The project metadata.</param>
/// <param name="projectDirectory">Project directory for license file extraction.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The full license detection result.</returns>
public async Task<LicenseDetectionResult?> DetectFromProjectAsync(
DotNetProjectMetadata projectMetadata,
string? projectDirectory = null,
CancellationToken ct = default)
{
if (projectMetadata is null)
{
return null;
}
// Try to get license from project file metadata
var projectLicense = projectMetadata.Licenses.Length > 0
? projectMetadata.Licenses[0]
: null;
if (projectLicense is null)
{
// Try to detect from LICENSE file in project directory
if (!string.IsNullOrWhiteSpace(projectDirectory))
{
return await DetectFromDirectoryAsync(projectDirectory, ct);
}
return null;
}
// Extract license text if available
LicenseTextExtractionResult? licenseTextResult = null;
string? copyrightFromAssemblyInfo = null;
if (!string.IsNullOrWhiteSpace(projectDirectory))
{
// Try license file if specified
if (!string.IsNullOrWhiteSpace(projectLicense.File))
{
var licenseFilePath = Path.Combine(projectDirectory, projectLicense.File);
if (File.Exists(licenseFilePath))
{
licenseTextResult = await _textExtractor.ExtractAsync(licenseFilePath, ct);
}
}
else
{
// Try standard LICENSE files
var licenseFiles = await _textExtractor.ExtractFromDirectoryAsync(projectDirectory, ct);
licenseTextResult = licenseFiles.FirstOrDefault();
}
// Extract copyright from AssemblyInfo if exists
copyrightFromAssemblyInfo = await TryExtractAssemblyInfoCopyrightAsync(projectDirectory, ct);
}
// Determine SPDX ID
var spdxId = DetermineSpdxId(projectLicense);
// Get copyright notices
var copyrightNotices = new List<string>();
if (licenseTextResult?.CopyrightNotices.Length > 0)
{
copyrightNotices.AddRange(licenseTextResult.CopyrightNotices.Select(c => c.FullText));
}
if (!string.IsNullOrWhiteSpace(copyrightFromAssemblyInfo))
{
copyrightNotices.Add(copyrightFromAssemblyInfo);
}
var primaryCopyright = copyrightNotices.Count > 0
? copyrightNotices[0]
: null;
// Check for expression
var isExpression = IsExpression(spdxId);
var result = new LicenseDetectionResult
{
SpdxId = spdxId,
OriginalText = GetOriginalText(projectLicense),
LicenseUrl = projectLicense.Url,
Confidence = MapConfidence(projectLicense.Confidence),
Method = DetermineDetectionMethod(projectLicense),
SourceFile = projectMetadata.SourcePath ?? "*.csproj",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = licenseTextResult?.FullText,
LicenseTextHash = licenseTextResult?.TextHash,
CopyrightNotice = primaryCopyright,
IsExpression = isExpression,
ExpressionComponents = isExpression ? ParseExpressionComponents(spdxId) : []
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license from a .nuspec file.
/// </summary>
public async Task<LicenseDetectionResult?> DetectFromNuspecAsync(
string nuspecPath,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(nuspecPath) || !File.Exists(nuspecPath))
{
return null;
}
try
{
var content = await File.ReadAllTextAsync(nuspecPath, ct);
return await DetectFromNuspecContentAsync(content, Path.GetDirectoryName(nuspecPath), ct);
}
catch
{
return null;
}
}
/// <summary>
/// Detects license from .nuspec content.
/// </summary>
public async Task<LicenseDetectionResult?> DetectFromNuspecContentAsync(
string nuspecContent,
string? packageDirectory = null,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(nuspecContent))
{
return null;
}
try
{
var doc = XDocument.Parse(nuspecContent);
var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None;
var metadata = doc.Root?.Element(ns + "metadata");
if (metadata is null)
{
return null;
}
// Try license element (NuGet 4.9+)
var licenseElement = metadata.Element(ns + "license");
if (licenseElement is not null)
{
var licenseType = licenseElement.Attribute("type")?.Value;
var licenseValue = licenseElement.Value.Trim();
if (string.Equals(licenseType, "expression", StringComparison.OrdinalIgnoreCase))
{
return await CreateNuspecLicenseResultAsync(
licenseValue,
null,
LicenseDetectionMethod.PackageMetadata,
LicenseDetectionConfidence.High,
packageDirectory,
ct);
}
else if (string.Equals(licenseType, "file", StringComparison.OrdinalIgnoreCase))
{
// License is in a file within the package
if (!string.IsNullOrWhiteSpace(packageDirectory))
{
var licensePath = Path.Combine(packageDirectory, licenseValue);
if (File.Exists(licensePath))
{
return await DetectFromLicenseFileAsync(licensePath, ct);
}
}
}
}
// Try licenseUrl (deprecated but common)
var licenseUrl = metadata.Element(ns + "licenseUrl")?.Value;
if (!string.IsNullOrWhiteSpace(licenseUrl))
{
var spdxId = NormalizeFromUrl(licenseUrl);
return await CreateNuspecLicenseResultAsync(
spdxId,
licenseUrl,
LicenseDetectionMethod.UrlMatching,
spdxId.StartsWith("LicenseRef-", StringComparison.Ordinal)
? LicenseDetectionConfidence.Low
: LicenseDetectionConfidence.Medium,
packageDirectory,
ct);
}
return null;
}
catch
{
return null;
}
}
/// <summary>
/// Detects license from a directory (using LICENSE file).
/// </summary>
public async Task<LicenseDetectionResult?> DetectFromDirectoryAsync(
string directory,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
{
return null;
}
var licenseFiles = await _textExtractor.ExtractFromDirectoryAsync(directory, ct);
var licenseTextResult = licenseFiles.FirstOrDefault();
if (licenseTextResult is null || string.IsNullOrWhiteSpace(licenseTextResult.DetectedLicenseId))
{
return null;
}
var copyrightNotices = licenseTextResult.CopyrightNotices;
var primaryCopyright = copyrightNotices.Length > 0
? copyrightNotices[0].FullText
: null;
var result = new LicenseDetectionResult
{
SpdxId = licenseTextResult.DetectedLicenseId,
Confidence = licenseTextResult.Confidence,
Method = LicenseDetectionMethod.LicenseFile,
SourceFile = licenseTextResult.SourceFile ?? "LICENSE",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = licenseTextResult.FullText,
LicenseTextHash = licenseTextResult.TextHash,
CopyrightNotice = primaryCopyright
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license from LICENSE file content.
/// </summary>
public async Task<LicenseDetectionResult?> DetectFromLicenseFileAsync(
string licenseFilePath,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(licenseFilePath) || !File.Exists(licenseFilePath))
{
return null;
}
var licenseTextResult = await _textExtractor.ExtractAsync(licenseFilePath, ct);
if (licenseTextResult is null)
{
return null;
}
var spdxId = licenseTextResult.DetectedLicenseId ?? "LicenseRef-Unknown";
var confidence = licenseTextResult.DetectedLicenseId is not null
? licenseTextResult.Confidence
: LicenseDetectionConfidence.Low;
var result = new LicenseDetectionResult
{
SpdxId = spdxId,
Confidence = confidence,
Method = LicenseDetectionMethod.LicenseFile,
SourceFile = Path.GetFileName(licenseFilePath),
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = licenseTextResult.FullText,
LicenseTextHash = licenseTextResult.TextHash,
CopyrightNotice = licenseTextResult.CopyrightNotices.Length > 0
? licenseTextResult.CopyrightNotices[0].FullText
: null
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license synchronously from project license info.
/// </summary>
public LicenseDetectionResult? Detect(DotNetProjectLicenseInfo licenseInfo)
{
if (licenseInfo is null)
{
return null;
}
var spdxId = DetermineSpdxId(licenseInfo);
var isExpression = IsExpression(spdxId);
var result = new LicenseDetectionResult
{
SpdxId = spdxId,
OriginalText = GetOriginalText(licenseInfo),
LicenseUrl = licenseInfo.Url,
Confidence = MapConfidence(licenseInfo.Confidence),
Method = DetermineDetectionMethod(licenseInfo),
SourceFile = "*.csproj",
Category = LicenseCategory.Unknown,
Obligations = [],
IsExpression = isExpression,
ExpressionComponents = isExpression ? ParseExpressionComponents(spdxId) : []
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects licenses from multiple project license infos.
/// </summary>
public IReadOnlyList<LicenseDetectionResult> DetectMultiple(
IEnumerable<DotNetProjectLicenseInfo> licenseInfos)
{
var results = new List<LicenseDetectionResult>();
foreach (var info in licenseInfos)
{
var result = Detect(info);
if (result is not null)
{
results.Add(result);
}
}
return results;
}
private async Task<LicenseDetectionResult?> CreateNuspecLicenseResultAsync(
string spdxId,
string? url,
LicenseDetectionMethod method,
LicenseDetectionConfidence confidence,
string? packageDirectory,
CancellationToken ct)
{
LicenseTextExtractionResult? licenseTextResult = null;
if (!string.IsNullOrWhiteSpace(packageDirectory))
{
var licenseFiles = await _textExtractor.ExtractFromDirectoryAsync(packageDirectory, ct);
licenseTextResult = licenseFiles.FirstOrDefault();
}
var isExpression = IsExpression(spdxId);
var result = new LicenseDetectionResult
{
SpdxId = spdxId,
LicenseUrl = url,
Confidence = confidence,
Method = method,
SourceFile = "*.nuspec",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = licenseTextResult?.FullText,
LicenseTextHash = licenseTextResult?.TextHash,
CopyrightNotice = licenseTextResult?.CopyrightNotices.Length > 0
? licenseTextResult.CopyrightNotices[0].FullText
: null,
IsExpression = isExpression,
ExpressionComponents = isExpression ? ParseExpressionComponents(spdxId) : []
};
return _categorizationService.Enrich(result);
}
private static async Task<string?> TryExtractAssemblyInfoCopyrightAsync(
string projectDirectory,
CancellationToken ct)
{
// Look for AssemblyInfo.cs in Properties folder or root
var paths = new[]
{
Path.Combine(projectDirectory, "Properties", "AssemblyInfo.cs"),
Path.Combine(projectDirectory, "AssemblyInfo.cs")
};
foreach (var path in paths)
{
if (!File.Exists(path))
{
continue;
}
try
{
var content = await File.ReadAllTextAsync(path, ct);
var match = AssemblyCopyrightRegex().Match(content);
if (match.Success)
{
return match.Groups["copyright"].Value;
}
}
catch
{
// Ignore file read errors
}
}
return null;
}
private static string DetermineSpdxId(DotNetProjectLicenseInfo licenseInfo)
{
// Prefer normalized SPDX ID if available
if (!string.IsNullOrWhiteSpace(licenseInfo.NormalizedSpdxId))
{
return licenseInfo.NormalizedSpdxId;
}
// Try expression (highest confidence)
if (!string.IsNullOrWhiteSpace(licenseInfo.Expression))
{
return NormalizeSpdxExpression(licenseInfo.Expression);
}
// Try URL matching
if (!string.IsNullOrWhiteSpace(licenseInfo.Url))
{
return NormalizeFromUrl(licenseInfo.Url);
}
// Try file (need to inspect content, return unknown for now)
if (!string.IsNullOrWhiteSpace(licenseInfo.File))
{
return "LicenseRef-File";
}
return "LicenseRef-Unknown";
}
private static string? GetOriginalText(DotNetProjectLicenseInfo licenseInfo)
{
if (!string.IsNullOrWhiteSpace(licenseInfo.Expression))
{
return licenseInfo.Expression;
}
if (!string.IsNullOrWhiteSpace(licenseInfo.Url))
{
return licenseInfo.Url;
}
if (!string.IsNullOrWhiteSpace(licenseInfo.File))
{
return $"File: {licenseInfo.File}";
}
return null;
}
private static LicenseDetectionConfidence MapConfidence(DotNetProjectLicenseConfidence confidence)
{
return confidence switch
{
DotNetProjectLicenseConfidence.High => LicenseDetectionConfidence.High,
DotNetProjectLicenseConfidence.Medium => LicenseDetectionConfidence.Medium,
DotNetProjectLicenseConfidence.Low => LicenseDetectionConfidence.Low,
_ => LicenseDetectionConfidence.None
};
}
private static LicenseDetectionMethod DetermineDetectionMethod(DotNetProjectLicenseInfo licenseInfo)
{
if (!string.IsNullOrWhiteSpace(licenseInfo.Expression))
{
return LicenseDetectionMethod.PackageMetadata;
}
if (!string.IsNullOrWhiteSpace(licenseInfo.File))
{
return LicenseDetectionMethod.LicenseFile;
}
if (!string.IsNullOrWhiteSpace(licenseInfo.Url))
{
return LicenseDetectionMethod.UrlMatching;
}
return LicenseDetectionMethod.KeywordFallback;
}
private static string NormalizeSpdxExpression(string expression)
{
// Already an SPDX expression, just normalize spacing
return expression.Trim();
}
private static string NormalizeFromUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
{
return "LicenseRef-Unknown";
}
var lower = url.ToLowerInvariant();
// Common license URLs
if (lower.Contains("opensource.org/licenses/mit") || lower.Contains("mit-license"))
{
return "MIT";
}
if (lower.Contains("apache.org/licenses/license-2.0") || lower.Contains("apache-2.0"))
{
return "Apache-2.0";
}
if (lower.Contains("opensource.org/licenses/bsd-3-clause") || lower.Contains("bsd-3-clause"))
{
return "BSD-3-Clause";
}
if (lower.Contains("opensource.org/licenses/bsd-2-clause") || lower.Contains("bsd-2-clause"))
{
return "BSD-2-Clause";
}
if (lower.Contains("opensource.org/licenses/isc"))
{
return "ISC";
}
if (lower.Contains("gnu.org/licenses/gpl-3.0") || lower.Contains("gpl-3.0"))
{
return "GPL-3.0-only";
}
if (lower.Contains("gnu.org/licenses/gpl-2.0") || lower.Contains("gpl-2.0"))
{
return "GPL-2.0-only";
}
if (lower.Contains("gnu.org/licenses/lgpl-3.0") || lower.Contains("lgpl-3.0"))
{
return "LGPL-3.0-only";
}
if (lower.Contains("gnu.org/licenses/lgpl-2.1") || lower.Contains("lgpl-2.1"))
{
return "LGPL-2.1-only";
}
if (lower.Contains("mozilla.org/mpl/2.0") || lower.Contains("mpl-2.0"))
{
return "MPL-2.0";
}
if (lower.Contains("creativecommons.org/publicdomain/zero/1.0") || lower.Contains("cc0"))
{
return "CC0-1.0";
}
if (lower.Contains("unlicense.org") || lower.Contains("unlicense"))
{
return "Unlicense";
}
// NuGet.org license URLs
if (lower.Contains("licenses.nuget.org/"))
{
// Extract SPDX ID from URL like https://licenses.nuget.org/MIT
var parts = url.Split('/');
if (parts.Length > 0)
{
var lastPart = parts[^1].Trim();
if (!string.IsNullOrWhiteSpace(lastPart))
{
return lastPart;
}
}
}
return "LicenseRef-Url";
}
private static bool IsExpression(string spdxId)
{
return spdxId.Contains(" OR ", StringComparison.OrdinalIgnoreCase) ||
spdxId.Contains(" AND ", StringComparison.OrdinalIgnoreCase) ||
spdxId.Contains(" WITH ", StringComparison.OrdinalIgnoreCase) ||
(spdxId.Contains('(') && spdxId.Contains(')'));
}
private static ImmutableArray<string> ParseExpressionComponents(string expression)
{
var components = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var tokens = expression
.Replace("(", " ")
.Replace(")", " ")
.Split([' '], StringSplitOptions.RemoveEmptyEntries);
foreach (var token in tokens)
{
var upper = token.ToUpperInvariant();
if (upper is not "OR" and not "AND" and not "WITH")
{
components.Add(token);
}
}
return [.. components.OrderBy(c => c, StringComparer.Ordinal)];
}
[GeneratedRegex(@"\[assembly:\s*AssemblyCopyright\s*\(\s*""(?<copyright>[^""]+)""\s*\)\s*\]", RegexOptions.IgnoreCase)]
private static partial Regex AssemblyCopyrightRegex();
}

View File

@@ -0,0 +1,273 @@
// -----------------------------------------------------------------------------
// EnhancedGoLicenseDetector.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-007 - Upgrade Go license detector
// Description: Enhanced Go license detection returning LicenseDetectionResult
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Enhanced Go license detector that returns full LicenseDetectionResult.
/// </summary>
internal sealed class EnhancedGoLicenseDetector
{
private readonly ILicenseCategorizationService _categorizationService;
private readonly ILicenseTextExtractor _textExtractor;
private readonly ICopyrightExtractor _copyrightExtractor;
/// <summary>
/// Creates a new enhanced Go license detector with the specified services.
/// </summary>
public EnhancedGoLicenseDetector(
ILicenseCategorizationService categorizationService,
ILicenseTextExtractor textExtractor,
ICopyrightExtractor copyrightExtractor)
{
_categorizationService = categorizationService;
_textExtractor = textExtractor;
_copyrightExtractor = copyrightExtractor;
}
/// <summary>
/// Creates a new enhanced Go license detector with default services.
/// </summary>
public EnhancedGoLicenseDetector()
{
_categorizationService = new LicenseCategorizationService();
_textExtractor = new LicenseTextExtractor();
_copyrightExtractor = new CopyrightExtractor();
}
/// <summary>
/// Detects license for a Go module at the given path.
/// </summary>
/// <param name="modulePath">Path to the Go module directory.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The full license detection result.</returns>
public async Task<LicenseDetectionResult?> DetectAsync(string modulePath, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(modulePath) || !Directory.Exists(modulePath))
{
return null;
}
// Extract license files from the directory
var licenseTextResults = await _textExtractor.ExtractFromDirectoryAsync(modulePath, ct);
var primaryLicenseResult = licenseTextResults.FirstOrDefault();
// Use existing detector for SPDX identification
var basicResult = GoLicenseDetector.DetectLicense(modulePath);
if (!basicResult.IsDetected && primaryLicenseResult is null)
{
return null;
}
// Get SPDX ID from existing detector or from text extraction
var spdxId = basicResult.SpdxIdentifier
?? primaryLicenseResult?.DetectedLicenseId
?? "LicenseRef-Unknown";
// Map confidence
var confidence = MapConfidence(basicResult.Confidence, primaryLicenseResult?.Confidence);
// Get copyright notices
var copyrightNotices = primaryLicenseResult?.CopyrightNotices ?? [];
var primaryCopyright = copyrightNotices.Length > 0
? copyrightNotices[0].FullText
: null;
// Check for dual licensing (common in Go: MIT OR Apache-2.0)
var isExpression = spdxId.Contains(" OR ", StringComparison.OrdinalIgnoreCase) ||
spdxId.Contains(" AND ", StringComparison.OrdinalIgnoreCase);
var result = new LicenseDetectionResult
{
SpdxId = spdxId,
OriginalText = basicResult.RawLicenseName,
Confidence = confidence,
Method = DetermineDetectionMethod(basicResult, primaryLicenseResult),
SourceFile = basicResult.LicenseFile ?? primaryLicenseResult?.SourceFile ?? "LICENSE",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = primaryLicenseResult?.FullText,
LicenseTextHash = primaryLicenseResult?.TextHash,
CopyrightNotice = primaryCopyright,
IsExpression = isExpression,
ExpressionComponents = isExpression ? ParseExpressionComponents(spdxId) : []
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license for a vendored Go module.
/// </summary>
public async Task<LicenseDetectionResult?> DetectVendoredAsync(
string vendorPath,
string modulePath,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(vendorPath) || string.IsNullOrWhiteSpace(modulePath))
{
return null;
}
var vendoredModulePath = Path.Combine(vendorPath, modulePath.Replace('/', Path.DirectorySeparatorChar));
if (Directory.Exists(vendoredModulePath))
{
return await DetectAsync(vendoredModulePath, ct);
}
return null;
}
/// <summary>
/// Detects license from license file content synchronously.
/// </summary>
public LicenseDetectionResult? DetectFromContent(string content, string? sourceFile = null)
{
if (string.IsNullOrWhiteSpace(content))
{
return null;
}
var basicResult = GoLicenseDetector.AnalyzeLicenseContent(content, sourceFile);
var textResult = _textExtractor.Extract(content, sourceFile);
var spdxId = basicResult.SpdxIdentifier
?? textResult.DetectedLicenseId
?? "LicenseRef-Unknown";
var confidence = MapConfidence(basicResult.Confidence, textResult.Confidence);
var copyrightNotices = textResult.CopyrightNotices;
var primaryCopyright = copyrightNotices.Length > 0
? copyrightNotices[0].FullText
: null;
var isExpression = spdxId.Contains(" OR ", StringComparison.OrdinalIgnoreCase) ||
spdxId.Contains(" AND ", StringComparison.OrdinalIgnoreCase);
var result = new LicenseDetectionResult
{
SpdxId = spdxId,
OriginalText = basicResult.RawLicenseName,
Confidence = confidence,
Method = LicenseDetectionMethod.LicenseFile,
SourceFile = sourceFile ?? "LICENSE",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = content,
LicenseTextHash = textResult.TextHash,
CopyrightNotice = primaryCopyright,
IsExpression = isExpression,
ExpressionComponents = isExpression ? ParseExpressionComponents(spdxId) : []
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license synchronously without full text extraction.
/// </summary>
public LicenseDetectionResult? Detect(string modulePath)
{
if (string.IsNullOrWhiteSpace(modulePath) || !Directory.Exists(modulePath))
{
return null;
}
var basicResult = GoLicenseDetector.DetectLicense(modulePath);
if (!basicResult.IsDetected)
{
return null;
}
var confidence = MapConfidence(basicResult.Confidence, null);
var isExpression = basicResult.SpdxIdentifier?.Contains(" OR ", StringComparison.OrdinalIgnoreCase) == true ||
basicResult.SpdxIdentifier?.Contains(" AND ", StringComparison.OrdinalIgnoreCase) == true;
var result = new LicenseDetectionResult
{
SpdxId = basicResult.SpdxIdentifier!,
OriginalText = basicResult.RawLicenseName,
Confidence = confidence,
Method = LicenseDetectionMethod.LicenseFile,
SourceFile = basicResult.LicenseFile ?? "LICENSE",
Category = LicenseCategory.Unknown,
Obligations = [],
IsExpression = isExpression,
ExpressionComponents = isExpression ? ParseExpressionComponents(basicResult.SpdxIdentifier!) : []
};
return _categorizationService.Enrich(result);
}
private static LicenseDetectionConfidence MapConfidence(
GoLicenseDetector.LicenseConfidence goConfidence,
LicenseDetectionConfidence? textConfidence)
{
// Use the higher confidence from either source
var goMapped = goConfidence switch
{
GoLicenseDetector.LicenseConfidence.High => LicenseDetectionConfidence.High,
GoLicenseDetector.LicenseConfidence.Medium => LicenseDetectionConfidence.Medium,
GoLicenseDetector.LicenseConfidence.Low => LicenseDetectionConfidence.Low,
_ => LicenseDetectionConfidence.None
};
if (textConfidence.HasValue && textConfidence.Value > goMapped)
{
return textConfidence.Value;
}
return goMapped;
}
private static LicenseDetectionMethod DetermineDetectionMethod(
GoLicenseDetector.LicenseInfo basicResult,
LicenseTextExtractionResult? textResult)
{
// If we have high confidence from SPDX identifier in file
if (basicResult.Confidence == GoLicenseDetector.LicenseConfidence.High)
{
return LicenseDetectionMethod.SpdxHeader;
}
// Pattern matching from license file
if (basicResult.IsDetected || textResult?.DetectedLicenseId is not null)
{
return LicenseDetectionMethod.PatternMatching;
}
return LicenseDetectionMethod.KeywordFallback;
}
private static ImmutableArray<string> ParseExpressionComponents(string expression)
{
var components = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var tokens = expression
.Replace("(", " ")
.Replace(")", " ")
.Split([' '], StringSplitOptions.RemoveEmptyEntries);
foreach (var token in tokens)
{
var upper = token.ToUpperInvariant();
if (upper is not "OR" and not "AND" and not "WITH")
{
components.Add(token);
}
}
return [.. components.OrderBy(c => c, StringComparer.Ordinal)];
}
}

View File

@@ -0,0 +1,316 @@
// -----------------------------------------------------------------------------
// JavaLicenseDetector.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-006 - Upgrade Java license detector
// Description: Enhanced Java license detection returning LicenseDetectionResult
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.License;
/// <summary>
/// Enhanced Java license detector that returns full LicenseDetectionResult.
/// </summary>
internal sealed class JavaLicenseDetector
{
private readonly ILicenseCategorizationService _categorizationService;
private readonly ILicenseTextExtractor _textExtractor;
private readonly ICopyrightExtractor _copyrightExtractor;
private readonly SpdxLicenseNormalizer _normalizer;
/// <summary>
/// Creates a new Java license detector with the specified services.
/// </summary>
public JavaLicenseDetector(
ILicenseCategorizationService categorizationService,
ILicenseTextExtractor textExtractor,
ICopyrightExtractor copyrightExtractor)
{
_categorizationService = categorizationService;
_textExtractor = textExtractor;
_copyrightExtractor = copyrightExtractor;
_normalizer = SpdxLicenseNormalizer.Instance;
}
/// <summary>
/// Creates a new Java license detector with default services.
/// </summary>
public JavaLicenseDetector()
{
_categorizationService = new LicenseCategorizationService();
_textExtractor = new LicenseTextExtractor();
_copyrightExtractor = new CopyrightExtractor();
_normalizer = SpdxLicenseNormalizer.Instance;
}
/// <summary>
/// Detects license information from Java license info.
/// </summary>
/// <param name="licenseInfo">The license info from project metadata.</param>
/// <param name="projectDirectory">Project directory for license file extraction.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The full license detection result.</returns>
public async Task<LicenseDetectionResult?> DetectAsync(
JavaLicenseInfo licenseInfo,
string? projectDirectory = null,
CancellationToken ct = default)
{
if (licenseInfo.Name is null && licenseInfo.Url is null)
{
return null;
}
// Use existing normalizer
var normalized = _normalizer.Normalize(licenseInfo.Name, licenseInfo.Url);
var spdxId = normalized.SpdxId ?? BuildLicenseRef(licenseInfo.Name);
// Determine confidence
var confidence = MapConfidence(normalized.SpdxConfidence);
// Extract license text if project directory is available
LicenseTextExtractionResult? licenseTextResult = null;
string? noticeContent = null;
if (!string.IsNullOrWhiteSpace(projectDirectory))
{
// Extract LICENSE file
var licenseFiles = await _textExtractor.ExtractFromDirectoryAsync(projectDirectory, ct);
licenseTextResult = licenseFiles.FirstOrDefault();
// Look for NOTICE file (common in Apache projects)
noticeContent = await TryReadNoticeFileAsync(projectDirectory, ct);
}
// Get copyright notices from LICENSE and NOTICE files
var copyrightNotices = new List<CopyrightNotice>();
if (licenseTextResult?.CopyrightNotices.Length > 0)
{
copyrightNotices.AddRange(licenseTextResult.CopyrightNotices);
}
if (!string.IsNullOrWhiteSpace(noticeContent))
{
copyrightNotices.AddRange(_copyrightExtractor.Extract(noticeContent));
}
var primaryCopyright = copyrightNotices.Count > 0
? copyrightNotices[0].FullText
: null;
var result = new LicenseDetectionResult
{
SpdxId = spdxId,
OriginalText = FormatOriginalText(licenseInfo),
LicenseUrl = licenseInfo.Url,
Confidence = confidence,
Method = DetermineDetectionMethod(licenseInfo),
SourceFile = "pom.xml",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = licenseTextResult?.FullText,
LicenseTextHash = licenseTextResult?.TextHash,
CopyrightNotice = primaryCopyright,
IsExpression = false,
ExpressionComponents = []
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects licenses from multiple license declarations (pom.xml can have multiple).
/// </summary>
public async Task<IReadOnlyList<LicenseDetectionResult>> DetectMultipleAsync(
IEnumerable<JavaLicenseInfo> licenses,
string? projectDirectory = null,
CancellationToken ct = default)
{
var results = new List<LicenseDetectionResult>();
foreach (var license in licenses)
{
var result = await DetectAsync(license, projectDirectory, ct);
if (result is not null)
{
results.Add(result);
}
}
return results;
}
/// <summary>
/// Creates a combined expression from multiple license results.
/// </summary>
public LicenseDetectionResult? CombineAsExpression(IReadOnlyList<LicenseDetectionResult> results)
{
if (results.Count == 0)
{
return null;
}
if (results.Count == 1)
{
return results[0];
}
// Multiple licenses - create OR expression (dual licensing is common in Java)
var spdxIds = results
.Select(r => r.SpdxId)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.Ordinal)
.ToList();
var expression = string.Join(" OR ", spdxIds);
// Use the first result as base and update
var first = results[0];
return first with
{
SpdxId = expression,
IsExpression = true,
ExpressionComponents = [.. spdxIds],
OriginalText = string.Join("; ", results.Select(r => r.OriginalText).Where(t => t is not null))
};
}
/// <summary>
/// Detects license from LICENSE file content.
/// </summary>
public LicenseDetectionResult? DetectFromLicenseFile(string licenseText, string? sourceFile = null)
{
if (string.IsNullOrWhiteSpace(licenseText))
{
return null;
}
var textResult = _textExtractor.Extract(licenseText, sourceFile);
var spdxId = textResult.DetectedLicenseId ?? "LicenseRef-Unknown";
var confidence = textResult.DetectedLicenseId is not null
? textResult.Confidence
: LicenseDetectionConfidence.Low;
var result = new LicenseDetectionResult
{
SpdxId = spdxId,
Confidence = confidence,
Method = LicenseDetectionMethod.LicenseFile,
SourceFile = sourceFile ?? "LICENSE",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = licenseText,
LicenseTextHash = textResult.TextHash,
CopyrightNotice = textResult.CopyrightNotices.Length > 0
? textResult.CopyrightNotices[0].FullText
: null
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license synchronously without file extraction.
/// </summary>
public LicenseDetectionResult? Detect(JavaLicenseInfo licenseInfo)
{
if (licenseInfo.Name is null && licenseInfo.Url is null)
{
return null;
}
var normalized = _normalizer.Normalize(licenseInfo.Name, licenseInfo.Url);
var spdxId = normalized.SpdxId ?? BuildLicenseRef(licenseInfo.Name);
var confidence = MapConfidence(normalized.SpdxConfidence);
var result = new LicenseDetectionResult
{
SpdxId = spdxId,
OriginalText = FormatOriginalText(licenseInfo),
LicenseUrl = licenseInfo.Url,
Confidence = confidence,
Method = DetermineDetectionMethod(licenseInfo),
SourceFile = "pom.xml",
Category = LicenseCategory.Unknown,
Obligations = []
};
return _categorizationService.Enrich(result);
}
private static async Task<string?> TryReadNoticeFileAsync(string directory, CancellationToken ct)
{
var noticeFiles = new[] { "NOTICE", "NOTICE.txt", "NOTICE.md" };
foreach (var noticeFile in noticeFiles)
{
var path = Path.Combine(directory, noticeFile);
if (File.Exists(path))
{
try
{
return await File.ReadAllTextAsync(path, ct);
}
catch
{
// Ignore file read errors
}
}
}
return null;
}
private static LicenseDetectionConfidence MapConfidence(SpdxConfidence spdxConfidence)
{
return spdxConfidence switch
{
SpdxConfidence.High => LicenseDetectionConfidence.High,
SpdxConfidence.Medium => LicenseDetectionConfidence.Medium,
SpdxConfidence.Low => LicenseDetectionConfidence.Low,
_ => LicenseDetectionConfidence.None
};
}
private static LicenseDetectionMethod DetermineDetectionMethod(JavaLicenseInfo licenseInfo)
{
if (!string.IsNullOrWhiteSpace(licenseInfo.Url))
{
return LicenseDetectionMethod.UrlMatching;
}
return LicenseDetectionMethod.PackageMetadata;
}
private static string? FormatOriginalText(JavaLicenseInfo licenseInfo)
{
if (licenseInfo.Name is not null && licenseInfo.Url is not null)
{
return $"{licenseInfo.Name} ({licenseInfo.Url})";
}
return licenseInfo.Name ?? licenseInfo.Url;
}
private static string BuildLicenseRef(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return "LicenseRef-Unknown";
}
// Sanitize for SPDX LicenseRef
var sanitized = new char[Math.Min(name.Length, 50)];
for (var i = 0; i < sanitized.Length; i++)
{
var c = name[i];
sanitized[i] = char.IsLetterOrDigit(c) || c == '.' || c == '-'
? c
: '-';
}
return $"LicenseRef-{new string(sanitized).Trim('-')}";
}
}

View File

@@ -0,0 +1,586 @@
// -----------------------------------------------------------------------------
// NodeLicenseDetector.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-009 - Add JavaScript/TypeScript license detector
// Description: Enhanced Node/JavaScript license detection returning LicenseDetectionResult
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal.Licensing;
/// <summary>
/// Enhanced Node/JavaScript license detector that returns full LicenseDetectionResult.
/// Supports package.json license field, licenses array (legacy), SPDX expressions,
/// and LICENSE file extraction.
/// </summary>
internal sealed class NodeLicenseDetector
{
private readonly ILicenseCategorizationService _categorizationService;
private readonly ILicenseTextExtractor _textExtractor;
private readonly ICopyrightExtractor _copyrightExtractor;
/// <summary>
/// Creates a new Node license detector with the specified services.
/// </summary>
public NodeLicenseDetector(
ILicenseCategorizationService categorizationService,
ILicenseTextExtractor textExtractor,
ICopyrightExtractor copyrightExtractor)
{
_categorizationService = categorizationService;
_textExtractor = textExtractor;
_copyrightExtractor = copyrightExtractor;
}
/// <summary>
/// Creates a new Node license detector with default services.
/// </summary>
public NodeLicenseDetector()
{
_categorizationService = new LicenseCategorizationService();
_textExtractor = new LicenseTextExtractor();
_copyrightExtractor = new CopyrightExtractor();
}
/// <summary>
/// Detects license information from package.json content.
/// </summary>
/// <param name="packageJsonContent">The package.json content as JSON string.</param>
/// <param name="packageDirectory">Package directory for license file extraction.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The full license detection result.</returns>
public async Task<LicenseDetectionResult?> DetectFromPackageJsonAsync(
string packageJsonContent,
string? packageDirectory = null,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(packageJsonContent))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(packageJsonContent);
var root = doc.RootElement;
return await DetectFromPackageJsonAsync(root, packageDirectory, ct);
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Detects license information from parsed package.json.
/// </summary>
/// <param name="packageJson">The parsed package.json root element.</param>
/// <param name="packageDirectory">Package directory for license file extraction.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The full license detection result.</returns>
public async Task<LicenseDetectionResult?> DetectFromPackageJsonAsync(
JsonElement packageJson,
string? packageDirectory = null,
CancellationToken ct = default)
{
// Try to extract license info from package.json
var licenseInfo = ExtractLicenseInfo(packageJson);
if (licenseInfo is null)
{
// No license in package.json, try LICENSE file if directory is available
if (!string.IsNullOrWhiteSpace(packageDirectory))
{
return await DetectFromDirectoryAsync(packageDirectory, ct);
}
return null;
}
// Extract license text if package directory is available
LicenseTextExtractionResult? licenseTextResult = null;
if (!string.IsNullOrWhiteSpace(packageDirectory))
{
var licenseFiles = await _textExtractor.ExtractFromDirectoryAsync(packageDirectory, ct);
licenseTextResult = licenseFiles.FirstOrDefault();
}
// Get copyright notices
var copyrightNotices = licenseTextResult?.CopyrightNotices ?? [];
var primaryCopyright = copyrightNotices.Length > 0
? copyrightNotices[0].FullText
: null;
// Build the result
var result = new LicenseDetectionResult
{
SpdxId = licenseInfo.SpdxId,
OriginalText = licenseInfo.OriginalText,
LicenseUrl = licenseInfo.Url,
Confidence = licenseInfo.Confidence,
Method = licenseInfo.Method,
SourceFile = "package.json",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = licenseTextResult?.FullText,
LicenseTextHash = licenseTextResult?.TextHash,
CopyrightNotice = primaryCopyright,
IsExpression = licenseInfo.IsExpression,
ExpressionComponents = licenseInfo.ExpressionComponents
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license from a package directory (using LICENSE file).
/// </summary>
public async Task<LicenseDetectionResult?> DetectFromDirectoryAsync(
string packageDirectory,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(packageDirectory) || !Directory.Exists(packageDirectory))
{
return null;
}
var licenseFiles = await _textExtractor.ExtractFromDirectoryAsync(packageDirectory, ct);
var licenseTextResult = licenseFiles.FirstOrDefault();
if (licenseTextResult is null || string.IsNullOrWhiteSpace(licenseTextResult.DetectedLicenseId))
{
return null;
}
var copyrightNotices = licenseTextResult.CopyrightNotices;
var primaryCopyright = copyrightNotices.Length > 0
? copyrightNotices[0].FullText
: null;
var result = new LicenseDetectionResult
{
SpdxId = licenseTextResult.DetectedLicenseId,
Confidence = licenseTextResult.Confidence,
Method = LicenseDetectionMethod.LicenseFile,
SourceFile = licenseTextResult.SourceFile ?? "LICENSE",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = licenseTextResult.FullText,
LicenseTextHash = licenseTextResult.TextHash,
CopyrightNotice = primaryCopyright
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license from license field string (synchronous, no file extraction).
/// </summary>
public LicenseDetectionResult? Detect(string? license)
{
if (string.IsNullOrWhiteSpace(license))
{
return null;
}
var normalized = NormalizeSpdxId(license);
var isExpression = IsExpression(normalized);
var result = new LicenseDetectionResult
{
SpdxId = normalized,
OriginalText = license != normalized ? license : null,
Confidence = DetermineConfidence(license),
Method = LicenseDetectionMethod.PackageMetadata,
SourceFile = "package.json",
Category = LicenseCategory.Unknown,
Obligations = [],
IsExpression = isExpression,
ExpressionComponents = isExpression ? ParseExpressionComponents(normalized) : []
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license from LICENSE file content.
/// </summary>
public LicenseDetectionResult? DetectFromLicenseFile(string licenseText, string? sourceFile = null)
{
if (string.IsNullOrWhiteSpace(licenseText))
{
return null;
}
var textResult = _textExtractor.Extract(licenseText, sourceFile);
var spdxId = textResult.DetectedLicenseId ?? "LicenseRef-Unknown";
var confidence = textResult.DetectedLicenseId is not null
? textResult.Confidence
: LicenseDetectionConfidence.Low;
var result = new LicenseDetectionResult
{
SpdxId = spdxId,
Confidence = confidence,
Method = LicenseDetectionMethod.LicenseFile,
SourceFile = sourceFile ?? "LICENSE",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = licenseText,
LicenseTextHash = textResult.TextHash,
CopyrightNotice = textResult.CopyrightNotices.Length > 0
? textResult.CopyrightNotices[0].FullText
: null
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license for a NodePackage.
/// </summary>
public LicenseDetectionResult? DetectFromPackage(NodePackage package)
{
if (package is null || string.IsNullOrWhiteSpace(package.License))
{
return null;
}
return Detect(package.License);
}
/// <summary>
/// Detects license for a NodePackage with file extraction.
/// </summary>
public async Task<LicenseDetectionResult?> DetectFromPackageAsync(
NodePackage package,
string? rootDirectory = null,
CancellationToken ct = default)
{
if (package is null)
{
return null;
}
// If we have a license field and root directory, try to extract full info
if (!string.IsNullOrWhiteSpace(rootDirectory) && !string.IsNullOrWhiteSpace(package.RelativePath))
{
var packageDirectory = Path.Combine(rootDirectory, package.RelativePath.Replace('/', Path.DirectorySeparatorChar));
if (Directory.Exists(packageDirectory))
{
// Try to read package.json from directory for full extraction
var packageJsonPath = Path.Combine(packageDirectory, "package.json");
if (File.Exists(packageJsonPath))
{
try
{
var content = await File.ReadAllTextAsync(packageJsonPath, ct);
return await DetectFromPackageJsonAsync(content, packageDirectory, ct);
}
catch
{
// Fall through to basic detection
}
}
// Just detect from directory LICENSE file
var dirResult = await DetectFromDirectoryAsync(packageDirectory, ct);
if (dirResult is not null)
{
return dirResult;
}
}
}
// Fall back to basic license field
return Detect(package.License);
}
private NodeLicenseInfo? ExtractLicenseInfo(JsonElement packageJson)
{
// Try modern "license" field (SPDX expression or identifier)
if (packageJson.TryGetProperty("license", out var licenseElement))
{
if (licenseElement.ValueKind == JsonValueKind.String)
{
var license = licenseElement.GetString();
if (!string.IsNullOrWhiteSpace(license))
{
return CreateLicenseInfo(license, LicenseDetectionMethod.PackageMetadata);
}
}
else if (licenseElement.ValueKind == JsonValueKind.Object)
{
// Legacy object format: { "type": "MIT", "url": "..." }
return ExtractLegacyLicenseObject(licenseElement);
}
}
// Try legacy "licenses" array
if (packageJson.TryGetProperty("licenses", out var licensesElement) &&
licensesElement.ValueKind == JsonValueKind.Array)
{
return ExtractLegacyLicensesArray(licensesElement);
}
return null;
}
private NodeLicenseInfo? ExtractLegacyLicenseObject(JsonElement licenseObj)
{
string? type = null;
string? url = null;
if (licenseObj.TryGetProperty("type", out var typeElement) &&
typeElement.ValueKind == JsonValueKind.String)
{
type = typeElement.GetString();
}
if (licenseObj.TryGetProperty("url", out var urlElement) &&
urlElement.ValueKind == JsonValueKind.String)
{
url = urlElement.GetString();
}
if (string.IsNullOrWhiteSpace(type))
{
return null;
}
var normalized = NormalizeSpdxId(type);
return new NodeLicenseInfo
{
SpdxId = normalized,
OriginalText = type,
Url = url,
Confidence = LicenseDetectionConfidence.Medium,
Method = LicenseDetectionMethod.PackageMetadata,
IsExpression = false,
ExpressionComponents = []
};
}
private NodeLicenseInfo? ExtractLegacyLicensesArray(JsonElement licensesArray)
{
var licenses = new List<string>();
string? firstUrl = null;
foreach (var item in licensesArray.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var license = item.GetString();
if (!string.IsNullOrWhiteSpace(license))
{
licenses.Add(NormalizeSpdxId(license));
}
}
else if (item.ValueKind == JsonValueKind.Object)
{
if (item.TryGetProperty("type", out var typeElement) &&
typeElement.ValueKind == JsonValueKind.String)
{
var type = typeElement.GetString();
if (!string.IsNullOrWhiteSpace(type))
{
licenses.Add(NormalizeSpdxId(type));
}
}
if (firstUrl is null &&
item.TryGetProperty("url", out var urlElement) &&
urlElement.ValueKind == JsonValueKind.String)
{
firstUrl = urlElement.GetString();
}
}
}
if (licenses.Count == 0)
{
return null;
}
if (licenses.Count == 1)
{
return new NodeLicenseInfo
{
SpdxId = licenses[0],
OriginalText = licenses[0],
Url = firstUrl,
Confidence = LicenseDetectionConfidence.Medium,
Method = LicenseDetectionMethod.PackageMetadata,
IsExpression = false,
ExpressionComponents = []
};
}
// Multiple licenses - create OR expression (dual licensing)
var expression = string.Join(" OR ", licenses.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(l => l, StringComparer.Ordinal));
return new NodeLicenseInfo
{
SpdxId = expression,
OriginalText = $"[{string.Join(", ", licenses)}]",
Url = firstUrl,
Confidence = LicenseDetectionConfidence.Medium,
Method = LicenseDetectionMethod.PackageMetadata,
IsExpression = true,
ExpressionComponents = [.. licenses.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(l => l, StringComparer.Ordinal)]
};
}
private NodeLicenseInfo CreateLicenseInfo(string license, LicenseDetectionMethod method)
{
var normalized = NormalizeSpdxId(license);
var isExpression = IsExpression(normalized);
return new NodeLicenseInfo
{
SpdxId = normalized,
OriginalText = license != normalized ? license : null,
Confidence = DetermineConfidence(license),
Method = method,
IsExpression = isExpression,
ExpressionComponents = isExpression ? ParseExpressionComponents(normalized) : []
};
}
private static string NormalizeSpdxId(string license)
{
if (string.IsNullOrWhiteSpace(license))
{
return "LicenseRef-Unknown";
}
var trimmed = license.Trim();
// Handle UNLICENSED (npm special value)
if (string.Equals(trimmed, "UNLICENSED", StringComparison.OrdinalIgnoreCase))
{
return "LicenseRef-UNLICENSED";
}
// Handle SEE LICENSE IN <filename> (npm convention)
if (trimmed.StartsWith("SEE LICENSE IN", StringComparison.OrdinalIgnoreCase))
{
return "LicenseRef-Custom";
}
// Common aliases
return trimmed.ToUpperInvariant() switch
{
"MIT" => "MIT",
"ISC" => "ISC",
"BSD" => "BSD-3-Clause",
"BSD-2" => "BSD-2-Clause",
"BSD-3" => "BSD-3-Clause",
"APACHE" => "Apache-2.0",
"APACHE 2" => "Apache-2.0",
"APACHE-2" => "Apache-2.0",
"APACHE2" => "Apache-2.0",
"GPL" => "GPL-3.0-only",
"GPL-2" => "GPL-2.0-only",
"GPL-3" => "GPL-3.0-only",
"LGPL" => "LGPL-3.0-only",
"LGPL-2" => "LGPL-2.0-only",
"LGPL-2.1" => "LGPL-2.1-only",
"LGPL-3" => "LGPL-3.0-only",
"MPL" => "MPL-2.0",
"MPL-2" => "MPL-2.0",
"CC0" => "CC0-1.0",
"CC-BY" => "CC-BY-4.0",
"CC-BY-SA" => "CC-BY-SA-4.0",
"WTFPL" => "WTFPL",
"ZLIB" => "Zlib",
"UNLICENSE" => "Unlicense",
"PUBLIC DOMAIN" => "LicenseRef-PublicDomain",
_ => trimmed // Return as-is if it's already an SPDX identifier
};
}
private static bool IsExpression(string license)
{
return license.Contains(" OR ", StringComparison.OrdinalIgnoreCase) ||
license.Contains(" AND ", StringComparison.OrdinalIgnoreCase) ||
license.Contains(" WITH ", StringComparison.OrdinalIgnoreCase) ||
(license.Contains('(') && license.Contains(')'));
}
private static LicenseDetectionConfidence DetermineConfidence(string license)
{
if (string.IsNullOrWhiteSpace(license))
{
return LicenseDetectionConfidence.None;
}
var trimmed = license.Trim();
// SPDX identifiers are high confidence
if (IsLikelySpdxId(trimmed))
{
return LicenseDetectionConfidence.High;
}
// Expressions are medium confidence
if (IsExpression(trimmed))
{
return LicenseDetectionConfidence.Medium;
}
// Free-form text is low confidence
return LicenseDetectionConfidence.Low;
}
private static bool IsLikelySpdxId(string license)
{
// SPDX identifiers don't have spaces (except in expressions)
// and typically contain hyphens or dots
if (license.Contains(' ') && !IsExpression(license))
{
return false;
}
// Common SPDX patterns
return license.Contains('-') ||
license.Contains('.') ||
license.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '.');
}
private static ImmutableArray<string> ParseExpressionComponents(string expression)
{
var components = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var tokens = expression
.Replace("(", " ")
.Replace(")", " ")
.Split([' '], StringSplitOptions.RemoveEmptyEntries);
foreach (var token in tokens)
{
var upper = token.ToUpperInvariant();
if (upper is not "OR" and not "AND" and not "WITH")
{
components.Add(token);
}
}
return [.. components.OrderBy(c => c, StringComparer.Ordinal)];
}
private sealed class NodeLicenseInfo
{
public required string SpdxId { get; init; }
public string? OriginalText { get; init; }
public string? Url { get; init; }
public LicenseDetectionConfidence Confidence { get; init; }
public LicenseDetectionMethod Method { get; init; }
public bool IsExpression { get; init; }
public ImmutableArray<string> ExpressionComponents { get; init; }
}
}

View File

@@ -0,0 +1,271 @@
// -----------------------------------------------------------------------------
// PythonLicenseDetector.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-005 - Upgrade Python license detector
// Description: Enhanced Python license detection returning LicenseDetectionResult
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Licensing;
/// <summary>
/// Enhanced Python license detector that returns full LicenseDetectionResult.
/// </summary>
internal sealed class PythonLicenseDetector
{
private readonly ILicenseCategorizationService _categorizationService;
private readonly ILicenseTextExtractor _textExtractor;
private readonly ICopyrightExtractor _copyrightExtractor;
/// <summary>
/// Creates a new Python license detector with the specified services.
/// </summary>
public PythonLicenseDetector(
ILicenseCategorizationService categorizationService,
ILicenseTextExtractor textExtractor,
ICopyrightExtractor copyrightExtractor)
{
_categorizationService = categorizationService;
_textExtractor = textExtractor;
_copyrightExtractor = copyrightExtractor;
}
/// <summary>
/// Creates a new Python license detector with default services.
/// </summary>
public PythonLicenseDetector()
{
_categorizationService = new LicenseCategorizationService();
_textExtractor = new LicenseTextExtractor();
_copyrightExtractor = new CopyrightExtractor();
}
/// <summary>
/// Detects license information from Python package metadata.
/// </summary>
/// <param name="license">The license field from METADATA.</param>
/// <param name="classifiers">The classifiers from METADATA.</param>
/// <param name="licenseExpression">PEP 639 license-expression field (if present).</param>
/// <param name="packageDirectory">Package directory for license file extraction.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The full license detection result.</returns>
public async Task<LicenseDetectionResult?> DetectAsync(
string? license,
IEnumerable<string>? classifiers,
string? licenseExpression = null,
string? packageDirectory = null,
CancellationToken ct = default)
{
// Use existing normalizer for basic SPDX resolution
var spdxId = SpdxLicenseNormalizer.Normalize(license, classifiers, licenseExpression);
if (spdxId is null)
{
return null;
}
// Determine detection method and confidence
var (method, confidence, originalText) = DetermineDetectionContext(
license, classifiers, licenseExpression);
// Check if it's an expression
var isExpression = spdxId.Contains(" OR ", StringComparison.OrdinalIgnoreCase) ||
spdxId.Contains(" AND ", StringComparison.OrdinalIgnoreCase) ||
spdxId.Contains(" WITH ", StringComparison.OrdinalIgnoreCase);
var expressionComponents = isExpression
? ParseExpressionComponents(spdxId)
: [];
// Extract license text if package directory is available
LicenseTextExtractionResult? licenseTextResult = null;
if (!string.IsNullOrWhiteSpace(packageDirectory))
{
var licenseFiles = await _textExtractor.ExtractFromDirectoryAsync(packageDirectory, ct);
licenseTextResult = licenseFiles.FirstOrDefault();
}
// Get copyright notices
var copyrightNotices = licenseTextResult?.CopyrightNotices ?? [];
var primaryCopyright = copyrightNotices.Length > 0
? copyrightNotices[0].FullText
: null;
// Build the base result
var result = new LicenseDetectionResult
{
SpdxId = spdxId,
OriginalText = originalText,
Confidence = confidence,
Method = method,
SourceFile = "METADATA",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = licenseTextResult?.FullText,
LicenseTextHash = licenseTextResult?.TextHash,
CopyrightNotice = primaryCopyright,
IsExpression = isExpression,
ExpressionComponents = expressionComponents
};
// Enrich with categorization
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license information synchronously (without license file extraction).
/// </summary>
public LicenseDetectionResult? Detect(
string? license,
IEnumerable<string>? classifiers,
string? licenseExpression = null)
{
var spdxId = SpdxLicenseNormalizer.Normalize(license, classifiers, licenseExpression);
if (spdxId is null)
{
return null;
}
var (method, confidence, originalText) = DetermineDetectionContext(
license, classifiers, licenseExpression);
var isExpression = spdxId.Contains(" OR ", StringComparison.OrdinalIgnoreCase) ||
spdxId.Contains(" AND ", StringComparison.OrdinalIgnoreCase) ||
spdxId.Contains(" WITH ", StringComparison.OrdinalIgnoreCase);
var result = new LicenseDetectionResult
{
SpdxId = spdxId,
OriginalText = originalText,
Confidence = confidence,
Method = method,
SourceFile = "METADATA",
Category = LicenseCategory.Unknown,
Obligations = [],
IsExpression = isExpression,
ExpressionComponents = isExpression ? ParseExpressionComponents(spdxId) : []
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license from LICENSE file content.
/// </summary>
public LicenseDetectionResult? DetectFromLicenseFile(string licenseText, string? sourceFile = null)
{
if (string.IsNullOrWhiteSpace(licenseText))
{
return null;
}
var textResult = _textExtractor.Extract(licenseText, sourceFile);
if (string.IsNullOrWhiteSpace(textResult.DetectedLicenseId))
{
// Unknown license from file
return new LicenseDetectionResult
{
SpdxId = "LicenseRef-Unknown",
OriginalText = licenseText.Length > 100 ? licenseText[..100] + "..." : licenseText,
Confidence = LicenseDetectionConfidence.Low,
Method = LicenseDetectionMethod.LicenseFile,
SourceFile = sourceFile ?? "LICENSE",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = licenseText,
LicenseTextHash = textResult.TextHash,
CopyrightNotice = textResult.CopyrightNotices.Length > 0
? textResult.CopyrightNotices[0].FullText
: null
};
}
var result = new LicenseDetectionResult
{
SpdxId = textResult.DetectedLicenseId,
Confidence = textResult.Confidence,
Method = LicenseDetectionMethod.LicenseFile,
SourceFile = sourceFile ?? "LICENSE",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = licenseText,
LicenseTextHash = textResult.TextHash,
CopyrightNotice = textResult.CopyrightNotices.Length > 0
? textResult.CopyrightNotices[0].FullText
: null
};
return _categorizationService.Enrich(result);
}
private static (LicenseDetectionMethod Method, LicenseDetectionConfidence Confidence, string? OriginalText)
DetermineDetectionContext(
string? license,
IEnumerable<string>? classifiers,
string? licenseExpression)
{
// PEP 639 license expression is most reliable
if (!string.IsNullOrWhiteSpace(licenseExpression))
{
return (LicenseDetectionMethod.PackageMetadata, LicenseDetectionConfidence.High, licenseExpression);
}
// Classifiers are standardized
if (classifiers is not null)
{
var licenseClassifiers = classifiers
.Where(c => c.StartsWith("License ::", StringComparison.OrdinalIgnoreCase))
.ToList();
if (licenseClassifiers.Count > 0)
{
return (LicenseDetectionMethod.ClassifierMapping, LicenseDetectionConfidence.High,
string.Join("; ", licenseClassifiers));
}
}
// License string from metadata
if (!string.IsNullOrWhiteSpace(license))
{
// Check if it looks like an SPDX identifier (high confidence)
// vs free-form text (lower confidence)
var isLikelySpdx = !license.Contains(' ') ||
license.Contains("-") ||
license.ToUpperInvariant() == license;
var confidence = isLikelySpdx
? LicenseDetectionConfidence.Medium
: LicenseDetectionConfidence.Low;
return (LicenseDetectionMethod.PackageMetadata, confidence, license);
}
return (LicenseDetectionMethod.KeywordFallback, LicenseDetectionConfidence.None, null);
}
private static ImmutableArray<string> ParseExpressionComponents(string expression)
{
// Simple parsing - split on OR/AND/WITH
var components = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var tokens = expression
.Replace("(", " ")
.Replace(")", " ")
.Split([' '], StringSplitOptions.RemoveEmptyEntries);
foreach (var token in tokens)
{
var upper = token.ToUpperInvariant();
if (upper is not "OR" and not "AND" and not "WITH")
{
components.Add(token);
}
}
return [.. components.OrderBy(c => c, StringComparer.Ordinal)];
}
}

View File

@@ -0,0 +1,265 @@
// -----------------------------------------------------------------------------
// EnhancedRustLicenseDetector.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-008 - Upgrade Rust license detector
// Description: Enhanced Rust license detection returning LicenseDetectionResult
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal;
/// <summary>
/// Enhanced Rust license detector that returns full LicenseDetectionResult.
/// </summary>
internal sealed class EnhancedRustLicenseDetector
{
private readonly ILicenseCategorizationService _categorizationService;
private readonly ILicenseTextExtractor _textExtractor;
private readonly ICopyrightExtractor _copyrightExtractor;
/// <summary>
/// Creates a new enhanced Rust license detector with the specified services.
/// </summary>
public EnhancedRustLicenseDetector(
ILicenseCategorizationService categorizationService,
ILicenseTextExtractor textExtractor,
ICopyrightExtractor copyrightExtractor)
{
_categorizationService = categorizationService;
_textExtractor = textExtractor;
_copyrightExtractor = copyrightExtractor;
}
/// <summary>
/// Creates a new enhanced Rust license detector with default services.
/// </summary>
public EnhancedRustLicenseDetector()
{
_categorizationService = new LicenseCategorizationService();
_textExtractor = new LicenseTextExtractor();
_copyrightExtractor = new CopyrightExtractor();
}
/// <summary>
/// Detects license from Rust license info.
/// </summary>
/// <param name="licenseInfo">The license info from Cargo.toml parsing.</param>
/// <param name="rootPath">Root path for resolving license files.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The full license detection result.</returns>
public async Task<LicenseDetectionResult?> DetectAsync(
RustLicenseInfo licenseInfo,
string? rootPath = null,
CancellationToken ct = default)
{
if (licenseInfo is null)
{
return null;
}
// Get SPDX expression from Cargo.toml
var spdxExpression = licenseInfo.Expressions.Length > 0
? string.Join(" OR ", licenseInfo.Expressions)
: null;
// Try to read license file content
LicenseTextExtractionResult? licenseTextResult = null;
if (licenseInfo.Files.Length > 0 && !string.IsNullOrWhiteSpace(rootPath))
{
var licenseFile = licenseInfo.Files[0];
var absolutePath = Path.GetFullPath(Path.Combine(rootPath, licenseFile.RelativePath.Replace('/', Path.DirectorySeparatorChar)));
if (File.Exists(absolutePath))
{
licenseTextResult = await _textExtractor.ExtractAsync(absolutePath, ct);
}
}
// If no expression from Cargo.toml, try to detect from license file
if (string.IsNullOrWhiteSpace(spdxExpression) && licenseTextResult?.DetectedLicenseId is not null)
{
spdxExpression = licenseTextResult.DetectedLicenseId;
}
if (string.IsNullOrWhiteSpace(spdxExpression))
{
// No license info found
return null;
}
// Check if it's an expression
var isExpression = spdxExpression.Contains(" OR ", StringComparison.OrdinalIgnoreCase) ||
spdxExpression.Contains(" AND ", StringComparison.OrdinalIgnoreCase) ||
spdxExpression.Contains("/", StringComparison.Ordinal); // Rust uses / for OR
// Normalize Rust-style expressions to SPDX (/ -> OR)
var normalizedExpression = spdxExpression.Replace("/", " OR ");
// Get copyright notices
var copyrightNotices = licenseTextResult?.CopyrightNotices ?? [];
var primaryCopyright = copyrightNotices.Length > 0
? copyrightNotices[0].FullText
: null;
// Determine confidence
var confidence = licenseInfo.Expressions.Length > 0
? LicenseDetectionConfidence.High
: licenseTextResult?.Confidence ?? LicenseDetectionConfidence.None;
// Determine source file
var sourceFile = licenseInfo.Files.Length > 0
? licenseInfo.Files[0].RelativePath
: licenseInfo.CargoTomlRelativePath;
var result = new LicenseDetectionResult
{
SpdxId = normalizedExpression,
OriginalText = spdxExpression != normalizedExpression ? spdxExpression : null,
Confidence = confidence,
Method = licenseInfo.Expressions.Length > 0
? LicenseDetectionMethod.PackageMetadata
: LicenseDetectionMethod.LicenseFile,
SourceFile = sourceFile,
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = licenseTextResult?.FullText,
LicenseTextHash = licenseTextResult?.TextHash ?? GetFileHash(licenseInfo),
CopyrightNotice = primaryCopyright,
IsExpression = isExpression,
ExpressionComponents = isExpression ? ParseExpressionComponents(normalizedExpression) : []
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license for a crate from the license index.
/// </summary>
public async Task<LicenseDetectionResult?> DetectFromIndexAsync(
RustLicenseIndex index,
string crateName,
string? version,
string? rootPath = null,
CancellationToken ct = default)
{
var info = index.Find(crateName, version);
if (info is null)
{
return null;
}
return await DetectAsync(info, rootPath, ct);
}
/// <summary>
/// Detects license synchronously without file reading.
/// </summary>
public LicenseDetectionResult? Detect(RustLicenseInfo licenseInfo)
{
if (licenseInfo is null)
{
return null;
}
var spdxExpression = licenseInfo.Expressions.Length > 0
? string.Join(" OR ", licenseInfo.Expressions)
: null;
if (string.IsNullOrWhiteSpace(spdxExpression))
{
return null;
}
var isExpression = spdxExpression.Contains(" OR ", StringComparison.OrdinalIgnoreCase) ||
spdxExpression.Contains(" AND ", StringComparison.OrdinalIgnoreCase) ||
spdxExpression.Contains("/", StringComparison.Ordinal);
var normalizedExpression = spdxExpression.Replace("/", " OR ");
var sourceFile = licenseInfo.Files.Length > 0
? licenseInfo.Files[0].RelativePath
: licenseInfo.CargoTomlRelativePath;
var result = new LicenseDetectionResult
{
SpdxId = normalizedExpression,
OriginalText = spdxExpression != normalizedExpression ? spdxExpression : null,
Confidence = LicenseDetectionConfidence.High,
Method = LicenseDetectionMethod.PackageMetadata,
SourceFile = sourceFile,
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseTextHash = GetFileHash(licenseInfo),
IsExpression = isExpression,
ExpressionComponents = isExpression ? ParseExpressionComponents(normalizedExpression) : []
};
return _categorizationService.Enrich(result);
}
/// <summary>
/// Detects license from license file content.
/// </summary>
public LicenseDetectionResult? DetectFromContent(string content, string? sourceFile = null)
{
if (string.IsNullOrWhiteSpace(content))
{
return null;
}
var textResult = _textExtractor.Extract(content, sourceFile);
var spdxId = textResult.DetectedLicenseId ?? "LicenseRef-Unknown";
var copyrightNotices = textResult.CopyrightNotices;
var primaryCopyright = copyrightNotices.Length > 0
? copyrightNotices[0].FullText
: null;
var result = new LicenseDetectionResult
{
SpdxId = spdxId,
Confidence = textResult.Confidence,
Method = LicenseDetectionMethod.LicenseFile,
SourceFile = sourceFile ?? "LICENSE",
Category = LicenseCategory.Unknown,
Obligations = [],
LicenseText = content,
LicenseTextHash = textResult.TextHash,
CopyrightNotice = primaryCopyright
};
return _categorizationService.Enrich(result);
}
private static string? GetFileHash(RustLicenseInfo licenseInfo)
{
if (licenseInfo.Files.Length > 0 && licenseInfo.Files[0].Sha256 is not null)
{
return $"sha256:{licenseInfo.Files[0].Sha256}";
}
return null;
}
private static ImmutableArray<string> ParseExpressionComponents(string expression)
{
var components = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var tokens = expression
.Replace("(", " ")
.Replace(")", " ")
.Split([' ', '/'], StringSplitOptions.RemoveEmptyEntries);
foreach (var token in tokens)
{
var upper = token.ToUpperInvariant();
if (upper is not "OR" and not "AND" and not "WITH")
{
components.Add(token);
}
}
return [.. components.OrderBy(c => c, StringComparer.Ordinal)];
}
}

View File

@@ -0,0 +1,385 @@
// -----------------------------------------------------------------------------
// CopyrightExtractor.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-004 - Implement copyright notice extractor
// Description: Implementation of copyright notice extraction with comprehensive patterns
// -----------------------------------------------------------------------------
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
/// <summary>
/// Extracts copyright notices from text using comprehensive pattern matching.
/// </summary>
public sealed partial class CopyrightExtractor : ICopyrightExtractor
{
/// <inheritdoc/>
public IReadOnlyList<CopyrightNotice> Extract(string text)
{
if (string.IsNullOrWhiteSpace(text))
return [];
var notices = new List<CopyrightNotice>();
var lines = text.Split(['\r', '\n'], StringSplitOptions.None);
var multiLineBuilder = new StringBuilder();
var multiLineStartLine = -1;
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
var lineNumber = i + 1;
// Check if this line starts a multi-line copyright notice
if (IsPartialCopyrightLine(line))
{
if (multiLineBuilder.Length == 0)
{
multiLineStartLine = lineNumber;
}
multiLineBuilder.Append(line.Trim());
multiLineBuilder.Append(' ');
continue;
}
// If we have a pending multi-line notice, try to complete it
if (multiLineBuilder.Length > 0)
{
// Check if this line continues the notice
if (IsContinuationLine(line))
{
multiLineBuilder.Append(line.Trim());
multiLineBuilder.Append(' ');
continue;
}
// Try to parse the accumulated multi-line notice
var multiLineText = multiLineBuilder.ToString().Trim();
var multiLineNotice = TryParseCopyrightLine(multiLineText, multiLineStartLine);
if (multiLineNotice is not null)
{
notices.Add(multiLineNotice);
}
multiLineBuilder.Clear();
multiLineStartLine = -1;
}
// Try to parse as a single-line notice
var notice = TryParseCopyrightLine(line.Trim(), lineNumber);
if (notice is not null)
{
notices.Add(notice);
}
}
// Handle any remaining multi-line notice
if (multiLineBuilder.Length > 0)
{
var multiLineText = multiLineBuilder.ToString().Trim();
var multiLineNotice = TryParseCopyrightLine(multiLineText, multiLineStartLine);
if (multiLineNotice is not null)
{
notices.Add(multiLineNotice);
}
}
return notices;
}
/// <inheritdoc/>
public async Task<IReadOnlyList<CopyrightNotice>> ExtractFromFileAsync(string filePath, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
return [];
try
{
var content = await File.ReadAllTextAsync(filePath, ct);
return Extract(content);
}
catch (Exception)
{
return [];
}
}
/// <inheritdoc/>
public IReadOnlyList<CopyrightNotice> Merge(IReadOnlyList<CopyrightNotice> notices)
{
if (notices.Count <= 1)
return notices;
var merged = new Dictionary<string, CopyrightNotice>(StringComparer.OrdinalIgnoreCase);
foreach (var notice in notices)
{
var normalizedHolder = notice.Holder is not null
? NormalizeHolder(notice.Holder)
: "unknown";
if (merged.TryGetValue(normalizedHolder, out var existing))
{
// Merge years
var mergedYear = MergeYears(existing.Year, notice.Year);
var mergedText = notice.FullText.Length > existing.FullText.Length
? notice.FullText
: existing.FullText;
merged[normalizedHolder] = existing with
{
Year = mergedYear,
FullText = mergedText
};
}
else
{
merged[normalizedHolder] = notice;
}
}
return [.. merged.Values];
}
/// <inheritdoc/>
public string NormalizeHolder(string holder)
{
if (string.IsNullOrWhiteSpace(holder))
return string.Empty;
// Remove common suffixes
var normalized = holder
.Replace(".", "")
.Replace(",", "")
.Replace(" Inc", "")
.Replace(" LLC", "")
.Replace(" Ltd", "")
.Replace(" Corp", "")
.Replace(" Corporation", "")
.Replace(" and contributors", "")
.Replace(" & contributors", "")
.Replace(" Contributors", "")
.Trim();
return normalized.ToLowerInvariant();
}
private static CopyrightNotice? TryParseCopyrightLine(string line, int lineNumber)
{
if (string.IsNullOrWhiteSpace(line))
return null;
// Try each pattern in order of specificity
var patterns = new Func<string, Match?>[]
{
l => CopyrightFullRegex().Match(l),
l => CopyrightSymbolRegex().Match(l),
l => ParenCopyrightRegex().Match(l),
l => AllRightsReservedRegex().Match(l),
l => CopyleftRegex().Match(l),
l => SimpleYearHolderRegex().Match(l)
};
foreach (var pattern in patterns)
{
var match = pattern(line);
if (match is not null && match.Success)
{
var yearGroup = match.Groups["year"];
var holderGroup = match.Groups["holder"];
var year = yearGroup.Success ? NormalizeYear(yearGroup.Value) : null;
var holder = holderGroup.Success ? CleanHolder(holderGroup.Value) : null;
// Skip if we couldn't extract meaningful information
if (string.IsNullOrWhiteSpace(year) && string.IsNullOrWhiteSpace(holder))
continue;
return new CopyrightNotice
{
FullText = line,
Year = year,
Holder = holder,
LineNumber = lineNumber
};
}
}
return null;
}
private static bool IsPartialCopyrightLine(string line)
{
// Check if line contains copyright indicator but might continue on next line
var trimmed = line.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
return false;
return (trimmed.Contains("Copyright", StringComparison.OrdinalIgnoreCase) ||
trimmed.Contains("©") ||
trimmed.Contains("(c)", StringComparison.OrdinalIgnoreCase)) &&
!HasCompleteHolder(trimmed);
}
private static bool HasCompleteHolder(string line)
{
// Check if the line likely has a complete holder name
// (ends with a name-like pattern, not just a year)
return YearFollowedByTextRegex().IsMatch(line);
}
private static bool IsContinuationLine(string line)
{
var trimmed = line.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
return false;
// Continuation lines typically start with holder names or continued text
// and don't start with new copyright indicators
return !trimmed.StartsWith("Copyright", StringComparison.OrdinalIgnoreCase) &&
!trimmed.StartsWith("©") &&
!trimmed.StartsWith("(c)", StringComparison.OrdinalIgnoreCase) &&
!trimmed.StartsWith("#") &&
!trimmed.StartsWith("//") &&
!trimmed.StartsWith("*") &&
trimmed.Length > 2;
}
private static string NormalizeYear(string year)
{
if (string.IsNullOrWhiteSpace(year))
return string.Empty;
// Clean up year string
var cleaned = year.Trim()
.Replace(" ", "")
.Replace(",", ", ");
// Normalize ranges
cleaned = YearRangeNormalizeRegex().Replace(cleaned, "$1-$2");
return cleaned;
}
private static string CleanHolder(string holder)
{
if (string.IsNullOrWhiteSpace(holder))
return string.Empty;
// Remove trailing punctuation and common suffixes
var cleaned = holder.Trim()
.TrimEnd('.', ',', ';', ':')
.Trim();
// Remove "All rights reserved" if present at the end
cleaned = AllRightsReservedSuffixRegex().Replace(cleaned, "").Trim();
return cleaned;
}
private static string? MergeYears(string? year1, string? year2)
{
if (string.IsNullOrWhiteSpace(year1))
return year2;
if (string.IsNullOrWhiteSpace(year2))
return year1;
// Parse all years from both strings
var years = new HashSet<int>();
foreach (var yearStr in new[] { year1, year2 })
{
var matches = YearExtractRegex().Matches(yearStr);
foreach (Match match in matches)
{
if (int.TryParse(match.Value, out var year))
{
years.Add(year);
}
}
// Handle ranges
var rangeMatches = YearRangeExtractRegex().Matches(yearStr);
foreach (Match match in rangeMatches)
{
if (int.TryParse(match.Groups[1].Value, out var startYear) &&
int.TryParse(match.Groups[2].Value, out var endYear))
{
for (var y = startYear; y <= endYear; y++)
{
years.Add(y);
}
}
}
}
if (years.Count == 0)
return year1;
var sortedYears = years.OrderBy(y => y).ToList();
// Format as range if consecutive years
if (sortedYears.Count > 2 && AreConsecutive(sortedYears))
{
return $"{sortedYears[0]}-{sortedYears[^1]}";
}
return string.Join(", ", sortedYears);
}
private static bool AreConsecutive(List<int> years)
{
for (var i = 1; i < years.Count; i++)
{
if (years[i] != years[i - 1] + 1)
return false;
}
return true;
}
// Comprehensive copyright patterns
// Copyright (c) 2024 Holder Name
// Copyright (C) 2020-2024 Holder Name
[GeneratedRegex(@"Copyright\s*(?:\(c\)|\(C\))?\s*(?<year>\d{4}(?:\s*[-,]\s*\d{4})*)\s+(?<holder>.+)", RegexOptions.IgnoreCase)]
private static partial Regex CopyrightFullRegex();
// © 2024 Holder Name
// ©2020-2024 Holder
[GeneratedRegex(@"©\s*(?<year>\d{4}(?:\s*[-,]\s*\d{4})*)\s+(?<holder>.+)", RegexOptions.IgnoreCase)]
private static partial Regex CopyrightSymbolRegex();
// (c) 2024 Holder Name
// (C) 2020-2024 Holder
[GeneratedRegex(@"\(c\)\s*(?<year>\d{4}(?:\s*[-,]\s*\d{4})*)\s+(?<holder>.+)", RegexOptions.IgnoreCase)]
private static partial Regex ParenCopyrightRegex();
// 2024 Holder Name. All rights reserved.
// 2020-2024 Holder. All Rights Reserved.
[GeneratedRegex(@"(?<year>\d{4}(?:\s*[-,]\s*\d{4})*)\s+(?<holder>.+?)\.\s*All\s+[Rr]ights\s+[Rr]eserved", RegexOptions.IgnoreCase)]
private static partial Regex AllRightsReservedRegex();
// Copyleft 2024 Holder Name (rare but exists)
[GeneratedRegex(@"Copyleft\s*(?<year>\d{4}(?:\s*[-,]\s*\d{4})*)\s+(?<holder>.+)", RegexOptions.IgnoreCase)]
private static partial Regex CopyleftRegex();
// Fallback: Year followed by what looks like a name
[GeneratedRegex(@"^\s*(?<year>\d{4}(?:\s*[-,]\s*\d{4})*)\s+(?<holder>[A-Z][a-zA-Z\s]+(?:Inc|LLC|Ltd|Corp|Foundation|Project|Contributors)?)", RegexOptions.None)]
private static partial Regex SimpleYearHolderRegex();
// Helper patterns
[GeneratedRegex(@"\d{4}(?:\s*[-,]\s*\d{4})*\s+[A-Z]", RegexOptions.None)]
private static partial Regex YearFollowedByTextRegex();
[GeneratedRegex(@"(\d{4})\s*[-]\s*(\d{4})", RegexOptions.None)]
private static partial Regex YearRangeNormalizeRegex();
[GeneratedRegex(@"\.\s*All\s+[Rr]ights\s+[Rr]eserved\.?$", RegexOptions.IgnoreCase)]
private static partial Regex AllRightsReservedSuffixRegex();
[GeneratedRegex(@"\b(\d{4})\b", RegexOptions.None)]
private static partial Regex YearExtractRegex();
[GeneratedRegex(@"(\d{4})\s*[-]\s*(\d{4})", RegexOptions.None)]
private static partial Regex YearRangeExtractRegex();
}

View File

@@ -0,0 +1,34 @@
// -----------------------------------------------------------------------------
// CopyrightNotice.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-001 - Create unified LicenseDetectionResult model
// Description: Model for extracted copyright notices
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
/// <summary>
/// Represents an extracted copyright notice from license text.
/// </summary>
public sealed record CopyrightNotice
{
/// <summary>
/// The full text of the copyright notice as it appears in the source.
/// </summary>
public required string FullText { get; init; }
/// <summary>
/// The year or year range (e.g., "2020" or "2018-2024").
/// </summary>
public string? Year { get; init; }
/// <summary>
/// The copyright holder name (e.g., "Google LLC", "Microsoft Corporation").
/// </summary>
public string? Holder { get; init; }
/// <summary>
/// Line number where the copyright notice was found.
/// </summary>
public int LineNumber { get; init; }
}

View File

@@ -0,0 +1,43 @@
// -----------------------------------------------------------------------------
// ICopyrightExtractor.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-004 - Implement copyright notice extractor
// Description: Interface for extracting copyright notices from text
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
/// <summary>
/// Service for extracting copyright notices from license text and source files.
/// </summary>
public interface ICopyrightExtractor
{
/// <summary>
/// Extracts copyright notices from text.
/// </summary>
/// <param name="text">The text to search for copyright notices.</param>
/// <returns>List of extracted copyright notices with parsed metadata.</returns>
IReadOnlyList<CopyrightNotice> Extract(string text);
/// <summary>
/// Extracts copyright notices from a file.
/// </summary>
/// <param name="filePath">Path to the file to search.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of extracted copyright notices with parsed metadata.</returns>
Task<IReadOnlyList<CopyrightNotice>> ExtractFromFileAsync(string filePath, CancellationToken ct = default);
/// <summary>
/// Merges duplicate copyright notices (same holder, overlapping years).
/// </summary>
/// <param name="notices">The notices to merge.</param>
/// <returns>Deduplicated and merged copyright notices.</returns>
IReadOnlyList<CopyrightNotice> Merge(IReadOnlyList<CopyrightNotice> notices);
/// <summary>
/// Normalizes a copyright holder name for comparison.
/// </summary>
/// <param name="holder">The holder name to normalize.</param>
/// <returns>Normalized holder name.</returns>
string NormalizeHolder(string holder);
}

View File

@@ -0,0 +1,114 @@
// -----------------------------------------------------------------------------
// ILicenseCategorizationService.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-002 - Build license categorization service
// Description: Service interface for license categorization and metadata lookup
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
/// <summary>
/// Service for categorizing licenses and determining their obligations.
/// </summary>
public interface ILicenseCategorizationService
{
/// <summary>
/// Categorizes a license by its SPDX identifier.
/// </summary>
/// <param name="spdxId">The SPDX license identifier.</param>
/// <returns>The category of the license.</returns>
LicenseCategory Categorize(string spdxId);
/// <summary>
/// Gets the obligations associated with a license.
/// </summary>
/// <param name="spdxId">The SPDX license identifier.</param>
/// <returns>The obligations that the license imposes.</returns>
IReadOnlyList<LicenseObligation> GetObligations(string spdxId);
/// <summary>
/// Determines if a license is OSI-approved.
/// </summary>
/// <param name="spdxId">The SPDX license identifier.</param>
/// <returns>True if OSI-approved, false if not, null if unknown.</returns>
bool? IsOsiApproved(string spdxId);
/// <summary>
/// Determines if a license is FSF-free.
/// </summary>
/// <param name="spdxId">The SPDX license identifier.</param>
/// <returns>True if FSF-free, false if not, null if unknown.</returns>
bool? IsFsfFree(string spdxId);
/// <summary>
/// Determines if a license identifier is deprecated in SPDX.
/// </summary>
/// <param name="spdxId">The SPDX license identifier.</param>
/// <returns>True if deprecated, false otherwise.</returns>
bool IsDeprecated(string spdxId);
/// <summary>
/// Gets the full license metadata for a given SPDX identifier.
/// </summary>
/// <param name="spdxId">The SPDX license identifier.</param>
/// <returns>The license metadata, or null if not found.</returns>
LicenseMetadata? GetMetadata(string spdxId);
/// <summary>
/// Enriches a license detection result with categorization data.
/// </summary>
/// <param name="result">The detection result to enrich.</param>
/// <returns>The enriched result with category and obligations.</returns>
LicenseDetectionResult Enrich(LicenseDetectionResult result);
}
/// <summary>
/// Metadata about a specific license.
/// </summary>
public sealed record LicenseMetadata
{
/// <summary>
/// The SPDX license identifier.
/// </summary>
public required string SpdxId { get; init; }
/// <summary>
/// Human-readable name of the license.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// The license category.
/// </summary>
public LicenseCategory Category { get; init; }
/// <summary>
/// Obligations imposed by the license.
/// </summary>
public IReadOnlyList<LicenseObligation> Obligations { get; init; } = [];
/// <summary>
/// Whether the license is OSI-approved.
/// </summary>
public bool IsOsiApproved { get; init; }
/// <summary>
/// Whether the license is FSF-free.
/// </summary>
public bool IsFsfFree { get; init; }
/// <summary>
/// Whether the license identifier is deprecated.
/// </summary>
public bool IsDeprecated { get; init; }
/// <summary>
/// URL to the license text.
/// </summary>
public string? Reference { get; init; }
/// <summary>
/// Alternative/deprecated SPDX identifiers for this license.
/// </summary>
public IReadOnlyList<string> AlternativeIds { get; init; } = [];
}

View File

@@ -0,0 +1,31 @@
// -----------------------------------------------------------------------------
// ILicenseDetectionAggregator.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-013 - Create license detection aggregator
// Description: Interface for aggregating license detection results
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
/// <summary>
/// Aggregates license detection results across multiple components.
/// </summary>
public interface ILicenseDetectionAggregator
{
/// <summary>
/// Aggregates license detection results into a summary.
/// </summary>
/// <param name="results">The detection results to aggregate.</param>
/// <returns>The aggregated summary.</returns>
LicenseDetectionSummary Aggregate(IReadOnlyList<LicenseDetectionResult> results);
/// <summary>
/// Aggregates license detection results into a summary with component count tracking.
/// </summary>
/// <param name="results">The detection results to aggregate.</param>
/// <param name="totalComponentCount">Total number of components (including those without licenses).</param>
/// <returns>The aggregated summary.</returns>
LicenseDetectionSummary Aggregate(
IReadOnlyList<LicenseDetectionResult> results,
int totalComponentCount);
}

View File

@@ -0,0 +1,47 @@
// -----------------------------------------------------------------------------
// ILicenseTextExtractor.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-003 - Implement license text extractor
// Description: Interface for extracting license text from files
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
/// <summary>
/// Service for extracting license text from LICENSE, COPYING, and similar files.
/// </summary>
public interface ILicenseTextExtractor
{
/// <summary>
/// Extracts license text from a file.
/// </summary>
/// <param name="filePath">Path to the license file.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The extraction result containing text, hash, and detected metadata.</returns>
Task<LicenseTextExtractionResult?> ExtractAsync(string filePath, CancellationToken ct = default);
/// <summary>
/// Extracts license text from raw content.
/// </summary>
/// <param name="content">The license text content.</param>
/// <param name="sourcePath">Optional source path for context.</param>
/// <returns>The extraction result containing text, hash, and detected metadata.</returns>
LicenseTextExtractionResult Extract(string content, string? sourcePath = null);
/// <summary>
/// Finds and extracts license files from a directory.
/// </summary>
/// <param name="directoryPath">Path to search for license files.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Extraction results for all found license files.</returns>
Task<IReadOnlyList<LicenseTextExtractionResult>> ExtractFromDirectoryAsync(
string directoryPath,
CancellationToken ct = default);
/// <summary>
/// Determines if a file is a license file based on its name.
/// </summary>
/// <param name="fileName">The file name to check.</param>
/// <returns>True if the file appears to be a license file.</returns>
bool IsLicenseFile(string fileName);
}

View File

@@ -0,0 +1,349 @@
// -----------------------------------------------------------------------------
// LicenseCategorizationService.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-002 - Build license categorization service
// Description: Implementation of license categorization with built-in knowledge base
// -----------------------------------------------------------------------------
using System.Collections.Frozen;
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
/// <summary>
/// Service for categorizing SPDX licenses and determining their obligations.
/// </summary>
public sealed class LicenseCategorizationService : ILicenseCategorizationService
{
private static readonly FrozenDictionary<string, LicenseMetadata> s_licenseDatabase = BuildLicenseDatabase();
/// <inheritdoc/>
public LicenseCategory Categorize(string spdxId)
{
if (string.IsNullOrWhiteSpace(spdxId))
return LicenseCategory.Unknown;
var normalized = NormalizeSpdxId(spdxId);
if (s_licenseDatabase.TryGetValue(normalized, out var metadata))
return metadata.Category;
// Pattern-based categorization for unknown licenses
return CategorizeByPattern(normalized);
}
/// <inheritdoc/>
public IReadOnlyList<LicenseObligation> GetObligations(string spdxId)
{
if (string.IsNullOrWhiteSpace(spdxId))
return [];
var normalized = NormalizeSpdxId(spdxId);
if (s_licenseDatabase.TryGetValue(normalized, out var metadata))
return metadata.Obligations;
// Return obligations based on category for unknown licenses
var category = CategorizeByPattern(normalized);
return GetDefaultObligations(category);
}
/// <inheritdoc/>
public bool? IsOsiApproved(string spdxId)
{
if (string.IsNullOrWhiteSpace(spdxId))
return null;
var normalized = NormalizeSpdxId(spdxId);
if (s_licenseDatabase.TryGetValue(normalized, out var metadata))
return metadata.IsOsiApproved;
return null;
}
/// <inheritdoc/>
public bool? IsFsfFree(string spdxId)
{
if (string.IsNullOrWhiteSpace(spdxId))
return null;
var normalized = NormalizeSpdxId(spdxId);
if (s_licenseDatabase.TryGetValue(normalized, out var metadata))
return metadata.IsFsfFree;
return null;
}
/// <inheritdoc/>
public bool IsDeprecated(string spdxId)
{
if (string.IsNullOrWhiteSpace(spdxId))
return false;
var normalized = NormalizeSpdxId(spdxId);
if (s_licenseDatabase.TryGetValue(normalized, out var metadata))
return metadata.IsDeprecated;
return false;
}
/// <inheritdoc/>
public LicenseMetadata? GetMetadata(string spdxId)
{
if (string.IsNullOrWhiteSpace(spdxId))
return null;
var normalized = NormalizeSpdxId(spdxId);
return s_licenseDatabase.GetValueOrDefault(normalized);
}
/// <inheritdoc/>
public LicenseDetectionResult Enrich(LicenseDetectionResult result)
{
var category = Categorize(result.SpdxId);
var obligations = GetObligations(result.SpdxId);
var osiApproved = IsOsiApproved(result.SpdxId);
var fsfFree = IsFsfFree(result.SpdxId);
var deprecated = IsDeprecated(result.SpdxId);
return result with
{
Category = category,
Obligations = obligations.ToImmutableArray(),
IsOsiApproved = osiApproved,
IsFsfFree = fsfFree,
IsDeprecated = deprecated
};
}
private static string NormalizeSpdxId(string spdxId)
{
// Normalize to uppercase for consistent lookup
return spdxId.Trim().ToUpperInvariant();
}
private static LicenseCategory CategorizeByPattern(string spdxId)
{
// Pattern-based categorization for licenses not in the database
var upper = spdxId.ToUpperInvariant();
// Public domain
if (upper.Contains("CC0") || upper.Contains("UNLICENSE") ||
upper.Contains("WTFPL") || upper == "0BSD" ||
upper.Contains("PUBLIC-DOMAIN"))
return LicenseCategory.PublicDomain;
// Network copyleft (AGPL)
if (upper.Contains("AGPL"))
return LicenseCategory.NetworkCopyleft;
// Strong copyleft (GPL but not LGPL/AGPL)
if (upper.Contains("GPL") && !upper.Contains("LGPL") && !upper.Contains("AGPL"))
return LicenseCategory.StrongCopyleft;
// Weak copyleft
if (upper.Contains("LGPL") || upper.Contains("MPL") ||
upper.Contains("EPL") || upper.Contains("CDDL") ||
upper.Contains("OSL") || upper.Contains("CPL") ||
upper.Contains("EUPL"))
return LicenseCategory.WeakCopyleft;
// Permissive patterns
if (upper.Contains("MIT") || upper.Contains("BSD") ||
upper.Contains("APACHE") || upper.Contains("ISC") ||
upper.Contains("ZLIB") || upper.Contains("BOOST") ||
upper.Contains("PSF") || upper.Contains("PYTHON"))
return LicenseCategory.Permissive;
// Custom/proprietary patterns
if (upper.StartsWith("LICENSEREF-") || upper.Contains("PROPRIETARY") ||
upper.Contains("COMMERCIAL"))
return LicenseCategory.Proprietary;
return LicenseCategory.Unknown;
}
private static IReadOnlyList<LicenseObligation> GetDefaultObligations(LicenseCategory category)
{
return category switch
{
LicenseCategory.Permissive => [LicenseObligation.Attribution, LicenseObligation.NoWarranty],
LicenseCategory.WeakCopyleft => [LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.IncludeLicense],
LicenseCategory.StrongCopyleft => [LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.SameLicense, LicenseObligation.IncludeLicense],
LicenseCategory.NetworkCopyleft => [LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.SameLicense, LicenseObligation.NetworkCopyleft, LicenseObligation.IncludeLicense],
LicenseCategory.PublicDomain => [],
LicenseCategory.Proprietary => [LicenseObligation.Attribution],
_ => []
};
}
private static FrozenDictionary<string, LicenseMetadata> BuildLicenseDatabase()
{
var licenses = new Dictionary<string, LicenseMetadata>(StringComparer.OrdinalIgnoreCase);
// Permissive licenses
AddLicense(licenses, "MIT", "MIT License", LicenseCategory.Permissive,
[LicenseObligation.Attribution, LicenseObligation.IncludeLicense, LicenseObligation.NoWarranty],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "Apache-2.0", "Apache License 2.0", LicenseCategory.Permissive,
[LicenseObligation.Attribution, LicenseObligation.IncludeLicense, LicenseObligation.StateChanges, LicenseObligation.PatentGrant, LicenseObligation.IncludeNotice],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "BSD-2-CLAUSE", "BSD 2-Clause \"Simplified\" License", LicenseCategory.Permissive,
[LicenseObligation.Attribution, LicenseObligation.NoWarranty],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "BSD-3-CLAUSE", "BSD 3-Clause \"New\" or \"Revised\" License", LicenseCategory.Permissive,
[LicenseObligation.Attribution, LicenseObligation.NoWarranty],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "ISC", "ISC License", LicenseCategory.Permissive,
[LicenseObligation.Attribution, LicenseObligation.NoWarranty],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "ZLIB", "zlib License", LicenseCategory.Permissive,
[LicenseObligation.Attribution, LicenseObligation.StateChanges],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "BSL-1.0", "Boost Software License 1.0", LicenseCategory.Permissive,
[LicenseObligation.Attribution],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "PSF-2.0", "Python Software Foundation License 2.0", LicenseCategory.Permissive,
[LicenseObligation.Attribution, LicenseObligation.NoWarranty],
osiApproved: false, fsfFree: true);
// Weak copyleft licenses
AddLicense(licenses, "LGPL-2.1-ONLY", "GNU Lesser General Public License v2.1 only", LicenseCategory.WeakCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.IncludeLicense],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "LGPL-2.1-OR-LATER", "GNU Lesser General Public License v2.1 or later", LicenseCategory.WeakCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.IncludeLicense],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "LGPL-3.0-ONLY", "GNU Lesser General Public License v3.0 only", LicenseCategory.WeakCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.IncludeLicense, LicenseObligation.PatentGrant],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "LGPL-3.0-OR-LATER", "GNU Lesser General Public License v3.0 or later", LicenseCategory.WeakCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.IncludeLicense, LicenseObligation.PatentGrant],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "MPL-2.0", "Mozilla Public License 2.0", LicenseCategory.WeakCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.IncludeLicense, LicenseObligation.PatentGrant],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "EPL-2.0", "Eclipse Public License 2.0", LicenseCategory.WeakCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.IncludeLicense, LicenseObligation.PatentGrant],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "CDDL-1.0", "Common Development and Distribution License 1.0", LicenseCategory.WeakCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.IncludeLicense, LicenseObligation.PatentGrant],
osiApproved: true, fsfFree: false);
// Strong copyleft licenses
AddLicense(licenses, "GPL-2.0-ONLY", "GNU General Public License v2.0 only", LicenseCategory.StrongCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.SameLicense, LicenseObligation.IncludeLicense],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "GPL-2.0-OR-LATER", "GNU General Public License v2.0 or later", LicenseCategory.StrongCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.SameLicense, LicenseObligation.IncludeLicense],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "GPL-3.0-ONLY", "GNU General Public License v3.0 only", LicenseCategory.StrongCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.SameLicense, LicenseObligation.IncludeLicense, LicenseObligation.PatentGrant],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "GPL-3.0-OR-LATER", "GNU General Public License v3.0 or later", LicenseCategory.StrongCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.SameLicense, LicenseObligation.IncludeLicense, LicenseObligation.PatentGrant],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "EUPL-1.2", "European Union Public License 1.2", LicenseCategory.StrongCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.SameLicense, LicenseObligation.IncludeLicense, LicenseObligation.PatentGrant],
osiApproved: true, fsfFree: true);
// Network copyleft licenses
AddLicense(licenses, "AGPL-3.0-ONLY", "GNU Affero General Public License v3.0 only", LicenseCategory.NetworkCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.SameLicense, LicenseObligation.IncludeLicense, LicenseObligation.PatentGrant, LicenseObligation.NetworkCopyleft],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "AGPL-3.0-OR-LATER", "GNU Affero General Public License v3.0 or later", LicenseCategory.NetworkCopyleft,
[LicenseObligation.Attribution, LicenseObligation.SourceDisclosure, LicenseObligation.SameLicense, LicenseObligation.IncludeLicense, LicenseObligation.PatentGrant, LicenseObligation.NetworkCopyleft],
osiApproved: true, fsfFree: true);
// Public domain dedications
AddLicense(licenses, "CC0-1.0", "Creative Commons Zero v1.0 Universal", LicenseCategory.PublicDomain,
[],
osiApproved: false, fsfFree: true);
AddLicense(licenses, "UNLICENSE", "The Unlicense", LicenseCategory.PublicDomain,
[],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "0BSD", "BSD Zero Clause License", LicenseCategory.PublicDomain,
[],
osiApproved: true, fsfFree: true);
AddLicense(licenses, "WTFPL", "Do What The F*ck You Want To Public License", LicenseCategory.PublicDomain,
[],
osiApproved: false, fsfFree: true);
// Deprecated license identifiers (map to current)
AddDeprecatedLicense(licenses, "GPL-2.0", "GPL-2.0-ONLY");
AddDeprecatedLicense(licenses, "GPL-2.0+", "GPL-2.0-OR-LATER");
AddDeprecatedLicense(licenses, "GPL-3.0", "GPL-3.0-ONLY");
AddDeprecatedLicense(licenses, "GPL-3.0+", "GPL-3.0-OR-LATER");
AddDeprecatedLicense(licenses, "LGPL-2.1", "LGPL-2.1-ONLY");
AddDeprecatedLicense(licenses, "LGPL-2.1+", "LGPL-2.1-OR-LATER");
AddDeprecatedLicense(licenses, "LGPL-3.0", "LGPL-3.0-ONLY");
AddDeprecatedLicense(licenses, "LGPL-3.0+", "LGPL-3.0-OR-LATER");
AddDeprecatedLicense(licenses, "AGPL-3.0", "AGPL-3.0-ONLY");
AddDeprecatedLicense(licenses, "AGPL-3.0+", "AGPL-3.0-OR-LATER");
return licenses.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
private static void AddLicense(
Dictionary<string, LicenseMetadata> licenses,
string spdxId,
string name,
LicenseCategory category,
LicenseObligation[] obligations,
bool osiApproved,
bool fsfFree,
bool deprecated = false)
{
licenses[spdxId.ToUpperInvariant()] = new LicenseMetadata
{
SpdxId = spdxId,
Name = name,
Category = category,
Obligations = obligations,
IsOsiApproved = osiApproved,
IsFsfFree = fsfFree,
IsDeprecated = deprecated
};
}
private static void AddDeprecatedLicense(
Dictionary<string, LicenseMetadata> licenses,
string deprecatedId,
string currentId)
{
if (licenses.TryGetValue(currentId.ToUpperInvariant(), out var current))
{
licenses[deprecatedId.ToUpperInvariant()] = current with
{
SpdxId = deprecatedId,
IsDeprecated = true,
AlternativeIds = [currentId]
};
}
}
}

View File

@@ -0,0 +1,280 @@
// -----------------------------------------------------------------------------
// LicenseDetectionAggregator.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-013 - Create license detection aggregator
// Description: Aggregates license detection results for reporting
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
/// <summary>
/// Default implementation of license detection result aggregation.
/// </summary>
public sealed class LicenseDetectionAggregator : ILicenseDetectionAggregator
{
/// <inheritdoc />
public LicenseDetectionSummary Aggregate(IReadOnlyList<LicenseDetectionResult> results)
{
return Aggregate(results, results.Count);
}
/// <inheritdoc />
public LicenseDetectionSummary Aggregate(
IReadOnlyList<LicenseDetectionResult> results,
int totalComponentCount)
{
if (results is null || results.Count == 0)
{
return new LicenseDetectionSummary
{
TotalComponents = totalComponentCount,
ComponentsWithLicense = 0,
ComponentsWithoutLicense = totalComponentCount,
};
}
// Deduplicate by SPDX ID and text hash
var uniqueResults = DeduplicateResults(results);
// Count by category
var byCategory = uniqueResults
.GroupBy(r => r.Category)
.ToImmutableDictionary(g => g.Key, g => g.Count());
// Count by SPDX ID
var bySpdxId = uniqueResults
.GroupBy(r => r.SpdxId, StringComparer.OrdinalIgnoreCase)
.ToImmutableDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
// Count unknowns
var unknownLicenses = uniqueResults
.Count(r => r.Category == LicenseCategory.Unknown ||
r.SpdxId.StartsWith("LicenseRef-", StringComparison.Ordinal));
// Count copyleft components
var copyleftCount = uniqueResults
.Count(r => r.Category is LicenseCategory.WeakCopyleft
or LicenseCategory.StrongCopyleft
or LicenseCategory.NetworkCopyleft);
// Extract unique copyright notices
var copyrightNotices = uniqueResults
.Where(r => !string.IsNullOrWhiteSpace(r.CopyrightNotice))
.Select(r => r.CopyrightNotice!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(c => c, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
// Get distinct license IDs
var distinctLicenses = uniqueResults
.Select(r => r.SpdxId)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(l => l, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new LicenseDetectionSummary
{
UniqueByComponent = uniqueResults,
ByCategory = byCategory,
BySpdxId = bySpdxId,
TotalComponents = totalComponentCount,
ComponentsWithLicense = uniqueResults.Length,
ComponentsWithoutLicense = totalComponentCount - uniqueResults.Length,
UnknownLicenses = unknownLicenses,
AllCopyrightNotices = copyrightNotices,
CopyleftComponentCount = copyleftCount,
DistinctLicenses = distinctLicenses,
};
}
/// <summary>
/// Creates a summary from results grouped by component.
/// </summary>
/// <param name="resultsByComponent">Results grouped by component key.</param>
/// <returns>The aggregated summary.</returns>
public LicenseDetectionSummary AggregateByComponent(
IReadOnlyDictionary<string, IReadOnlyList<LicenseDetectionResult>> resultsByComponent)
{
if (resultsByComponent is null || resultsByComponent.Count == 0)
{
return new LicenseDetectionSummary();
}
// Take the first (or best confidence) result for each component
var bestResults = resultsByComponent
.Select(kvp => SelectBestResult(kvp.Value))
.Where(r => r is not null)
.Cast<LicenseDetectionResult>()
.ToList();
return Aggregate(bestResults, resultsByComponent.Count);
}
/// <summary>
/// Merges multiple summaries into one.
/// </summary>
/// <param name="summaries">The summaries to merge.</param>
/// <returns>The merged summary.</returns>
public LicenseDetectionSummary Merge(IReadOnlyList<LicenseDetectionSummary> summaries)
{
if (summaries is null || summaries.Count == 0)
{
return new LicenseDetectionSummary();
}
if (summaries.Count == 1)
{
return summaries[0];
}
// Combine all unique results
var allResults = summaries
.SelectMany(s => s.UniqueByComponent)
.ToList();
var totalComponents = summaries.Sum(s => s.TotalComponents);
return Aggregate(allResults, totalComponents);
}
/// <summary>
/// Gets compliance risk indicators from the summary.
/// </summary>
/// <param name="summary">The license detection summary.</param>
/// <returns>Risk indicators for policy evaluation.</returns>
public LicenseComplianceRisk GetComplianceRisk(LicenseDetectionSummary summary)
{
if (summary is null)
{
return new LicenseComplianceRisk();
}
var hasStrongCopyleft = summary.ByCategory.ContainsKey(LicenseCategory.StrongCopyleft) &&
summary.ByCategory[LicenseCategory.StrongCopyleft] > 0;
var hasNetworkCopyleft = summary.ByCategory.ContainsKey(LicenseCategory.NetworkCopyleft) &&
summary.ByCategory[LicenseCategory.NetworkCopyleft] > 0;
var unknownPercentage = summary.TotalComponents > 0
? (double)summary.UnknownLicenses / summary.TotalComponents * 100
: 0;
var copyleftPercentage = summary.TotalComponents > 0
? (double)summary.CopyleftComponentCount / summary.TotalComponents * 100
: 0;
return new LicenseComplianceRisk
{
HasStrongCopyleft = hasStrongCopyleft,
HasNetworkCopyleft = hasNetworkCopyleft,
UnknownLicensePercentage = unknownPercentage,
CopyleftPercentage = copyleftPercentage,
MissingLicenseCount = summary.ComponentsWithoutLicense,
RequiresReview = hasStrongCopyleft || hasNetworkCopyleft || unknownPercentage > 10,
};
}
private static ImmutableArray<LicenseDetectionResult> DeduplicateResults(
IReadOnlyList<LicenseDetectionResult> results)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var unique = ImmutableArray.CreateBuilder<LicenseDetectionResult>();
foreach (var result in results)
{
// Generate a deduplication key
var key = GenerateDeduplicationKey(result);
if (seen.Add(key))
{
unique.Add(result);
}
}
return unique.ToImmutable();
}
private static string GenerateDeduplicationKey(LicenseDetectionResult result)
{
// Prefer text hash for uniqueness
if (!string.IsNullOrWhiteSpace(result.LicenseTextHash))
{
return $"{result.SpdxId}|{result.LicenseTextHash}";
}
// Fall back to SPDX ID + source
return $"{result.SpdxId}|{result.SourceFile ?? "unknown"}";
}
private static LicenseDetectionResult? SelectBestResult(IReadOnlyList<LicenseDetectionResult> results)
{
if (results is null || results.Count == 0)
{
return null;
}
if (results.Count == 1)
{
return results[0];
}
// Prefer highest confidence, then by detection method priority
return results
.OrderByDescending(r => r.Confidence)
.ThenBy(r => GetMethodPriority(r.Method))
.First();
}
private static int GetMethodPriority(LicenseDetectionMethod method)
{
return method switch
{
LicenseDetectionMethod.SpdxHeader => 0,
LicenseDetectionMethod.PackageMetadata => 1,
LicenseDetectionMethod.LicenseFile => 2,
LicenseDetectionMethod.ClassifierMapping => 3,
LicenseDetectionMethod.UrlMatching => 4,
LicenseDetectionMethod.PatternMatching => 5,
LicenseDetectionMethod.KeywordFallback => 6,
_ => 99
};
}
}
/// <summary>
/// License compliance risk indicators.
/// </summary>
public sealed record LicenseComplianceRisk
{
/// <summary>
/// Whether any component has a strong copyleft license (GPL).
/// </summary>
public bool HasStrongCopyleft { get; init; }
/// <summary>
/// Whether any component has a network copyleft license (AGPL).
/// </summary>
public bool HasNetworkCopyleft { get; init; }
/// <summary>
/// Percentage of components with unknown licenses.
/// </summary>
public double UnknownLicensePercentage { get; init; }
/// <summary>
/// Percentage of components with any copyleft license.
/// </summary>
public double CopyleftPercentage { get; init; }
/// <summary>
/// Number of components without any detected license.
/// </summary>
public int MissingLicenseCount { get; init; }
/// <summary>
/// Whether manual review is recommended based on risk indicators.
/// </summary>
public bool RequiresReview { get; init; }
}

View File

@@ -0,0 +1,260 @@
// -----------------------------------------------------------------------------
// LicenseDetectionResult.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-001 - Create unified LicenseDetectionResult model
// Description: Unified model for license detection results across all language analyzers
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
/// <summary>
/// Unified license detection result model for all language analyzers.
/// </summary>
public sealed record LicenseDetectionResult
{
/// <summary>
/// Normalized SPDX license identifier or LicenseRef- for custom licenses.
/// </summary>
public required string SpdxId { get; init; }
/// <summary>
/// Original license string from the source before normalization.
/// </summary>
public string? OriginalText { get; init; }
/// <summary>
/// URL to the license if provided in the source.
/// </summary>
public string? LicenseUrl { get; init; }
/// <summary>
/// Confidence level of the license detection.
/// </summary>
public LicenseDetectionConfidence Confidence { get; init; } = LicenseDetectionConfidence.None;
/// <summary>
/// Method used to detect the license.
/// </summary>
public LicenseDetectionMethod Method { get; init; } = LicenseDetectionMethod.KeywordFallback;
/// <summary>
/// Source file where the license was detected (e.g., LICENSE, package.json).
/// </summary>
public string? SourceFile { get; init; }
/// <summary>
/// Line number in the source file where the license was found, if applicable.
/// </summary>
public int? SourceLine { get; init; }
/// <summary>
/// Category of the license (permissive, copyleft, etc.).
/// </summary>
public LicenseCategory Category { get; init; } = LicenseCategory.Unknown;
/// <summary>
/// License obligations that apply to this license.
/// </summary>
public ImmutableArray<LicenseObligation> Obligations { get; init; } = [];
/// <summary>
/// Full text of the license if extracted.
/// </summary>
public string? LicenseText { get; init; }
/// <summary>
/// SHA256 hash of the license text for deduplication.
/// </summary>
public string? LicenseTextHash { get; init; }
/// <summary>
/// Extracted copyright notice(s) from the license.
/// </summary>
public string? CopyrightNotice { get; init; }
/// <summary>
/// Indicates if this is a compound SPDX expression (e.g., "MIT OR Apache-2.0").
/// </summary>
public bool IsExpression { get; init; }
/// <summary>
/// Individual license identifiers if this is a compound expression.
/// </summary>
public ImmutableArray<string> ExpressionComponents { get; init; } = [];
/// <summary>
/// Indicates if the license is OSI-approved.
/// </summary>
public bool? IsOsiApproved { get; init; }
/// <summary>
/// Indicates if the license is FSF-free.
/// </summary>
public bool? IsFsfFree { get; init; }
/// <summary>
/// Indicates if this license identifier is deprecated in the SPDX license list.
/// </summary>
public bool? IsDeprecated { get; init; }
}
/// <summary>
/// Confidence level of license detection.
/// </summary>
public enum LicenseDetectionConfidence
{
/// <summary>
/// High confidence - exact match from SPDX header or verified metadata.
/// </summary>
High,
/// <summary>
/// Medium confidence - normalized from package metadata or known patterns.
/// </summary>
Medium,
/// <summary>
/// Low confidence - inferred from partial matches or heuristics.
/// </summary>
Low,
/// <summary>
/// No confidence - unable to determine license.
/// </summary>
None
}
/// <summary>
/// Method used to detect the license.
/// </summary>
public enum LicenseDetectionMethod
{
/// <summary>
/// SPDX-License-Identifier comment in source code.
/// </summary>
SpdxHeader,
/// <summary>
/// Package metadata (package.json, Cargo.toml, pom.xml, etc.).
/// </summary>
PackageMetadata,
/// <summary>
/// LICENSE, COPYING, or similar file in the project.
/// </summary>
LicenseFile,
/// <summary>
/// PyPI classifiers or similar classification systems.
/// </summary>
ClassifierMapping,
/// <summary>
/// License URL lookup and matching.
/// </summary>
UrlMatching,
/// <summary>
/// Text pattern matching in license files.
/// </summary>
PatternMatching,
/// <summary>
/// Basic keyword detection fallback.
/// </summary>
KeywordFallback
}
/// <summary>
/// Category of license based on copyleft and usage restrictions.
/// </summary>
public enum LicenseCategory
{
/// <summary>
/// Permissive licenses (MIT, BSD, Apache, ISC, Zlib, Boost).
/// </summary>
Permissive,
/// <summary>
/// Weak copyleft licenses (LGPL, MPL, EPL, CDDL, OSL).
/// </summary>
WeakCopyleft,
/// <summary>
/// Strong copyleft licenses (GPL, EUPL, but not AGPL).
/// </summary>
StrongCopyleft,
/// <summary>
/// Network copyleft licenses (AGPL).
/// </summary>
NetworkCopyleft,
/// <summary>
/// Public domain dedications (CC0, Unlicense, WTFPL, 0BSD).
/// </summary>
PublicDomain,
/// <summary>
/// Proprietary or commercial licenses.
/// </summary>
Proprietary,
/// <summary>
/// Cannot determine category.
/// </summary>
Unknown
}
/// <summary>
/// Obligations that a license may impose.
/// </summary>
public enum LicenseObligation
{
/// <summary>
/// Must include copyright notice and attribution.
/// </summary>
Attribution,
/// <summary>
/// Must provide source code for modifications.
/// </summary>
SourceDisclosure,
/// <summary>
/// Derivative works must use the same license.
/// </summary>
SameLicense,
/// <summary>
/// License includes a patent grant.
/// </summary>
PatentGrant,
/// <summary>
/// Must include warranty disclaimer.
/// </summary>
NoWarranty,
/// <summary>
/// Must document modifications made to the code.
/// </summary>
StateChanges,
/// <summary>
/// Must include the full license text in distributions.
/// </summary>
IncludeLicense,
/// <summary>
/// Network use triggers copyleft (AGPL).
/// </summary>
NetworkCopyleft,
/// <summary>
/// Must include NOTICE file contents (Apache 2.0).
/// </summary>
IncludeNotice
}

View File

@@ -0,0 +1,68 @@
// -----------------------------------------------------------------------------
// LicenseDetectionSummary.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-001 - Create unified LicenseDetectionResult model
// Description: Aggregated summary of license detection results
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
/// <summary>
/// Aggregated summary of license detection results across components.
/// </summary>
public sealed record LicenseDetectionSummary
{
/// <summary>
/// Unique license detection results by component.
/// </summary>
public ImmutableArray<LicenseDetectionResult> UniqueByComponent { get; init; } = [];
/// <summary>
/// Count of components by license category.
/// </summary>
public ImmutableDictionary<LicenseCategory, int> ByCategory { get; init; } =
ImmutableDictionary<LicenseCategory, int>.Empty;
/// <summary>
/// Count of components by SPDX license identifier.
/// </summary>
public ImmutableDictionary<string, int> BySpdxId { get; init; } =
ImmutableDictionary<string, int>.Empty;
/// <summary>
/// Total number of components analyzed.
/// </summary>
public int TotalComponents { get; init; }
/// <summary>
/// Number of components with detected licenses.
/// </summary>
public int ComponentsWithLicense { get; init; }
/// <summary>
/// Number of components without detected licenses.
/// </summary>
public int ComponentsWithoutLicense { get; init; }
/// <summary>
/// Number of components with unknown/unrecognized licenses.
/// </summary>
public int UnknownLicenses { get; init; }
/// <summary>
/// All unique copyright notices extracted.
/// </summary>
public ImmutableArray<string> AllCopyrightNotices { get; init; } = [];
/// <summary>
/// Count of components with copyleft licenses that may have compliance implications.
/// </summary>
public int CopyleftComponentCount { get; init; }
/// <summary>
/// Distinct SPDX license identifiers found.
/// </summary>
public ImmutableArray<string> DistinctLicenses { get; init; } = [];
}

View File

@@ -0,0 +1,56 @@
// -----------------------------------------------------------------------------
// LicenseTextExtractionResult.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-001 - Create unified LicenseDetectionResult model
// Description: Result model for license text extraction
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
/// <summary>
/// Result of extracting license text from a file.
/// </summary>
public sealed record LicenseTextExtractionResult
{
/// <summary>
/// The full text of the license.
/// </summary>
public required string FullText { get; init; }
/// <summary>
/// SHA256 hash of the license text for deduplication.
/// </summary>
public required string TextHash { get; init; }
/// <summary>
/// Copyright notices extracted from the license text.
/// </summary>
public ImmutableArray<CopyrightNotice> CopyrightNotices { get; init; } = [];
/// <summary>
/// Detected SPDX license identifier if identifiable from text patterns.
/// </summary>
public string? DetectedLicenseId { get; init; }
/// <summary>
/// Confidence level of the license detection from text.
/// </summary>
public LicenseDetectionConfidence Confidence { get; init; } = LicenseDetectionConfidence.None;
/// <summary>
/// Source file path where the license was extracted from.
/// </summary>
public string? SourceFile { get; init; }
/// <summary>
/// File encoding detected during extraction.
/// </summary>
public string? Encoding { get; init; }
/// <summary>
/// Size of the license text in bytes.
/// </summary>
public long SizeBytes { get; init; }
}

View File

@@ -0,0 +1,389 @@
// -----------------------------------------------------------------------------
// LicenseTextExtractor.cs
// Sprint: SPRINT_20260119_024_Scanner_license_detection_enhancements
// Task: TASK-024-003 - Implement license text extractor
// Description: Implementation of license text extraction from files
// -----------------------------------------------------------------------------
using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
/// <summary>
/// Extracts license text from LICENSE, COPYING, and similar files.
/// </summary>
public sealed partial class LicenseTextExtractor : ILicenseTextExtractor
{
/// <summary>
/// Default maximum file size (1MB).
/// </summary>
public const long DefaultMaxFileSizeBytes = 1024 * 1024;
private static readonly FrozenSet<string> s_licenseFileNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"LICENSE",
"LICENSE.txt",
"LICENSE.md",
"LICENSE.rst",
"LICENCE",
"LICENCE.txt",
"LICENCE.md",
"COPYING",
"COPYING.txt",
"COPYING.md",
"NOTICE",
"NOTICE.txt",
"NOTICE.md",
"UNLICENSE",
"UNLICENSE.txt"
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
private static readonly FrozenSet<string> s_licenseFilePatterns = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"LICENSE-",
"LICENSE.",
"LICENCE-",
"LICENCE.",
"COPYING-",
"COPYING."
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
private static readonly FrozenDictionary<string, (string SpdxId, LicenseDetectionConfidence Confidence)> s_licensePatterns =
BuildLicensePatterns();
private readonly long _maxFileSizeBytes;
/// <summary>
/// Creates a new license text extractor with the specified maximum file size.
/// </summary>
/// <param name="maxFileSizeBytes">Maximum file size to process. Default is 1MB.</param>
public LicenseTextExtractor(long maxFileSizeBytes = DefaultMaxFileSizeBytes)
{
_maxFileSizeBytes = maxFileSizeBytes;
}
/// <inheritdoc/>
public async Task<LicenseTextExtractionResult?> ExtractAsync(string filePath, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(filePath))
return null;
if (!File.Exists(filePath))
return null;
var fileInfo = new FileInfo(filePath);
if (fileInfo.Length > _maxFileSizeBytes)
{
return new LicenseTextExtractionResult
{
FullText = $"[File exceeds maximum size of {_maxFileSizeBytes} bytes]",
TextHash = string.Empty,
SourceFile = filePath,
SizeBytes = fileInfo.Length,
Confidence = LicenseDetectionConfidence.None
};
}
try
{
var (content, encoding) = await ReadFileWithEncodingDetectionAsync(filePath, ct);
var result = Extract(content, filePath);
return result with
{
Encoding = encoding,
SizeBytes = fileInfo.Length
};
}
catch (Exception)
{
return null;
}
}
/// <inheritdoc/>
public LicenseTextExtractionResult Extract(string content, string? sourcePath = null)
{
if (string.IsNullOrWhiteSpace(content))
{
return new LicenseTextExtractionResult
{
FullText = string.Empty,
TextHash = ComputeHash(string.Empty),
SourceFile = sourcePath,
Confidence = LicenseDetectionConfidence.None
};
}
var copyrightNotices = ExtractCopyrightNotices(content);
var (detectedLicenseId, confidence) = DetectLicenseFromText(content);
return new LicenseTextExtractionResult
{
FullText = content,
TextHash = ComputeHash(content),
CopyrightNotices = copyrightNotices,
DetectedLicenseId = detectedLicenseId,
Confidence = confidence,
SourceFile = sourcePath,
SizeBytes = Encoding.UTF8.GetByteCount(content)
};
}
/// <inheritdoc/>
public async Task<IReadOnlyList<LicenseTextExtractionResult>> ExtractFromDirectoryAsync(
string directoryPath,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(directoryPath) || !Directory.Exists(directoryPath))
return [];
var results = new List<LicenseTextExtractionResult>();
try
{
var files = Directory.GetFiles(directoryPath);
foreach (var file in files)
{
ct.ThrowIfCancellationRequested();
var fileName = Path.GetFileName(file);
if (IsLicenseFile(fileName))
{
var result = await ExtractAsync(file, ct);
if (result is not null)
{
results.Add(result);
}
}
}
}
catch (UnauthorizedAccessException)
{
// Skip directories we can't access
}
return results;
}
/// <inheritdoc/>
public bool IsLicenseFile(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
return false;
// Exact match
if (s_licenseFileNames.Contains(fileName))
return true;
// Pattern match (e.g., LICENSE-MIT, LICENSE.Apache-2.0)
foreach (var pattern in s_licenseFilePatterns)
{
if (fileName.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static async Task<(string Content, string Encoding)> ReadFileWithEncodingDetectionAsync(
string filePath,
CancellationToken ct)
{
// Read raw bytes first to detect encoding
var bytes = await File.ReadAllBytesAsync(filePath, ct);
// Check for BOM
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
{
return (Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3), "UTF-8-BOM");
}
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
{
return (Encoding.Unicode.GetString(bytes, 2, bytes.Length - 2), "UTF-16LE");
}
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF)
{
return (Encoding.BigEndianUnicode.GetString(bytes, 2, bytes.Length - 2), "UTF-16BE");
}
// Default to UTF-8 (no BOM)
return (Encoding.UTF8.GetString(bytes), "UTF-8");
}
private static string ComputeHash(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static ImmutableArray<CopyrightNotice> ExtractCopyrightNotices(string content)
{
var notices = new List<CopyrightNotice>();
var lines = content.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i].Trim();
var notice = TryParseCopyrightLine(line, i + 1);
if (notice is not null)
{
notices.Add(notice);
}
}
return [.. notices];
}
private static CopyrightNotice? TryParseCopyrightLine(string line, int lineNumber)
{
// Match various copyright patterns
var match = CopyrightRegex().Match(line);
if (!match.Success)
{
match = CopyrightSymbolRegex().Match(line);
}
if (!match.Success)
{
match = ParenCopyrightRegex().Match(line);
}
if (!match.Success)
{
match = AllRightsReservedRegex().Match(line);
}
if (!match.Success)
return null;
var yearGroup = match.Groups["year"];
var holderGroup = match.Groups["holder"];
return new CopyrightNotice
{
FullText = line,
Year = yearGroup.Success ? NormalizeYear(yearGroup.Value) : null,
Holder = holderGroup.Success ? holderGroup.Value.Trim() : null,
LineNumber = lineNumber
};
}
private static string NormalizeYear(string year)
{
// Handle year ranges like "2018-2024" or "2018, 2020, 2024"
return year.Trim();
}
private static (string? SpdxId, LicenseDetectionConfidence Confidence) DetectLicenseFromText(string content)
{
var normalizedContent = content.ToUpperInvariant();
foreach (var (pattern, result) in s_licensePatterns)
{
if (normalizedContent.Contains(pattern))
{
return result;
}
}
// Check for SPDX identifier in the text
var spdxMatch = SpdxIdentifierRegex().Match(content);
if (spdxMatch.Success)
{
return (spdxMatch.Groups[1].Value, LicenseDetectionConfidence.High);
}
return (null, LicenseDetectionConfidence.None);
}
private static FrozenDictionary<string, (string SpdxId, LicenseDetectionConfidence Confidence)> BuildLicensePatterns()
{
return new Dictionary<string, (string, LicenseDetectionConfidence)>(StringComparer.OrdinalIgnoreCase)
{
// MIT patterns
["PERMISSION IS HEREBY GRANTED, FREE OF CHARGE"] = ("MIT", LicenseDetectionConfidence.High),
["THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED"] = ("MIT", LicenseDetectionConfidence.Medium),
// Apache 2.0 patterns
["APACHE LICENSE, VERSION 2.0"] = ("Apache-2.0", LicenseDetectionConfidence.High),
["LICENSED UNDER THE APACHE LICENSE, VERSION 2.0"] = ("Apache-2.0", LicenseDetectionConfidence.High),
["HTTP://WWW.APACHE.ORG/LICENSES/LICENSE-2.0"] = ("Apache-2.0", LicenseDetectionConfidence.High),
// BSD patterns
["REDISTRIBUTION AND USE IN SOURCE AND BINARY FORMS, WITH OR WITHOUT MODIFICATION"] = ("BSD-3-Clause", LicenseDetectionConfidence.Medium),
// GPL patterns
["GNU GENERAL PUBLIC LICENSE, VERSION 3"] = ("GPL-3.0-only", LicenseDetectionConfidence.High),
["GNU GENERAL PUBLIC LICENSE VERSION 3"] = ("GPL-3.0-only", LicenseDetectionConfidence.High),
["GNU GPL VERSION 3"] = ("GPL-3.0-only", LicenseDetectionConfidence.Medium),
["GNU GENERAL PUBLIC LICENSE, VERSION 2"] = ("GPL-2.0-only", LicenseDetectionConfidence.High),
["GNU GENERAL PUBLIC LICENSE VERSION 2"] = ("GPL-2.0-only", LicenseDetectionConfidence.High),
// LGPL patterns
["GNU LESSER GENERAL PUBLIC LICENSE, VERSION 3"] = ("LGPL-3.0-only", LicenseDetectionConfidence.High),
["GNU LESSER GENERAL PUBLIC LICENSE VERSION 3"] = ("LGPL-3.0-only", LicenseDetectionConfidence.High),
["GNU LESSER GENERAL PUBLIC LICENSE, VERSION 2.1"] = ("LGPL-2.1-only", LicenseDetectionConfidence.High),
// AGPL patterns
["GNU AFFERO GENERAL PUBLIC LICENSE, VERSION 3"] = ("AGPL-3.0-only", LicenseDetectionConfidence.High),
["GNU AFFERO GENERAL PUBLIC LICENSE VERSION 3"] = ("AGPL-3.0-only", LicenseDetectionConfidence.High),
// MPL patterns
["MOZILLA PUBLIC LICENSE, VERSION 2.0"] = ("MPL-2.0", LicenseDetectionConfidence.High),
["MOZILLA PUBLIC LICENSE VERSION 2.0"] = ("MPL-2.0", LicenseDetectionConfidence.High),
// ISC patterns
["ISC LICENSE"] = ("ISC", LicenseDetectionConfidence.Medium),
["PERMISSION TO USE, COPY, MODIFY, AND/OR DISTRIBUTE THIS SOFTWARE"] = ("ISC", LicenseDetectionConfidence.Medium),
// Unlicense patterns
["THIS IS FREE AND UNENCUMBERED SOFTWARE RELEASED INTO THE PUBLIC DOMAIN"] = ("Unlicense", LicenseDetectionConfidence.High),
// CC0 patterns
["CREATIVE COMMONS ZERO V1.0 UNIVERSAL"] = ("CC0-1.0", LicenseDetectionConfidence.High),
["CC0 1.0 UNIVERSAL"] = ("CC0-1.0", LicenseDetectionConfidence.High),
// WTFPL patterns
["DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE"] = ("WTFPL", LicenseDetectionConfidence.High),
// Boost patterns
["BOOST SOFTWARE LICENSE - VERSION 1.0"] = ("BSL-1.0", LicenseDetectionConfidence.High),
["BOOST SOFTWARE LICENSE, VERSION 1.0"] = ("BSL-1.0", LicenseDetectionConfidence.High),
// Zlib patterns
["ZLIB LICENSE"] = ("Zlib", LicenseDetectionConfidence.Medium),
// EPL patterns
["ECLIPSE PUBLIC LICENSE - V 2.0"] = ("EPL-2.0", LicenseDetectionConfidence.High),
["ECLIPSE PUBLIC LICENSE, VERSION 2.0"] = ("EPL-2.0", LicenseDetectionConfidence.High),
// EUPL patterns
["EUROPEAN UNION PUBLIC LICENCE V. 1.2"] = ("EUPL-1.2", LicenseDetectionConfidence.High)
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
// Regex patterns for copyright extraction
[GeneratedRegex(@"Copyright\s+(?:\(c\)\s+)?(?<year>\d{4}(?:\s*[-,]\s*\d{4})*)\s+(?<holder>.+)", RegexOptions.IgnoreCase)]
private static partial Regex CopyrightRegex();
[GeneratedRegex(@"©\s*(?<year>\d{4}(?:\s*[-,]\s*\d{4})*)\s+(?<holder>.+)", RegexOptions.IgnoreCase)]
private static partial Regex CopyrightSymbolRegex();
[GeneratedRegex(@"\(c\)\s*(?<year>\d{4}(?:\s*[-,]\s*\d{4})*)\s+(?<holder>.+)", RegexOptions.IgnoreCase)]
private static partial Regex ParenCopyrightRegex();
[GeneratedRegex(@"(?<year>\d{4}(?:\s*[-,]\s*\d{4})*)\s+(?<holder>.+?)\.\s*All\s+[Rr]ights\s+[Rr]eserved", RegexOptions.IgnoreCase)]
private static partial Regex AllRightsReservedRegex();
[GeneratedRegex(@"SPDX-License-Identifier:\s*([A-Za-z0-9\-\.+]+(?:\s+(?:OR|AND|WITH)\s+[A-Za-z0-9\-\.+]+)*)", RegexOptions.IgnoreCase)]
private static partial Regex SpdxIdentifierRegex();
}

View File

@@ -0,0 +1,18 @@
# Scanner.BuildProvenance - Agent Instructions
## Module Overview
Build provenance verification and SLSA evaluation for parsed SBOMs.
## Key Components
- **BuildProvenanceAnalyzer** - Orchestrates build provenance checks.
- **BuildProvenancePolicyLoader** - Loads build provenance policy (YAML/JSON).
- **BuildProvenanceReportFormatter** - JSON/text/PDF/SARIF formatting.
- **ReproducibilityVerifier** - Optional rebuild verification using GroundTruth.
## Required Reading
- `docs/modules/scanner/architecture.md`
## Working Agreement
- Keep outputs deterministic (stable ordering, UTC timestamps).
- Avoid external network calls; use offline fixtures for tests.
- Update sprint status and module docs when contracts change.

View File

@@ -0,0 +1,200 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.BuildProvenance.Models;
using StellaOps.Scanner.BuildProvenance.Policy;
namespace StellaOps.Scanner.BuildProvenance.Analyzers;
public sealed class BuildConfigVerifier
{
public IEnumerable<ProvenanceFinding> Verify(
ParsedSbom sbom,
BuildProvenanceChain chain,
BuildProvenancePolicy policy)
{
ArgumentNullException.ThrowIfNull(sbom);
ArgumentNullException.ThrowIfNull(chain);
ArgumentNullException.ThrowIfNull(policy);
var findings = new List<ProvenanceFinding>();
var buildInfo = sbom.BuildInfo;
if (policy.BuildRequirements.RequireConfigDigest)
{
if (string.IsNullOrWhiteSpace(chain.BuildConfigDigest)
|| string.IsNullOrWhiteSpace(chain.BuildConfigUri))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.MissingBuildConfig,
ProvenanceSeverity.High,
"Missing build configuration digest",
"Build configuration source or digest is missing.",
subject: chain.BuildConfigUri ?? sbom.SerialNumber));
}
}
if (!string.IsNullOrWhiteSpace(chain.BuildConfigUri)
&& !string.IsNullOrWhiteSpace(chain.BuildConfigDigest))
{
if (TryResolveConfigPath(chain.BuildConfigUri, out var path) && File.Exists(path))
{
var digest = ComputeSha256(path);
if (!DigestMatches(chain.BuildConfigDigest, digest))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.OutputMismatch,
ProvenanceSeverity.High,
"Build configuration digest mismatch",
$"Expected {chain.BuildConfigDigest} but computed sha256:{digest}.",
subject: path));
}
}
}
var env = buildInfo?.Environment;
if (env is not null && !env.IsEmpty)
{
if (env.Count > policy.BuildRequirements.MaxEnvironmentVariables)
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.EnvironmentVariableLeak,
ProvenanceSeverity.Medium,
"Excessive build environment variables",
$"Build environment contains {env.Count} variables; policy limit is {policy.BuildRequirements.MaxEnvironmentVariables}.",
subject: sbom.SerialNumber));
}
foreach (var key in env.Keys)
{
if (MatchesProhibitedPattern(key, policy.BuildRequirements.ProhibitedEnvVarPatterns))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.EnvironmentVariableLeak,
ProvenanceSeverity.High,
"Sensitive environment variable present",
$"Environment variable {key} matches prohibited patterns.",
subject: key));
}
}
}
if (policy.BuildRequirements.RequireHermeticBuild
&& buildInfo?.Parameters is not null
&& ContainsNetworkAccessSignal(buildInfo.Parameters))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.NonHermeticBuild,
ProvenanceSeverity.High,
"Non-hermetic build detected",
"Build parameters indicate network access during build.",
subject: sbom.SerialNumber));
}
return findings;
}
private static bool MatchesProhibitedPattern(string input, ImmutableArray<string> patterns)
{
if (patterns.IsDefaultOrEmpty)
{
return false;
}
foreach (var pattern in patterns)
{
if (BuildProvenancePatternMatcher.Matches(input, pattern))
{
return true;
}
}
return false;
}
private static bool ContainsNetworkAccessSignal(ImmutableDictionary<string, string> parameters)
{
var keys = new[]
{
"networkAccess",
"allowNetwork",
"buildNetworkAccess",
"netAccess"
};
foreach (var key in keys)
{
if (parameters.TryGetValue(key, out var value)
&& bool.TryParse(value, out var enabled)
&& enabled)
{
return true;
}
}
return false;
}
private static bool TryResolveConfigPath(string uri, out string path)
{
path = uri;
if (string.IsNullOrWhiteSpace(uri))
{
return false;
}
if (Uri.TryCreate(uri, UriKind.Absolute, out var parsed))
{
if (parsed.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase))
{
path = parsed.LocalPath;
return true;
}
return false;
}
return true;
}
private static string ComputeSha256(string path)
{
using var stream = File.OpenRead(path);
var hash = SHA256.HashData(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static bool DigestMatches(string expected, string actualHex)
{
if (string.IsNullOrWhiteSpace(expected))
{
return false;
}
var normalized = expected.Trim();
if (normalized.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized["sha256:".Length..];
}
return string.Equals(normalized, actualHex, StringComparison.OrdinalIgnoreCase);
}
private static ProvenanceFinding BuildFinding(
BuildProvenanceFindingType type,
ProvenanceSeverity severity,
string title,
string description,
string? subject)
{
return new ProvenanceFinding
{
Type = type,
Severity = severity,
Title = title,
Description = description,
Subject = subject
};
}
}

View File

@@ -0,0 +1,110 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.BuildProvenance.Models;
using StellaOps.Scanner.BuildProvenance.Policy;
namespace StellaOps.Scanner.BuildProvenance.Analyzers;
public sealed class BuildInputIntegrityChecker
{
public IEnumerable<ProvenanceFinding> Verify(
ParsedSbom sbom,
BuildProvenanceChain chain,
BuildProvenancePolicy policy)
{
ArgumentNullException.ThrowIfNull(sbom);
ArgumentNullException.ThrowIfNull(chain);
ArgumentNullException.ThrowIfNull(policy);
var findings = new List<ProvenanceFinding>();
var componentRefs = new HashSet<string>(sbom.Components
.Select(c => c.BomRef)
.Where(refId => !string.IsNullOrWhiteSpace(refId))!
.Select(refId => refId!), StringComparer.OrdinalIgnoreCase);
foreach (var input in chain.Inputs)
{
if (string.IsNullOrWhiteSpace(input.Reference))
{
continue;
}
if (!componentRefs.Contains(input.Reference))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.InputIntegrityFailed,
ProvenanceSeverity.Medium,
"Unknown build input",
$"Build input reference {input.Reference} does not exist in SBOM components.",
subject: input.Reference));
}
}
foreach (var dependency in sbom.Dependencies)
{
if (!componentRefs.Contains(dependency.SourceRef))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.InputIntegrityFailed,
ProvenanceSeverity.Low,
"Dependency source missing",
$"Dependency source {dependency.SourceRef} is missing from SBOM components.",
subject: dependency.SourceRef));
}
}
if (policy.BuildRequirements.RequireHermeticBuild
&& sbom.BuildInfo?.Parameters is not null
&& ContainsNetworkAccessSignal(sbom.BuildInfo.Parameters))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.NonHermeticBuild,
ProvenanceSeverity.High,
"Network access during build",
"Build parameters indicate network access during build steps.",
subject: sbom.SerialNumber));
}
return findings;
}
private static bool ContainsNetworkAccessSignal(ImmutableDictionary<string, string> parameters)
{
var keys = new[]
{
"networkAccess",
"allowNetwork",
"buildNetworkAccess",
"netAccess"
};
foreach (var key in keys)
{
if (parameters.TryGetValue(key, out var value)
&& bool.TryParse(value, out var enabled)
&& enabled)
{
return true;
}
}
return false;
}
private static ProvenanceFinding BuildFinding(
BuildProvenanceFindingType type,
ProvenanceSeverity severity,
string title,
string description,
string? subject)
{
return new ProvenanceFinding
{
Type = type,
Severity = severity,
Title = title,
Description = description,
Subject = subject
};
}
}

View File

@@ -0,0 +1,207 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.BuildProvenance.Models;
using StellaOps.Scanner.BuildProvenance.Policy;
namespace StellaOps.Scanner.BuildProvenance.Analyzers;
public interface IBuildProvenanceVerifier
{
Task<BuildProvenanceReport> VerifyAsync(
ParsedSbom sbom,
BuildProvenancePolicy policy,
CancellationToken ct);
}
public sealed class BuildProvenanceAnalyzer : IBuildProvenanceVerifier
{
private readonly BuildProvenanceChainBuilder _chainBuilder;
private readonly BuildConfigVerifier _configVerifier;
private readonly SourceVerifier _sourceVerifier;
private readonly BuilderVerifier _builderVerifier;
private readonly BuildInputIntegrityChecker _inputChecker;
private readonly ReproducibilityVerifier _reproVerifier;
private readonly SlsaLevelEvaluator _slsaEvaluator;
private readonly ILogger<BuildProvenanceAnalyzer> _logger;
public BuildProvenanceAnalyzer(
BuildProvenanceChainBuilder chainBuilder,
BuildConfigVerifier configVerifier,
SourceVerifier sourceVerifier,
BuilderVerifier builderVerifier,
BuildInputIntegrityChecker inputChecker,
ReproducibilityVerifier reproVerifier,
SlsaLevelEvaluator slsaEvaluator,
ILogger<BuildProvenanceAnalyzer> logger)
{
_chainBuilder = chainBuilder ?? throw new ArgumentNullException(nameof(chainBuilder));
_configVerifier = configVerifier ?? throw new ArgumentNullException(nameof(configVerifier));
_sourceVerifier = sourceVerifier ?? throw new ArgumentNullException(nameof(sourceVerifier));
_builderVerifier = builderVerifier ?? throw new ArgumentNullException(nameof(builderVerifier));
_inputChecker = inputChecker ?? throw new ArgumentNullException(nameof(inputChecker));
_reproVerifier = reproVerifier ?? throw new ArgumentNullException(nameof(reproVerifier));
_slsaEvaluator = slsaEvaluator ?? throw new ArgumentNullException(nameof(slsaEvaluator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<BuildProvenanceReport> VerifyAsync(
ParsedSbom sbom,
BuildProvenancePolicy policy,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(sbom);
ArgumentNullException.ThrowIfNull(policy);
var chain = _chainBuilder.Build(sbom);
var findings = new List<ProvenanceFinding>();
if (sbom.BuildInfo is null && sbom.Formulation is null)
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.MissingBuildProvenance,
ProvenanceSeverity.High,
"Missing build provenance",
"SBOM contains no build provenance or formulation data.",
subject: sbom.SerialNumber));
}
findings.AddRange(_configVerifier.Verify(sbom, chain, policy));
findings.AddRange(_sourceVerifier.Verify(sbom, chain, policy));
findings.AddRange(_builderVerifier.Verify(sbom, chain, policy));
findings.AddRange(_inputChecker.Verify(sbom, chain, policy));
var reproStatus = await _reproVerifier.VerifyAsync(sbom, policy, ct).ConfigureAwait(false);
if (reproStatus.State == ReproducibilityState.NotReproducible)
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.NonReproducibleBuild,
ProvenanceSeverity.High,
"Build is not reproducible",
reproStatus.Details ?? "Rebuild verification reported differences.",
subject: sbom.SerialNumber));
}
else if (reproStatus.State == ReproducibilityState.Failed)
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.NonReproducibleBuild,
ProvenanceSeverity.Medium,
"Reproducibility verification failed",
reproStatus.Details ?? "Rebuild verification failed.",
subject: sbom.SerialNumber));
}
var effectivePolicy = ApplyExemptions(policy, sbom);
var slsaLevel = _slsaEvaluator.Evaluate(sbom, chain, reproStatus, findings, effectivePolicy);
if (slsaLevel < (SlsaLevel)Math.Max(effectivePolicy.MinimumSlsaLevel, 0))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.SlsaLevelInsufficient,
ProvenanceSeverity.High,
"SLSA level below policy minimum",
$"Achieved {slsaLevel} but policy requires level {effectivePolicy.MinimumSlsaLevel}.",
subject: chain.BuilderId ?? sbom.SerialNumber));
}
var summary = BuildSummary(findings);
var attestation = new BuildProvenanceAttestation
{
SlsaLevel = slsaLevel,
BuilderId = chain.BuilderId,
SourceRepository = chain.SourceRepository,
SourceCommit = chain.SourceCommit,
GeneratedAtUtc = DateTimeOffset.UtcNow
};
_logger.LogInformation(
"Build provenance verification complete for {Serial}: SLSA={SlsaLevel} Findings={Findings}",
sbom.SerialNumber,
slsaLevel,
findings.Count);
return new BuildProvenanceReport
{
AchievedLevel = slsaLevel,
Findings = findings.ToImmutableArray(),
ProvenanceChain = chain,
ReproducibilityStatus = reproStatus,
Summary = summary,
GeneratedAtUtc = DateTimeOffset.UtcNow,
PolicyVersion = policy.Version,
Attestation = attestation
};
}
private static BuildProvenancePolicy ApplyExemptions(BuildProvenancePolicy policy, ParsedSbom sbom)
{
if (policy.Exemptions.IsDefaultOrEmpty)
{
return policy;
}
foreach (var exemption in policy.Exemptions)
{
if (string.IsNullOrWhiteSpace(exemption.ComponentPattern))
{
continue;
}
foreach (var component in sbom.Components)
{
var candidate = component.Purl ?? component.Name ?? component.BomRef ?? string.Empty;
if (BuildProvenancePatternMatcher.Matches(candidate, exemption.ComponentPattern))
{
var overrideLevel = exemption.SlsaLevelOverride;
if (overrideLevel.HasValue)
{
return policy with { MinimumSlsaLevel = overrideLevel.Value };
}
return policy;
}
}
}
return policy;
}
private static BuildProvenanceSummary BuildSummary(IReadOnlyList<ProvenanceFinding> findings)
{
if (findings.Count == 0)
{
return BuildProvenanceSummary.Empty;
}
var bySeverity = findings
.GroupBy(f => f.Severity)
.ToImmutableDictionary(g => g.Key, g => g.Count());
var byType = findings
.GroupBy(f => f.Type)
.ToImmutableDictionary(g => g.Key, g => g.Count());
return new BuildProvenanceSummary
{
TotalFindings = findings.Count,
FindingsBySeverity = bySeverity,
FindingsByType = byType
};
}
private static ProvenanceFinding BuildFinding(
BuildProvenanceFindingType type,
ProvenanceSeverity severity,
string title,
string description,
string? subject)
{
return new ProvenanceFinding
{
Type = type,
Severity = severity,
Title = title,
Description = description,
Subject = subject
};
}
}

View File

@@ -0,0 +1,148 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.BuildProvenance.Models;
namespace StellaOps.Scanner.BuildProvenance.Analyzers;
public sealed class BuildProvenanceChainBuilder
{
private static readonly string[] BuilderIdKeys =
{
"builderId",
"builder",
"builder_id",
"buildService",
"build.service"
};
private static readonly string[] SourceRepoKeys =
{
"sourceRepository",
"sourceRepo",
"repository",
"repo",
"gitUrl",
"git.url"
};
private static readonly string[] SourceCommitKeys =
{
"sourceCommit",
"commit",
"gitCommit",
"git.commit",
"revision"
};
public BuildProvenanceChain Build(ParsedSbom sbom)
{
ArgumentNullException.ThrowIfNull(sbom);
var buildInfo = sbom.BuildInfo;
var formulation = sbom.Formulation;
var environment = buildInfo?.Environment ?? ImmutableDictionary<string, string>.Empty;
var builderId = FindParameter(buildInfo, BuilderIdKeys)
?? buildInfo?.BuildType;
var sourceRepo = FindParameter(buildInfo, SourceRepoKeys);
var sourceCommit = FindParameter(buildInfo, SourceCommitKeys);
var configUri = buildInfo?.ConfigSourceUri ?? buildInfo?.ConfigSourceEntrypoint;
var configDigest = buildInfo?.ConfigSourceDigest;
var inputs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var outputs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (formulation is not null)
{
foreach (var component in formulation.Components)
{
if (!string.IsNullOrWhiteSpace(component.BomRef))
{
inputs.Add(component.BomRef!);
}
foreach (var reference in component.ComponentRefs)
{
if (!string.IsNullOrWhiteSpace(reference))
{
inputs.Add(reference);
}
}
}
foreach (var workflow in formulation.Workflows)
{
foreach (var input in workflow.InputRefs)
{
if (!string.IsNullOrWhiteSpace(input))
{
inputs.Add(input);
}
}
foreach (var output in workflow.OutputRefs)
{
if (!string.IsNullOrWhiteSpace(output))
{
outputs.Add(output);
}
}
}
foreach (var task in formulation.Tasks)
{
foreach (var input in task.InputRefs)
{
if (!string.IsNullOrWhiteSpace(input))
{
inputs.Add(input);
}
}
foreach (var output in task.OutputRefs)
{
if (!string.IsNullOrWhiteSpace(output))
{
outputs.Add(output);
}
}
}
}
return new BuildProvenanceChain
{
BuilderId = builderId,
SourceRepository = sourceRepo,
SourceCommit = sourceCommit,
BuildConfigUri = configUri,
BuildConfigDigest = configDigest,
Environment = environment,
Inputs = inputs.Select(reference => new BuildInput { Reference = reference }).ToImmutableArray(),
Outputs = outputs.Select(reference => new BuildOutput { Reference = reference }).ToImmutableArray()
};
}
private static string? FindParameter(ParsedBuildInfo? buildInfo, IEnumerable<string> keys)
{
if (buildInfo?.Parameters is null || buildInfo.Parameters.IsEmpty)
{
return null;
}
foreach (var key in keys)
{
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
if (buildInfo.Parameters.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return null;
}
}

View File

@@ -0,0 +1,46 @@
namespace StellaOps.Scanner.BuildProvenance.Analyzers;
internal static class BuildProvenancePatternMatcher
{
public static bool Matches(string input, string pattern)
{
if (string.IsNullOrEmpty(pattern))
{
return false;
}
var normalizedInput = input ?? string.Empty;
var normalizedPattern = pattern.Trim();
if (normalizedPattern == "*")
{
return true;
}
var wildcardIndex = normalizedPattern.IndexOf('*');
if (wildcardIndex < 0)
{
return normalizedInput.Equals(normalizedPattern, StringComparison.OrdinalIgnoreCase);
}
var parts = normalizedPattern.Split('*', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
{
return true;
}
var position = 0;
foreach (var part in parts)
{
var matchIndex = normalizedInput.IndexOf(part, position, StringComparison.OrdinalIgnoreCase);
if (matchIndex < 0)
{
return false;
}
position = matchIndex + part.Length;
}
return true;
}
}

View File

@@ -0,0 +1,144 @@
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.BuildProvenance.Models;
using StellaOps.Scanner.BuildProvenance.Policy;
namespace StellaOps.Scanner.BuildProvenance.Analyzers;
public sealed class BuilderVerifier
{
private static readonly string[] BuilderVersionKeys =
{
"builderVersion",
"runnerVersion",
"buildServiceVersion",
"builder.version"
};
private static readonly string[] BuilderAttestationKeys =
{
"builderAttestationSigned",
"builderSignature",
"builder.signature"
};
public IEnumerable<ProvenanceFinding> Verify(
ParsedSbom sbom,
BuildProvenanceChain chain,
BuildProvenancePolicy policy)
{
ArgumentNullException.ThrowIfNull(sbom);
ArgumentNullException.ThrowIfNull(chain);
ArgumentNullException.ThrowIfNull(policy);
var findings = new List<ProvenanceFinding>();
var builderId = chain.BuilderId ?? string.Empty;
if (string.IsNullOrWhiteSpace(builderId))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.UnverifiedBuilder,
ProvenanceSeverity.High,
"Missing builder identity",
"Build provenance does not include a builder identity.",
subject: sbom.SerialNumber));
return findings;
}
var trusted = policy.TrustedBuilders.FirstOrDefault(b =>
string.Equals(b.Id, builderId, StringComparison.OrdinalIgnoreCase));
if (trusted is null)
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.UnverifiedBuilder,
ProvenanceSeverity.Medium,
"Builder is not trusted",
$"Builder {builderId} is not in the trusted registry.",
subject: builderId));
}
var version = FindParameter(sbom.BuildInfo, BuilderVersionKeys);
if (trusted is not null && !string.IsNullOrWhiteSpace(trusted.MinVersion)
&& !string.IsNullOrWhiteSpace(version)
&& Version.TryParse(trusted.MinVersion, out var minVersion)
&& Version.TryParse(version, out var builderVersion))
{
if (builderVersion < minVersion)
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.UnverifiedBuilder,
ProvenanceSeverity.Medium,
"Builder version below minimum",
$"Builder {builderId} version {builderVersion} is below required {minVersion}.",
subject: builderId));
}
}
if (trusted is not null && !IsAttestationSigned(sbom.BuildInfo))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.UnverifiedBuilder,
ProvenanceSeverity.Low,
"Builder attestation missing",
"Trusted builder attestation signature was not detected.",
subject: builderId));
}
return findings;
}
private static bool IsAttestationSigned(ParsedBuildInfo? buildInfo)
{
if (buildInfo?.Parameters is null || buildInfo.Parameters.IsEmpty)
{
return false;
}
foreach (var key in BuilderAttestationKeys)
{
if (buildInfo.Parameters.TryGetValue(key, out var value)
&& bool.TryParse(value, out var parsed)
&& parsed)
{
return true;
}
}
return false;
}
private static string? FindParameter(ParsedBuildInfo? buildInfo, IEnumerable<string> keys)
{
if (buildInfo?.Parameters is null || buildInfo.Parameters.IsEmpty)
{
return null;
}
foreach (var key in keys)
{
if (buildInfo.Parameters.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return null;
}
private static ProvenanceFinding BuildFinding(
BuildProvenanceFindingType type,
ProvenanceSeverity severity,
string title,
string description,
string? subject)
{
return new ProvenanceFinding
{
Type = type,
Severity = severity,
Title = title,
Description = description,
Subject = subject
};
}
}

View File

@@ -0,0 +1,185 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.GroundTruth.Reproducible;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.BuildProvenance.Models;
using StellaOps.Scanner.BuildProvenance.Policy;
namespace StellaOps.Scanner.BuildProvenance.Analyzers;
public sealed class ReproducibilityVerifier
{
private static readonly string[] BuildinfoPathKeys =
{
"buildinfoPath",
"buildinfo.path",
"buildinfo"
};
public ReproducibilityVerifier(
IRebuildService rebuildService,
DeterminismValidator determinismValidator,
ILogger<ReproducibilityVerifier> logger)
{
_rebuildService = rebuildService ?? throw new ArgumentNullException(nameof(rebuildService));
_determinismValidator = determinismValidator ?? throw new ArgumentNullException(nameof(determinismValidator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ReproducibilityStatus> VerifyAsync(
ParsedSbom sbom,
BuildProvenancePolicy policy,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(sbom);
ArgumentNullException.ThrowIfNull(policy);
if (!policy.Reproducibility.VerifyOnDemand)
{
return new ReproducibilityStatus
{
State = ReproducibilityState.NotRequested,
Details = "Reproducibility verification is disabled by policy.",
Issues = []
};
}
var buildInfo = sbom.BuildInfo;
if (buildInfo is null)
{
return new ReproducibilityStatus
{
State = ReproducibilityState.Skipped,
Details = "No build metadata available for reproducibility checks.",
Issues = []
};
}
var buildinfoPath = FindParameter(buildInfo, BuildinfoPathKeys);
if (string.IsNullOrWhiteSpace(buildinfoPath) || !File.Exists(buildinfoPath))
{
return new ReproducibilityStatus
{
State = ReproducibilityState.Skipped,
Details = "Buildinfo path is missing or unavailable.",
Issues = []
};
}
_logger.LogInformation("Running reproducibility verification for buildinfo {Path}.", buildinfoPath);
var result = await _rebuildService.RebuildLocalAsync(buildinfoPath, cancellationToken: ct)
.ConfigureAwait(false);
if (!result.Success)
{
return new ReproducibilityStatus
{
State = ReproducibilityState.Failed,
Backend = result.Backend.ToString(),
Details = result.Error ?? "Rebuild failed.",
Issues = BuildIssues(result, ProvenanceSeverity.Medium)
};
}
var reproducible = result.Reproducible ?? false;
var determinismIssues = await ValidateDeterminismAsync(buildInfo, result, ct).ConfigureAwait(false);
var mergedIssues = BuildIssues(result, reproducible ? ProvenanceSeverity.Low : ProvenanceSeverity.High)
.AddRange(determinismIssues);
return new ReproducibilityStatus
{
State = reproducible ? ReproducibilityState.Reproducible : ReproducibilityState.NotReproducible,
Backend = result.Backend.ToString(),
Details = reproducible ? "Rebuild matched declared checksums." : "Rebuild checksums differ.",
Issues = mergedIssues
};
}
private static ImmutableArray<ReproducibilityIssue> BuildIssues(RebuildResult result, ProvenanceSeverity severity)
{
if (result.ChecksumResults is null)
{
return [];
}
var issues = new List<ReproducibilityIssue>();
foreach (var checksum in result.ChecksumResults)
{
if (checksum.Matches)
{
continue;
}
issues.Add(new ReproducibilityIssue
{
Code = "checksum_mismatch",
Description = $"{checksum.Filename} expected {checksum.ExpectedSha256} got {checksum.ActualSha256}.",
Severity = severity
});
}
return issues.ToImmutableArray();
}
private async Task<ImmutableArray<ReproducibilityIssue>> ValidateDeterminismAsync(
ParsedBuildInfo buildInfo,
RebuildResult result,
CancellationToken ct)
{
if (buildInfo.Parameters.IsEmpty || result.Artifacts is null || result.Artifacts.Count == 0)
{
return [];
}
if (!buildInfo.Parameters.TryGetValue("originalArtifactPath", out var originalPath)
|| string.IsNullOrWhiteSpace(originalPath))
{
return [];
}
var rebuiltPath = result.Artifacts[0].Path;
if (!File.Exists(originalPath) || !File.Exists(rebuiltPath))
{
return [];
}
var report = await _determinismValidator.ValidateAsync(originalPath, rebuiltPath, cancellationToken: ct)
.ConfigureAwait(false);
if (report.IsReproducible)
{
return [];
}
return
[
new ReproducibilityIssue
{
Code = "determinism_mismatch",
Description = report.Error ?? "Determinism validation reported differences.",
Severity = ProvenanceSeverity.High
}
];
}
private static string? FindParameter(ParsedBuildInfo buildInfo, IEnumerable<string> keys)
{
if (buildInfo.Parameters.IsEmpty)
{
return null;
}
foreach (var key in keys)
{
if (buildInfo.Parameters.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return null;
}
private readonly IRebuildService _rebuildService;
private readonly DeterminismValidator _determinismValidator;
private readonly ILogger<ReproducibilityVerifier> _logger;
}

View File

@@ -0,0 +1,133 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.BuildProvenance.Models;
using StellaOps.Scanner.BuildProvenance.Policy;
namespace StellaOps.Scanner.BuildProvenance.Analyzers;
public sealed class SlsaLevelEvaluator
{
private static readonly string[] SignedProvenanceKeys =
{
"provenanceSigned",
"builderAttestationSigned",
"signedProvenance",
"provenance.signature"
};
private static readonly string[] HermeticKeys =
{
"hermetic",
"hermeticBuild",
"buildHermetic",
"isolatedBuild",
"buildIsolation",
"build.isolated",
"build.hermetic",
"sandboxed"
};
public SlsaLevel Evaluate(
ParsedSbom sbom,
BuildProvenanceChain chain,
ReproducibilityStatus reproducibilityStatus,
IReadOnlyList<ProvenanceFinding> findings,
BuildProvenancePolicy policy)
{
ArgumentNullException.ThrowIfNull(sbom);
ArgumentNullException.ThrowIfNull(chain);
ArgumentNullException.ThrowIfNull(reproducibilityStatus);
ArgumentNullException.ThrowIfNull(findings);
ArgumentNullException.ThrowIfNull(policy);
var hasProvenance = sbom.BuildInfo is not null || sbom.Formulation is not null;
if (!hasProvenance)
{
return SlsaLevel.None;
}
var level = SlsaLevel.Level1;
var hasBuilder = !string.IsNullOrWhiteSpace(chain.BuilderId);
var provenanceSigned = IsProvenanceSigned(sbom.BuildInfo);
if (hasBuilder && provenanceSigned)
{
level = SlsaLevel.Level2;
}
if (level >= SlsaLevel.Level2 && IsHermetic(sbom.BuildInfo, findings, policy))
{
level = SlsaLevel.Level3;
}
if (level >= SlsaLevel.Level3 && reproducibilityStatus.State == ReproducibilityState.Reproducible)
{
level = SlsaLevel.Level4;
}
return level;
}
private static bool IsProvenanceSigned(ParsedBuildInfo? buildInfo)
{
if (buildInfo?.Parameters is null || buildInfo.Parameters.IsEmpty)
{
return false;
}
foreach (var key in SignedProvenanceKeys)
{
if (buildInfo.Parameters.TryGetValue(key, out var value)
&& bool.TryParse(value, out var parsed)
&& parsed)
{
return true;
}
}
return false;
}
private static bool IsHermetic(
ParsedBuildInfo? buildInfo,
IReadOnlyList<ProvenanceFinding> findings,
BuildProvenancePolicy policy)
{
if (!policy.BuildRequirements.RequireHermeticBuild && !HasHermeticSignal(buildInfo))
{
return false;
}
foreach (var finding in findings)
{
if (finding.Type is BuildProvenanceFindingType.NonHermeticBuild
or BuildProvenanceFindingType.EnvironmentVariableLeak
or BuildProvenanceFindingType.MissingBuildConfig)
{
return false;
}
}
return true;
}
private static bool HasHermeticSignal(ParsedBuildInfo? buildInfo)
{
if (buildInfo?.Parameters is null || buildInfo.Parameters.IsEmpty)
{
return false;
}
foreach (var key in HermeticKeys)
{
if (buildInfo.Parameters.TryGetValue(key, out var value)
&& bool.TryParse(value, out var parsed)
&& parsed)
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,172 @@
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.BuildProvenance.Models;
using StellaOps.Scanner.BuildProvenance.Policy;
namespace StellaOps.Scanner.BuildProvenance.Analyzers;
public sealed class SourceVerifier
{
private static readonly string[] SignedKeys =
{
"sourceSigned",
"commitSigned",
"commitSignature",
"signedCommit",
"source.signature"
};
private static readonly string[] RefKeys =
{
"sourceRef",
"ref",
"gitRef",
"git.ref"
};
public IEnumerable<ProvenanceFinding> Verify(
ParsedSbom sbom,
BuildProvenanceChain chain,
BuildProvenancePolicy policy)
{
ArgumentNullException.ThrowIfNull(sbom);
ArgumentNullException.ThrowIfNull(chain);
ArgumentNullException.ThrowIfNull(policy);
var findings = new List<ProvenanceFinding>();
if (policy.SourceRequirements.RequireSignedCommits && !IsSigned(sbom.BuildInfo))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.UnsignedSource,
ProvenanceSeverity.High,
"Unsigned source commit",
"Source commit signature requirement is not satisfied.",
subject: chain.SourceCommit ?? chain.SourceRepository));
}
if (!policy.SourceRequirements.AllowedRepositories.IsDefaultOrEmpty)
{
var repo = chain.SourceRepository ?? string.Empty;
if (!MatchesAny(repo, policy.SourceRequirements.AllowedRepositories))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.InputIntegrityFailed,
ProvenanceSeverity.Medium,
"Source repository not in allowed list",
$"Repository {repo} is not allowed by policy.",
subject: repo));
}
}
if (policy.SourceRequirements.RequireTaggedRelease)
{
var reference = FindParameter(sbom.BuildInfo, RefKeys);
if (!IsTagReference(reference))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.InputIntegrityFailed,
ProvenanceSeverity.Medium,
"Source ref is not a tagged release",
"Policy requires builds from tagged releases.",
subject: reference ?? chain.SourceCommit ?? chain.SourceRepository));
}
}
if (string.IsNullOrWhiteSpace(chain.SourceRepository))
{
findings.Add(BuildFinding(
BuildProvenanceFindingType.MissingBuildProvenance,
ProvenanceSeverity.Medium,
"Missing source repository",
"Build provenance is missing source repository details.",
subject: sbom.SerialNumber));
}
return findings;
}
private static bool IsSigned(ParsedBuildInfo? buildInfo)
{
if (buildInfo?.Parameters is null || buildInfo.Parameters.IsEmpty)
{
return false;
}
foreach (var key in SignedKeys)
{
if (buildInfo.Parameters.TryGetValue(key, out var value)
&& bool.TryParse(value, out var parsed)
&& parsed)
{
return true;
}
}
return false;
}
private static bool MatchesAny(string input, IEnumerable<string> patterns)
{
foreach (var pattern in patterns)
{
if (BuildProvenancePatternMatcher.Matches(input, pattern))
{
return true;
}
}
return false;
}
private static bool IsTagReference(string? reference)
{
if (string.IsNullOrWhiteSpace(reference))
{
return false;
}
var trimmed = reference.Trim();
if (trimmed.Contains("refs/tags/", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return trimmed.StartsWith("v", StringComparison.OrdinalIgnoreCase)
&& trimmed.Length > 1;
}
private static string? FindParameter(ParsedBuildInfo? buildInfo, IEnumerable<string> keys)
{
if (buildInfo?.Parameters is null || buildInfo.Parameters.IsEmpty)
{
return null;
}
foreach (var key in keys)
{
if (buildInfo.Parameters.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return null;
}
private static ProvenanceFinding BuildFinding(
BuildProvenanceFindingType type,
ProvenanceSeverity severity,
string title,
string description,
string? subject)
{
return new ProvenanceFinding
{
Type = type,
Severity = severity,
Title = title,
Description = description,
Subject = subject
};
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.BuildProvenance.Analyzers;
using StellaOps.Scanner.BuildProvenance.Policy;
namespace StellaOps.Scanner.BuildProvenance;
public static class BuildProvenanceServiceCollectionExtensions
{
public static IServiceCollection AddBuildProvenance(this IServiceCollection services)
{
services.TryAddSingleton<IBuildProvenanceVerifier, BuildProvenanceAnalyzer>();
services.TryAddSingleton<BuildProvenanceChainBuilder>();
services.TryAddSingleton<BuildConfigVerifier>();
services.TryAddSingleton<SourceVerifier>();
services.TryAddSingleton<BuilderVerifier>();
services.TryAddSingleton<BuildInputIntegrityChecker>();
services.TryAddSingleton<ReproducibilityVerifier>();
services.TryAddSingleton<SlsaLevelEvaluator>();
services.TryAddSingleton<IBuildProvenancePolicyLoader, BuildProvenancePolicyLoader>();
return services;
}
}

View File

@@ -0,0 +1,151 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.BuildProvenance.Models;
public sealed record BuildProvenanceReport
{
public SlsaLevel AchievedLevel { get; init; } = SlsaLevel.None;
public ImmutableArray<ProvenanceFinding> Findings { get; init; } = [];
public BuildProvenanceChain ProvenanceChain { get; init; } = BuildProvenanceChain.Empty;
public ReproducibilityStatus ReproducibilityStatus { get; init; } = ReproducibilityStatus.Unknown;
public BuildProvenanceSummary Summary { get; init; } = BuildProvenanceSummary.Empty;
public DateTimeOffset GeneratedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public string? PolicyVersion { get; init; }
public BuildProvenanceAttestation? Attestation { get; init; }
}
public sealed record BuildProvenanceChain
{
public static BuildProvenanceChain Empty { get; } = new()
{
Environment = ImmutableDictionary<string, string>.Empty,
Inputs = [],
Outputs = []
};
public string? BuilderId { get; init; }
public string? SourceRepository { get; init; }
public string? SourceCommit { get; init; }
public string? BuildConfigUri { get; init; }
public string? BuildConfigDigest { get; init; }
public ImmutableDictionary<string, string> Environment { get; init; } =
ImmutableDictionary<string, string>.Empty;
public ImmutableArray<BuildInput> Inputs { get; init; } = [];
public ImmutableArray<BuildOutput> Outputs { get; init; } = [];
}
public sealed record BuildInput
{
public required string Reference { get; init; }
public string? Digest { get; init; }
public string? SourceUri { get; init; }
public string? Kind { get; init; }
}
public sealed record BuildOutput
{
public required string Reference { get; init; }
public string? Digest { get; init; }
public string? Kind { get; init; }
}
public sealed record BuildProvenanceAttestation
{
public string PredicateType { get; init; } = "https://slsa.dev/provenance/v1";
public SlsaLevel SlsaLevel { get; init; } = SlsaLevel.None;
public string? BuilderId { get; init; }
public string? SourceRepository { get; init; }
public string? SourceCommit { get; init; }
public DateTimeOffset GeneratedAtUtc { get; init; } = DateTimeOffset.UtcNow;
}
public sealed record ProvenanceFinding
{
public required BuildProvenanceFindingType Type { get; init; }
public required ProvenanceSeverity Severity { get; init; }
public required string Title { get; init; }
public required string Description { get; init; }
public string? Remediation { get; init; }
public string? Subject { get; init; }
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
public enum BuildProvenanceFindingType
{
MissingBuildProvenance,
UnverifiedBuilder,
UnsignedSource,
NonHermeticBuild,
MissingBuildConfig,
EnvironmentVariableLeak,
NonReproducibleBuild,
SlsaLevelInsufficient,
InputIntegrityFailed,
OutputMismatch
}
public enum ProvenanceSeverity
{
Unknown,
Low,
Medium,
High,
Critical
}
public enum SlsaLevel
{
None = 0,
Level1 = 1,
Level2 = 2,
Level3 = 3,
Level4 = 4
}
public sealed record ReproducibilityStatus
{
public static ReproducibilityStatus Unknown { get; } = new()
{
State = ReproducibilityState.Unknown,
Issues = []
};
public ReproducibilityState State { get; init; } = ReproducibilityState.Unknown;
public string? Backend { get; init; }
public string? Details { get; init; }
public ImmutableArray<ReproducibilityIssue> Issues { get; init; } = [];
}
public enum ReproducibilityState
{
Unknown,
NotRequested,
Skipped,
Reproducible,
NotReproducible,
Failed
}
public sealed record ReproducibilityIssue
{
public required string Code { get; init; }
public required string Description { get; init; }
public ProvenanceSeverity Severity { get; init; } = ProvenanceSeverity.Unknown;
}
public sealed record BuildProvenanceSummary
{
public static BuildProvenanceSummary Empty { get; } = new()
{
TotalFindings = 0,
FindingsBySeverity = ImmutableDictionary<ProvenanceSeverity, int>.Empty,
FindingsByType = ImmutableDictionary<BuildProvenanceFindingType, int>.Empty
};
public int TotalFindings { get; init; }
public ImmutableDictionary<ProvenanceSeverity, int> FindingsBySeverity { get; init; } =
ImmutableDictionary<ProvenanceSeverity, int>.Empty;
public ImmutableDictionary<BuildProvenanceFindingType, int> FindingsByType { get; init; } =
ImmutableDictionary<BuildProvenanceFindingType, int>.Empty;
}

View File

@@ -0,0 +1,77 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.BuildProvenance.Policy;
public sealed record BuildProvenancePolicy
{
public string? Version { get; init; }
public int MinimumSlsaLevel { get; init; } = 2;
public ImmutableArray<TrustedBuilder> TrustedBuilders { get; init; } = [];
public SourceRequirements SourceRequirements { get; init; } = new();
public BuildRequirements BuildRequirements { get; init; } = new();
public ReproducibilityRequirements Reproducibility { get; init; } = new();
public ImmutableArray<BuildProvenanceExemption> Exemptions { get; init; } = [];
}
public sealed record TrustedBuilder
{
public required string Id { get; init; }
public string? Name { get; init; }
public string? MinVersion { get; init; }
}
public sealed record SourceRequirements
{
public bool RequireSignedCommits { get; init; }
public bool RequireTaggedRelease { get; init; }
public ImmutableArray<string> AllowedRepositories { get; init; } = [];
}
public sealed record BuildRequirements
{
public bool RequireHermeticBuild { get; init; }
public bool RequireConfigDigest { get; init; }
public int MaxEnvironmentVariables { get; init; } = 50;
public ImmutableArray<string> ProhibitedEnvVarPatterns { get; init; } = [];
}
public sealed record ReproducibilityRequirements
{
public bool RequireReproducible { get; init; }
public bool VerifyOnDemand { get; init; } = true;
}
public sealed record BuildProvenanceExemption
{
public required string ComponentPattern { get; init; }
public string? Reason { get; init; }
public int? SlsaLevelOverride { get; init; }
}
public static class BuildProvenancePolicyDefaults
{
public static BuildProvenancePolicy Default { get; } = new()
{
MinimumSlsaLevel = 2,
TrustedBuilders = [],
SourceRequirements = new SourceRequirements
{
RequireSignedCommits = false,
RequireTaggedRelease = false,
AllowedRepositories = []
},
BuildRequirements = new BuildRequirements
{
RequireHermeticBuild = false,
RequireConfigDigest = false,
MaxEnvironmentVariables = 50,
ProhibitedEnvVarPatterns = ["*_KEY", "*_SECRET", "*_TOKEN"]
},
Reproducibility = new ReproducibilityRequirements
{
RequireReproducible = false,
VerifyOnDemand = true
},
Exemptions = []
};
}

View File

@@ -0,0 +1,100 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Scanner.BuildProvenance.Policy;
public interface IBuildProvenancePolicyLoader
{
Task<BuildProvenancePolicy> LoadAsync(string? path, CancellationToken ct = default);
}
public sealed class BuildProvenancePolicyLoader : IBuildProvenancePolicyLoader
{
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
private readonly IDeserializer _yamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
public async Task<BuildProvenancePolicy> LoadAsync(string? path, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return BuildProvenancePolicyDefaults.Default;
}
var extension = Path.GetExtension(path).ToLowerInvariant();
await using var stream = File.OpenRead(path);
return extension switch
{
".yaml" or ".yml" => LoadFromYaml(stream),
_ => await LoadFromJsonAsync(stream, ct).ConfigureAwait(false)
};
}
private BuildProvenancePolicy LoadFromYaml(Stream stream)
{
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var yamlObject = _yamlDeserializer.Deserialize(reader);
if (yamlObject is null)
{
return BuildProvenancePolicyDefaults.Default;
}
var payload = JsonSerializer.Serialize(yamlObject);
using var document = JsonDocument.Parse(payload);
return ExtractPolicy(document.RootElement);
}
private static async Task<BuildProvenancePolicy> LoadFromJsonAsync(Stream stream, CancellationToken ct)
{
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct)
.ConfigureAwait(false);
return ExtractPolicy(document.RootElement);
}
private static BuildProvenancePolicy ExtractPolicy(JsonElement root)
{
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty("buildProvenancePolicy", out var policyElement))
{
return JsonSerializer.Deserialize<BuildProvenancePolicy>(policyElement, JsonOptions)
?? BuildProvenancePolicyDefaults.Default;
}
return JsonSerializer.Deserialize<BuildProvenancePolicy>(root, JsonOptions)
?? BuildProvenancePolicyDefaults.Default;
}
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
options.Converters.Add(new FlexibleBooleanConverter());
return options;
}
private sealed class FlexibleBooleanConverter : JsonConverter<bool>
{
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.True => true,
JsonTokenType.False => false,
JsonTokenType.String when bool.TryParse(reader.GetString(), out var value) => value,
_ => throw new JsonException($"Expected boolean value or boolean string, got {reader.TokenType}.")
};
}
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
{
writer.WriteBooleanValue(value);
}
}
}

View File

@@ -0,0 +1,231 @@
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.BuildProvenance.Models;
using StellaOps.Scanner.Sarif;
using ProvenanceSeverity = StellaOps.Scanner.BuildProvenance.Models.ProvenanceSeverity;
using SarifSeverity = StellaOps.Scanner.Sarif.Severity;
namespace StellaOps.Scanner.BuildProvenance.Reporting;
public static class BuildProvenanceReportFormatter
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
public static byte[] ToJsonBytes(BuildProvenanceReport report)
{
return JsonSerializer.SerializeToUtf8Bytes(report, JsonOptions);
}
public static byte[] ToInTotoPredicateBytes(BuildProvenanceReport report)
{
var predicate = new
{
predicateType = "https://slsa.dev/provenance/v1",
slsaLevel = report.AchievedLevel.ToString(),
builder = new
{
id = report.ProvenanceChain.BuilderId
},
source = new
{
repository = report.ProvenanceChain.SourceRepository,
commit = report.ProvenanceChain.SourceCommit
},
buildConfig = new
{
uri = report.ProvenanceChain.BuildConfigUri,
digest = report.ProvenanceChain.BuildConfigDigest
},
materials = report.ProvenanceChain.Inputs.Select(input => new
{
uri = input.SourceUri ?? input.Reference,
digest = input.Digest,
kind = input.Kind
})
};
return JsonSerializer.SerializeToUtf8Bytes(predicate, JsonOptions);
}
public static string ToText(BuildProvenanceReport report)
{
var builder = new StringBuilder();
builder.AppendLine("Build Provenance Report");
builder.AppendLine($"SLSA Level: {report.AchievedLevel}");
builder.AppendLine($"Findings: {report.Summary.TotalFindings}");
builder.AppendLine($"Reproducibility: {report.ReproducibilityStatus.State}");
foreach (var severityGroup in report.Summary.FindingsBySeverity.OrderByDescending(kvp => kvp.Key))
{
builder.AppendLine($" {severityGroup.Key}: {severityGroup.Value}");
}
builder.AppendLine();
foreach (var finding in report.Findings)
{
builder.AppendLine($"- [{finding.Severity}] {finding.Title}");
if (!string.IsNullOrWhiteSpace(finding.Description))
{
builder.AppendLine($" {finding.Description}");
}
if (!string.IsNullOrWhiteSpace(finding.Remediation))
{
builder.AppendLine($" Remediation: {finding.Remediation}");
}
}
return builder.ToString();
}
public static byte[] ToPdfBytes(BuildProvenanceReport report)
{
return SimplePdfBuilder.Build(ToText(report));
}
}
public sealed class BuildProvenanceSarifExporter
{
private readonly ISarifExportService _sarifExporter;
public BuildProvenanceSarifExporter(ISarifExportService sarifExporter)
{
_sarifExporter = sarifExporter ?? throw new ArgumentNullException(nameof(sarifExporter));
}
public async Task<object?> ExportAsync(BuildProvenanceReport report, CancellationToken ct = default)
{
if (report.Findings.IsDefaultOrEmpty)
{
return null;
}
var inputs = report.Findings.Select(MapToFindingInput).ToList();
var options = new SarifExportOptions
{
ToolName = "StellaOps Scanner",
ToolVersion = "1.0.0",
Category = "build-provenance",
IncludeEvidenceUris = false,
IncludeReachability = false,
IncludeVexStatus = false
};
return await _sarifExporter.ExportAsync(inputs, options, ct).ConfigureAwait(false);
}
private static FindingInput MapToFindingInput(ProvenanceFinding finding)
{
return new FindingInput
{
Type = FindingType.Configuration,
VulnerabilityId = finding.Type.ToString(),
ComponentName = finding.Subject,
Severity = MapSeverity(finding.Severity),
Title = finding.Title,
Description = finding.Description,
Recommendation = finding.Remediation,
Properties = new Dictionary<string, object>
{
["findingType"] = finding.Type.ToString(),
["subject"] = finding.Subject ?? string.Empty
}
};
}
private static SarifSeverity MapSeverity(ProvenanceSeverity severity)
{
return severity switch
{
ProvenanceSeverity.Critical => SarifSeverity.Critical,
ProvenanceSeverity.High => SarifSeverity.High,
ProvenanceSeverity.Medium => SarifSeverity.Medium,
ProvenanceSeverity.Low => SarifSeverity.Low,
_ => SarifSeverity.Unknown
};
}
}
internal static class SimplePdfBuilder
{
public static byte[] Build(string text)
{
var lines = text.Replace("\r", string.Empty).Split('\n');
var contentStream = BuildContentStream(lines);
var objects = new List<string>
{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
$"<< /Length {contentStream.Length} >>\nstream\n{contentStream}\nendstream",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
};
using var stream = new MemoryStream();
WriteLine(stream, "%PDF-1.4");
var offsets = new List<long> { 0 };
for (var i = 0; i < objects.Count; i++)
{
offsets.Add(stream.Position);
WriteLine(stream, $"{i + 1} 0 obj");
WriteLine(stream, objects[i]);
WriteLine(stream, "endobj");
}
var xrefStart = stream.Position;
WriteLine(stream, "xref");
WriteLine(stream, $"0 {objects.Count + 1}");
WriteLine(stream, "0000000000 65535 f ");
for (var i = 1; i < offsets.Count; i++)
{
WriteLine(stream, $"{offsets[i]:0000000000} 00000 n ");
}
WriteLine(stream, "trailer");
WriteLine(stream, $"<< /Size {objects.Count + 1} /Root 1 0 R >>");
WriteLine(stream, "startxref");
WriteLine(stream, xrefStart.ToString());
WriteLine(stream, "%%EOF");
return stream.ToArray();
}
private static string BuildContentStream(IEnumerable<string> lines)
{
var builder = new StringBuilder();
builder.AppendLine("BT");
builder.AppendLine("/F1 10 Tf");
var y = 760;
foreach (var line in lines)
{
var escaped = EscapeText(line);
builder.AppendLine($"72 {y} Td ({escaped}) Tj");
y -= 14;
if (y < 60)
{
break;
}
}
builder.AppendLine("ET");
return builder.ToString();
}
private static string EscapeText(string value)
{
return value.Replace("\\", "\\\\")
.Replace("(", "\\(")
.Replace(")", "\\)");
}
private static void WriteLine(Stream stream, string line)
{
var bytes = Encoding.ASCII.GetBytes(line + "\n");
stream.Write(bytes, 0, bytes.Length);
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.GroundTruth.Reproducible/StellaOps.BinaryIndex.GroundTruth.Reproducible.csproj" />
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.SbomIntegration/StellaOps.Concelier.SbomIntegration.csproj" />
<ProjectReference Include="../StellaOps.Scanner.Sarif/StellaOps.Scanner.Sarif.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
</Project>

View File

@@ -31,6 +31,9 @@ public static class ScanAnalysisKeys
public const string ReachabilityUnionGraph = "analysis.reachability.union.graph";
public const string ReachabilityUnionCas = "analysis.reachability.union.cas";
public const string ReachabilityRichGraphCas = "analysis.reachability.richgraph.cas";
public const string DependencyReachabilityReport = "analysis.reachability.dependency.report";
public const string DependencyReachabilitySarif = "analysis.reachability.dependency.sarif";
public const string DependencyReachabilityDot = "analysis.reachability.dependency.dot";
public const string FileEntries = "analysis.files.entries";
public const string EntropyReport = "analysis.entropy.report";
@@ -60,4 +63,20 @@ public static class ScanAnalysisKeys
public const string VexGateSummary = "analysis.vexgate.summary";
public const string VexGatePolicyVersion = "analysis.vexgate.policy.version";
public const string VexGateBypassed = "analysis.vexgate.bypassed";
// Sprint: SPRINT_20260119_016 - Service security analysis
public const string ServiceSecurityReport = "analysis.service.security.report";
public const string ServiceSecurityPolicyVersion = "analysis.service.security.policy.version";
// Sprint: SPRINT_20260119_017 - CBOM crypto analysis
public const string CryptoAnalysisReport = "analysis.crypto.report";
public const string CryptoPolicyVersion = "analysis.crypto.policy.version";
// Sprint: SPRINT_20260119_018 - AI/ML supply chain security
public const string AiMlSecurityReport = "analysis.ai-ml.report";
public const string AiMlPolicyVersion = "analysis.ai-ml.policy.version";
// Sprint: SPRINT_20260119_019 - Build provenance verification
public const string BuildProvenanceReport = "analysis.build.provenance.report";
public const string BuildProvenancePolicyVersion = "analysis.build.provenance.policy.version";
}

View File

@@ -9,4 +9,7 @@ public static class ScanMetadataKeys
public const string LayerArchives = "scanner.layer.archives";
public const string RuntimeProcRoot = "scanner.runtime.proc_root";
public const string CurrentLayerDigest = "scanner.layer.current.digest";
public const string SbomPath = "sbom.path";
public const string SbomFormat = "sbom.format";
public const string ReachabilityCallGraphPath = "reachability.callgraph.path";
}

View File

@@ -0,0 +1,206 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.CryptoAnalysis.Models;
namespace StellaOps.Scanner.CryptoAnalysis.Analyzers;
public sealed class AlgorithmStrengthAnalyzer : ICryptoCheck
{
public Task<CryptoAnalysisResult> AnalyzeAsync(
CryptoAnalysisContext context,
CancellationToken ct = default)
{
var findings = new List<CryptoFinding>();
foreach (var component in context.Components)
{
ct.ThrowIfCancellationRequested();
var crypto = component.CryptoProperties;
if (crypto is null || crypto.AssetType != CryptoAssetType.Algorithm)
{
continue;
}
var algorithm = CryptoAlgorithmCatalog.ResolveAlgorithmName(component);
if (string.IsNullOrWhiteSpace(algorithm))
{
continue;
}
if (context.IsExempted(component, algorithm))
{
continue;
}
var strength = ClassifyStrength(algorithm, crypto);
if (strength is AlgorithmStrength.Broken or AlgorithmStrength.Weak or AlgorithmStrength.Legacy)
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.WeakAlgorithm,
Severity = MapStrengthSeverity(strength),
Title = $"Weak cryptographic algorithm detected ({algorithm})",
Description = $"Component {component.Name ?? component.BomRef} uses {algorithm}, which is classified as {strength.ToString().ToLowerInvariant()}.",
Remediation = "Replace with a modern, approved algorithm (AES-GCM, SHA-256+, or post-quantum where required).",
Algorithm = algorithm,
Metadata = BuildMetadata(crypto)
});
}
if (IsProhibited(context, algorithm))
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.WeakAlgorithm,
Severity = Severity.High,
Title = $"Prohibited algorithm detected ({algorithm})",
Description = $"Policy prohibits {algorithm} but it appears in component {component.Name ?? component.BomRef}.",
Remediation = "Remove the prohibited algorithm or add a scoped exemption with expiration.",
Algorithm = algorithm,
Metadata = BuildMetadata(crypto)
});
}
var keySize = crypto.AlgorithmProperties?.KeySize;
var family = CryptoAlgorithmCatalog.GetAlgorithmFamily(algorithm);
if (keySize.HasValue && !string.IsNullOrWhiteSpace(family))
{
if (context.Policy.MinimumKeyLengths.TryGetValue(family, out var minimum)
&& keySize.Value < minimum)
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.ShortKeyLength,
Severity = Severity.High,
Title = $"Key length below policy minimum ({algorithm})",
Description = $"{algorithm} key length {keySize} is below required minimum {minimum}.",
Remediation = "Rotate keys to meet policy minimum key length.",
Algorithm = algorithm,
Metadata = BuildMetadata(crypto, ("keySize", keySize.Value.ToString()), ("minimumKeySize", minimum.ToString()))
});
}
}
if (crypto.AlgorithmProperties?.Mode == CryptoMode.Ecb)
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.InsecureMode,
Severity = Severity.High,
Title = $"Insecure cipher mode detected ({algorithm})",
Description = "ECB mode does not provide semantic security and should be avoided.",
Remediation = "Use GCM or CTR mode with authenticated encryption.",
Algorithm = algorithm,
Metadata = BuildMetadata(crypto, ("mode", "ECB"))
});
}
if (context.Policy.RequiredFeatures.AuthenticatedEncryption
&& crypto.AlgorithmProperties?.CryptoFunctions is { Length: > 0 } functions
&& functions.Any(f => f.Contains("encrypt", StringComparison.OrdinalIgnoreCase))
&& !functions.Any(f => f.Contains("mac", StringComparison.OrdinalIgnoreCase)
|| f.Contains("auth", StringComparison.OrdinalIgnoreCase)
|| f.Contains("integrity", StringComparison.OrdinalIgnoreCase)))
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.MissingIntegrity,
Severity = Severity.Medium,
Title = $"Missing integrity protection ({algorithm})",
Description = "Encryption functions were declared without authenticated integrity protection.",
Remediation = "Ensure authenticated encryption (e.g., AES-GCM) or add MAC/HMAC coverage.",
Algorithm = algorithm,
Metadata = BuildMetadata(crypto)
});
}
}
return Task.FromResult(new CryptoAnalysisResult
{
Findings = findings.ToImmutableArray()
});
}
private static AlgorithmStrength ClassifyStrength(string algorithm, ParsedCryptoProperties properties)
{
if (CryptoAlgorithmCatalog.IsPostQuantum(algorithm))
{
return AlgorithmStrength.PostQuantum;
}
if (CryptoAlgorithmCatalog.IsWeakAlgorithm(algorithm))
{
return AlgorithmStrength.Weak;
}
if (!string.IsNullOrWhiteSpace(properties.AlgorithmProperties?.Curve)
&& properties.AlgorithmProperties?.Curve?.Contains("secp256", StringComparison.OrdinalIgnoreCase) == true)
{
return AlgorithmStrength.Strong;
}
return AlgorithmStrength.Acceptable;
}
private static Severity MapStrengthSeverity(AlgorithmStrength strength)
{
return strength switch
{
AlgorithmStrength.Broken => Severity.Critical,
AlgorithmStrength.Weak => Severity.High,
AlgorithmStrength.Legacy => Severity.Medium,
AlgorithmStrength.Acceptable => Severity.Low,
AlgorithmStrength.Strong => Severity.Low,
AlgorithmStrength.PostQuantum => Severity.Low,
_ => Severity.Unknown
};
}
private static bool IsProhibited(CryptoAnalysisContext context, string algorithm)
{
if (context.Policy.ProhibitedAlgorithms.IsDefaultOrEmpty)
{
return false;
}
return context.Policy.ProhibitedAlgorithms.Any(entry =>
entry.Equals(algorithm, StringComparison.OrdinalIgnoreCase)
|| algorithm.Contains(entry, StringComparison.OrdinalIgnoreCase));
}
private static ImmutableDictionary<string, string> BuildMetadata(
ParsedCryptoProperties properties,
params (string Key, string Value)[] additions)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(properties.Oid))
{
metadata["oid"] = properties.Oid!;
}
foreach (var (key, value) in additions)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
{
continue;
}
metadata[key] = value;
}
return metadata.Count == 0
? ImmutableDictionary<string, string>.Empty
: metadata.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,122 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.CryptoAnalysis.Models;
namespace StellaOps.Scanner.CryptoAnalysis.Analyzers;
public sealed class CertificateAnalyzer : ICryptoCheck
{
public Task<CryptoAnalysisResult> AnalyzeAsync(
CryptoAnalysisContext context,
CancellationToken ct = default)
{
var findings = new List<CryptoFinding>();
var warningDays = context.Policy.Certificates.ExpirationWarningDays;
foreach (var component in context.Components)
{
ct.ThrowIfCancellationRequested();
var crypto = component.CryptoProperties;
if (crypto is null || crypto.AssetType != CryptoAssetType.Certificate)
{
continue;
}
var certificate = crypto.CertificateProperties;
if (certificate is null)
{
continue;
}
if (certificate.NotValidAfter is { } notAfter)
{
var daysRemaining = (notAfter - context.NowUtc).TotalDays;
if (daysRemaining < 0)
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.ExpiredCertificate,
Severity = Severity.High,
Title = "Expired certificate detected",
Description = $"Certificate for {component.Name ?? component.BomRef} expired on {notAfter:O}.",
Remediation = "Rotate the certificate and update the CBOM metadata.",
Certificate = certificate.SubjectName,
Metadata = BuildMetadata(certificate, ("daysRemaining", daysRemaining.ToString("0")))
});
}
else if (daysRemaining <= warningDays)
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.ExpiredCertificate,
Severity = Severity.Medium,
Title = "Certificate nearing expiration",
Description = $"Certificate for {component.Name ?? component.BomRef} expires on {notAfter:O}.",
Remediation = "Schedule rotation before expiry window closes.",
Certificate = certificate.SubjectName,
Metadata = BuildMetadata(certificate, ("daysRemaining", daysRemaining.ToString("0")))
});
}
}
var signatureAlgorithm = certificate.SignatureAlgorithmRef ?? component.Name;
if (!string.IsNullOrWhiteSpace(signatureAlgorithm)
&& CryptoAlgorithmCatalog.IsWeakAlgorithm(signatureAlgorithm))
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.WeakAlgorithm,
Severity = Severity.High,
Title = "Weak certificate signature algorithm",
Description = $"Certificate uses {signatureAlgorithm}, which is considered weak.",
Remediation = "Use SHA-256+ with RSA/ECDSA or regional approved algorithms.",
Certificate = certificate.SubjectName,
Algorithm = signatureAlgorithm,
Metadata = BuildMetadata(certificate)
});
}
}
return Task.FromResult(new CryptoAnalysisResult
{
Findings = findings.ToImmutableArray()
});
}
private static ImmutableDictionary<string, string> BuildMetadata(
ParsedCertificateProperties certificate,
params (string Key, string Value)[] additions)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(certificate.SubjectName))
{
metadata["subject"] = certificate.SubjectName!;
}
if (!string.IsNullOrWhiteSpace(certificate.IssuerName))
{
metadata["issuer"] = certificate.IssuerName!;
}
foreach (var (key, value) in additions)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
{
continue;
}
metadata[key] = value;
}
return metadata.Count == 0
? ImmutableDictionary<string, string>.Empty
: metadata.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,261 @@
using System.Linq;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Scanner.CryptoAnalysis.Analyzers;
public static class CryptoAlgorithmCatalog
{
private static readonly Dictionary<string, string> OidToAlgorithm = new(StringComparer.OrdinalIgnoreCase)
{
["1.2.840.113549.1.1.1"] = "RSA",
["1.2.840.113549.1.1.11"] = "SHA256withRSA",
["1.2.840.10045.4.3.2"] = "ECDSA-SHA256",
["1.2.840.10045.4.3.3"] = "ECDSA-SHA384",
["1.2.840.10045.4.3.4"] = "ECDSA-SHA512",
["2.16.840.1.101.3.4.2.1"] = "SHA256",
["2.16.840.1.101.3.4.2.2"] = "SHA384",
["2.16.840.1.101.3.4.2.3"] = "SHA512",
["1.2.643.7.1.1.1.1"] = "GOST3410-2012-256",
["1.2.643.7.1.1.1.2"] = "GOST3410-2012-512",
["1.2.643.7.1.1.2.2"] = "GOST3411-2012-256",
["1.2.643.7.1.1.2.3"] = "GOST3411-2012-512",
["1.2.156.10197.1.301"] = "SM2",
["1.2.156.10197.1.401"] = "SM3",
["1.2.156.10197.1.104.1"] = "SM4"
};
private static readonly HashSet<string> WeakAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
"MD2",
"MD4",
"MD5",
"SHA1",
"SHA-1",
"DES",
"3DES",
"TRIPLEDES",
"RC2",
"RC4",
"BLOWFISH"
};
private static readonly HashSet<string> QuantumVulnerableAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
"RSA",
"DSA",
"DH",
"DIFFIE-HELLMAN",
"ECDSA",
"ECDH",
"ECC",
"ED25519",
"ED448",
"EDDSA"
};
private static readonly HashSet<string> PostQuantumAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
"KYBER",
"DILITHIUM",
"SPHINCS+",
"SPHINCS",
"FALCON",
"CLASSICMCELIECE"
};
private static readonly HashSet<string> FipsApprovedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
"AES",
"HMAC",
"SHA256",
"SHA384",
"SHA512",
"RSA",
"ECDSA",
"ECDH",
"HKDF",
"PBKDF2",
"CTR",
"GCM",
"CBC",
"OAEP",
"PKCS1"
};
private static readonly HashSet<string> EidasAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
"RSA",
"ECDSA",
"SHA256",
"SHA384",
"SHA512"
};
private static readonly HashSet<string> GostAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
"GOST",
"GOST3410",
"GOST3411",
"GOST28147",
"GOST3410-2012-256",
"GOST3410-2012-512",
"GOST3411-2012-256",
"GOST3411-2012-512"
};
private static readonly HashSet<string> SmAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
"SM2",
"SM3",
"SM4"
};
public static string? ResolveAlgorithmName(ParsedComponent component)
{
if (!string.IsNullOrWhiteSpace(component.Name))
{
return component.Name.Trim();
}
var crypto = component.CryptoProperties;
if (crypto is null)
{
return null;
}
var byOid = MapOidToAlgorithm(crypto.Oid);
if (!string.IsNullOrWhiteSpace(byOid))
{
return byOid;
}
var parameters = crypto.AlgorithmProperties?.ParameterSetIdentifier;
if (!string.IsNullOrWhiteSpace(parameters))
{
return parameters.Trim();
}
var curve = crypto.AlgorithmProperties?.Curve;
if (!string.IsNullOrWhiteSpace(curve))
{
return curve.Trim();
}
return null;
}
public static string? MapOidToAlgorithm(string? oid)
{
if (string.IsNullOrWhiteSpace(oid))
{
return null;
}
return OidToAlgorithm.TryGetValue(oid.Trim(), out var algorithm)
? algorithm
: null;
}
public static string Normalize(string? algorithm)
{
return string.IsNullOrWhiteSpace(algorithm)
? string.Empty
: algorithm.Trim().ToUpperInvariant();
}
public static bool IsWeakAlgorithm(string algorithm)
{
var normalized = Normalize(algorithm);
if (WeakAlgorithms.Contains(normalized))
{
return true;
}
return WeakAlgorithms.Any(entry => normalized.Contains(entry, StringComparison.OrdinalIgnoreCase));
}
public static bool IsPostQuantum(string algorithm)
{
var normalized = Normalize(algorithm);
return PostQuantumAlgorithms.Any(entry => normalized.Contains(entry, StringComparison.OrdinalIgnoreCase));
}
public static bool IsQuantumVulnerable(string algorithm)
{
var normalized = Normalize(algorithm);
return QuantumVulnerableAlgorithms.Any(entry => normalized.Contains(entry, StringComparison.OrdinalIgnoreCase));
}
public static bool IsFipsApproved(string algorithm)
{
var normalized = Normalize(algorithm);
if (FipsApprovedAlgorithms.Contains(normalized))
{
return true;
}
return FipsApprovedAlgorithms.Any(entry => normalized.Contains(entry, StringComparison.OrdinalIgnoreCase));
}
public static bool IsEidasAlgorithm(string algorithm)
{
var normalized = Normalize(algorithm);
return EidasAlgorithms.Any(entry => normalized.Contains(entry, StringComparison.OrdinalIgnoreCase));
}
public static bool IsGostAlgorithm(string algorithm)
{
var normalized = Normalize(algorithm);
return GostAlgorithms.Any(entry => normalized.Contains(entry, StringComparison.OrdinalIgnoreCase));
}
public static bool IsSmAlgorithm(string algorithm)
{
var normalized = Normalize(algorithm);
return SmAlgorithms.Any(entry => normalized.Contains(entry, StringComparison.OrdinalIgnoreCase));
}
public static string? GetAlgorithmFamily(string algorithm)
{
var normalized = Normalize(algorithm);
if (normalized.Contains("RSA", StringComparison.OrdinalIgnoreCase))
{
return "RSA";
}
if (normalized.Contains("ECDSA", StringComparison.OrdinalIgnoreCase))
{
return "ECDSA";
}
if (normalized.Contains("ECDH", StringComparison.OrdinalIgnoreCase))
{
return "ECDH";
}
if (normalized.Contains("ED25519", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("ED448", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("EDDSA", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("ECC", StringComparison.OrdinalIgnoreCase))
{
return "ECC";
}
if (normalized.Contains("DSA", StringComparison.OrdinalIgnoreCase))
{
return "DSA";
}
if (normalized.Contains("DH", StringComparison.OrdinalIgnoreCase))
{
return "DH";
}
if (normalized.Contains("AES", StringComparison.OrdinalIgnoreCase))
{
return "AES";
}
return null;
}
}

View File

@@ -0,0 +1,117 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text.RegularExpressions;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.CryptoAnalysis.Policy;
namespace StellaOps.Scanner.CryptoAnalysis.Analyzers;
public sealed class CryptoAnalysisContext
{
private readonly RegexCache _regexCache = new();
private CryptoAnalysisContext(
CryptoPolicy policy,
ImmutableArray<ParsedComponent> components,
DateTimeOffset nowUtc)
{
Policy = policy;
Components = components;
NowUtc = nowUtc;
}
public CryptoPolicy Policy { get; }
public ImmutableArray<ParsedComponent> Components { get; }
public DateTimeOffset NowUtc { get; }
public static CryptoAnalysisContext Create(
IReadOnlyList<ParsedComponent> components,
CryptoPolicy policy,
TimeProvider timeProvider)
{
var sorted = (components ?? Array.Empty<ParsedComponent>())
.Where(component => component.CryptoProperties is not null)
.OrderBy(component => component.BomRef, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var now = timeProvider.GetUtcNow();
return new CryptoAnalysisContext(policy, sorted, now);
}
public bool IsExempted(ParsedComponent component, string? algorithm)
{
if (Policy.Exemptions.IsDefaultOrEmpty || string.IsNullOrWhiteSpace(algorithm))
{
return false;
}
foreach (var exemption in Policy.Exemptions)
{
if (IsExemptionExpired(exemption))
{
continue;
}
if (!MatchesPattern(component.Name, exemption.ComponentPattern)
&& !MatchesPattern(component.BomRef, exemption.ComponentPattern))
{
continue;
}
if (exemption.Algorithms.IsDefaultOrEmpty)
{
return true;
}
if (exemption.Algorithms.Any(entry => entry.Equals(algorithm, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
return false;
}
private bool IsExemptionExpired(CryptoPolicyExemption exemption)
{
if (exemption.ExpirationDate is null)
{
return false;
}
return exemption.ExpirationDate.Value < NowUtc;
}
private bool MatchesPattern(string? value, string? pattern)
{
if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(pattern))
{
return false;
}
var regex = _regexCache.Get(pattern);
return regex.IsMatch(value);
}
private sealed class RegexCache
{
private readonly Dictionary<string, Regex> _cache = new(StringComparer.OrdinalIgnoreCase);
public Regex Get(string pattern)
{
if (_cache.TryGetValue(pattern, out var cached))
{
return cached;
}
var regexPattern = "^" + Regex.Escape(pattern)
.Replace("\\*", ".*")
.Replace("\\?", ".")
+ "$";
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
_cache[pattern] = regex;
return regex;
}
}
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Immutable;
using StellaOps.Scanner.CryptoAnalysis.Models;
namespace StellaOps.Scanner.CryptoAnalysis.Analyzers;
public sealed record CryptoAnalysisResult
{
public static CryptoAnalysisResult Empty { get; } = new();
public ImmutableArray<CryptoFinding> Findings { get; init; } = [];
public CryptoInventory? Inventory { get; init; }
public PostQuantumReadiness? QuantumReadiness { get; init; }
}

View File

@@ -0,0 +1,124 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.CryptoAnalysis.Models;
namespace StellaOps.Scanner.CryptoAnalysis.Analyzers;
public sealed class CryptoInventoryGenerator : ICryptoCheck
{
public Task<CryptoAnalysisResult> AnalyzeAsync(
CryptoAnalysisContext context,
CancellationToken ct = default)
{
var algorithms = new List<CryptoAlgorithmUsage>();
var certificates = new List<CryptoCertificateUsage>();
var protocols = new List<CryptoProtocolUsage>();
var keyMaterials = new List<CryptoKeyMaterial>();
foreach (var component in context.Components)
{
ct.ThrowIfCancellationRequested();
var crypto = component.CryptoProperties;
if (crypto is null)
{
continue;
}
switch (crypto.AssetType)
{
case CryptoAssetType.Algorithm:
{
var properties = crypto.AlgorithmProperties;
algorithms.Add(new CryptoAlgorithmUsage
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Algorithm = CryptoAlgorithmCatalog.ResolveAlgorithmName(component),
AlgorithmIdentifier = crypto.Oid,
Primitive = properties?.Primitive?.ToString(),
Mode = properties?.Mode?.ToString(),
Padding = properties?.Padding?.ToString(),
KeySize = properties?.KeySize,
Curve = properties?.Curve,
ExecutionEnvironment = properties?.ExecutionEnvironment?.ToString(),
CertificationLevel = properties?.CertificationLevel?.ToString(),
CryptoFunctions = properties?.CryptoFunctions ?? []
});
break;
}
case CryptoAssetType.Certificate:
{
var properties = crypto.CertificateProperties;
certificates.Add(new CryptoCertificateUsage
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
SubjectName = properties?.SubjectName,
IssuerName = properties?.IssuerName,
NotValidBefore = properties?.NotValidBefore,
NotValidAfter = properties?.NotValidAfter,
SignatureAlgorithmRef = properties?.SignatureAlgorithmRef,
SubjectPublicKeyRef = properties?.SubjectPublicKeyRef,
CertificateFormat = properties?.CertificateFormat,
CertificateExtension = properties?.CertificateExtension
});
break;
}
case CryptoAssetType.Protocol:
{
var properties = crypto.ProtocolProperties;
protocols.Add(new CryptoProtocolUsage
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = properties?.Type,
Version = properties?.Version,
CipherSuites = properties?.CipherSuites ?? [],
IkeV2TransformTypes = properties?.IkeV2TransformTypes ?? [],
CryptoRefArray = properties?.CryptoRefArray ?? []
});
break;
}
case CryptoAssetType.RelatedCryptoMaterial:
{
var properties = crypto.RelatedCryptoMaterial;
keyMaterials.Add(new CryptoKeyMaterial
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = properties?.Type,
Reference = properties?.Reference,
MaterialRefs = properties?.MaterialRefs ?? []
});
break;
}
}
}
var inventory = new CryptoInventory
{
Algorithms = algorithms
.OrderBy(entry => entry.ComponentBomRef, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.Algorithm ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray(),
Certificates = certificates
.OrderBy(entry => entry.ComponentBomRef, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.SubjectName ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray(),
Protocols = protocols
.OrderBy(entry => entry.ComponentBomRef, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.Type ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray(),
KeyMaterials = keyMaterials
.OrderBy(entry => entry.ComponentBomRef, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.Type ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray()
};
return Task.FromResult(new CryptoAnalysisResult
{
Inventory = inventory
});
}
}

View File

@@ -0,0 +1,146 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.CryptoAnalysis.Models;
namespace StellaOps.Scanner.CryptoAnalysis.Analyzers;
public sealed class FipsComplianceChecker : ICryptoCheck
{
public Task<CryptoAnalysisResult> AnalyzeAsync(
CryptoAnalysisContext context,
CancellationToken ct = default)
{
if (!RequiresFips(context.Policy))
{
return Task.FromResult(CryptoAnalysisResult.Empty);
}
var findings = new List<CryptoFinding>();
foreach (var component in context.Components)
{
ct.ThrowIfCancellationRequested();
var crypto = component.CryptoProperties;
if (crypto is null || crypto.AssetType != CryptoAssetType.Algorithm)
{
continue;
}
var algorithm = CryptoAlgorithmCatalog.ResolveAlgorithmName(component);
if (string.IsNullOrWhiteSpace(algorithm))
{
continue;
}
if (context.IsExempted(component, algorithm))
{
continue;
}
if (!IsApprovedAlgorithm(context.Policy, algorithm))
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.NonFipsCompliant,
Severity = Severity.High,
Title = $"Non-FIPS algorithm detected ({algorithm})",
Description = $"Component {component.Name ?? component.BomRef} uses {algorithm}, which is not approved by FIPS policy.",
Remediation = "Replace with an approved FIPS algorithm or apply an approved exemption.",
Algorithm = algorithm,
Metadata = BuildMetadata(crypto)
});
}
var mode = crypto.AlgorithmProperties?.Mode;
if (mode == CryptoMode.Ecb)
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.InsecureMode,
Severity = Severity.High,
Title = $"Non-FIPS cipher mode detected ({algorithm})",
Description = "ECB mode is not permitted under FIPS guidance for confidentiality.",
Remediation = "Use GCM, CTR, or CBC with appropriate padding.",
Algorithm = algorithm,
Metadata = BuildMetadata(crypto, ("mode", "ECB"))
});
}
if (crypto.AlgorithmProperties?.Padding == CryptoPadding.None
&& !string.IsNullOrWhiteSpace(algorithm)
&& algorithm.Contains("RSA", StringComparison.OrdinalIgnoreCase))
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.InsecureMode,
Severity = Severity.High,
Title = "Missing padding on RSA operation",
Description = "RSA operations without padding are non-compliant and insecure.",
Remediation = "Use OAEP or PKCS1 padding under FIPS-approved profiles.",
Algorithm = algorithm,
Metadata = BuildMetadata(crypto, ("padding", "None"))
});
}
}
return Task.FromResult(new CryptoAnalysisResult
{
Findings = findings.ToImmutableArray()
});
}
private static bool RequiresFips(Policy.CryptoPolicy policy)
{
if (!policy.ComplianceFrameworks.IsDefaultOrEmpty
&& policy.ComplianceFrameworks.Any(framework => framework.Contains("FIPS", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
return !string.IsNullOrWhiteSpace(policy.ComplianceFramework)
&& policy.ComplianceFramework.Contains("FIPS", StringComparison.OrdinalIgnoreCase);
}
private static bool IsApprovedAlgorithm(Policy.CryptoPolicy policy, string algorithm)
{
if (!policy.ApprovedAlgorithms.IsDefaultOrEmpty)
{
return policy.ApprovedAlgorithms.Any(entry =>
entry.Equals(algorithm, StringComparison.OrdinalIgnoreCase)
|| algorithm.Contains(entry, StringComparison.OrdinalIgnoreCase));
}
return CryptoAlgorithmCatalog.IsFipsApproved(algorithm);
}
private static ImmutableDictionary<string, string> BuildMetadata(
ParsedCryptoProperties properties,
params (string Key, string Value)[] additions)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(properties.Oid))
{
metadata["oid"] = properties.Oid!;
}
foreach (var (key, value) in additions)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
{
continue;
}
metadata[key] = value;
}
return metadata.Count == 0
? ImmutableDictionary<string, string>.Empty
: metadata.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,144 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.CryptoAnalysis.Models;
namespace StellaOps.Scanner.CryptoAnalysis.Analyzers;
public sealed class PostQuantumAnalyzer : ICryptoCheck
{
public Task<CryptoAnalysisResult> AnalyzeAsync(
CryptoAnalysisContext context,
CancellationToken ct = default)
{
if (!context.Policy.PostQuantum.Enabled)
{
return Task.FromResult(CryptoAnalysisResult.Empty);
}
var findings = new List<CryptoFinding>();
var totalAlgorithms = 0;
var pqcAlgorithms = 0;
var hybridAlgorithms = 0;
var vulnerableAlgorithms = 0;
foreach (var component in context.Components)
{
ct.ThrowIfCancellationRequested();
var crypto = component.CryptoProperties;
if (crypto is null || crypto.AssetType != CryptoAssetType.Algorithm)
{
continue;
}
var algorithm = CryptoAlgorithmCatalog.ResolveAlgorithmName(component);
if (string.IsNullOrWhiteSpace(algorithm))
{
continue;
}
totalAlgorithms++;
if (algorithm.Contains("hybrid", StringComparison.OrdinalIgnoreCase))
{
hybridAlgorithms++;
}
if (CryptoAlgorithmCatalog.IsPostQuantum(algorithm))
{
pqcAlgorithms++;
continue;
}
if (!CryptoAlgorithmCatalog.IsQuantumVulnerable(algorithm))
{
continue;
}
vulnerableAlgorithms++;
var severity = context.Policy.PostQuantum.RequireHybridForLongLived
? Severity.High
: Severity.Medium;
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.QuantumVulnerable,
Severity = severity,
Title = $"Quantum-vulnerable algorithm detected ({algorithm})",
Description = $"{algorithm} is vulnerable to future quantum attacks and should be migrated.",
Remediation = "Adopt a hybrid or post-quantum algorithm (Kyber, Dilithium, SPHINCS+) for long-lived data.",
Algorithm = algorithm
});
}
var score = CalculateReadinessScore(totalAlgorithms, pqcAlgorithms, hybridAlgorithms, vulnerableAlgorithms);
var recommendations = BuildRecommendations(vulnerableAlgorithms, pqcAlgorithms, context);
var readiness = new PostQuantumReadiness
{
Score = score,
TotalAlgorithms = totalAlgorithms,
QuantumVulnerableAlgorithms = vulnerableAlgorithms,
PostQuantumAlgorithms = pqcAlgorithms,
HybridAlgorithms = hybridAlgorithms,
MigrationRecommendations = recommendations
};
return Task.FromResult(new CryptoAnalysisResult
{
Findings = findings.ToImmutableArray(),
QuantumReadiness = readiness
});
}
private static int CalculateReadinessScore(
int totalAlgorithms,
int pqcAlgorithms,
int hybridAlgorithms,
int vulnerableAlgorithms)
{
if (totalAlgorithms == 0)
{
return 100;
}
var resilient = pqcAlgorithms + hybridAlgorithms;
var score = resilient / (double)totalAlgorithms * 100d;
if (vulnerableAlgorithms == 0 && resilient == 0)
{
score = 100d;
}
return (int)Math.Round(score, MidpointRounding.AwayFromZero);
}
private static ImmutableArray<string> BuildRecommendations(
int vulnerableAlgorithms,
int pqcAlgorithms,
CryptoAnalysisContext context)
{
if (vulnerableAlgorithms == 0)
{
return ImmutableArray<string>.Empty;
}
var recommendations = new List<string>
{
"Prioritize migration from RSA/ECC to hybrid or post-quantum algorithms."
};
if (context.Policy.PostQuantum.RequireHybridForLongLived)
{
recommendations.Add("Adopt hybrid PQC for long-lived data flows per policy.");
}
if (pqcAlgorithms == 0)
{
recommendations.Add("Introduce Kyber and Dilithium pilots to validate PQC readiness.");
}
return recommendations.ToImmutableArray();
}
}

View File

@@ -0,0 +1,174 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.CryptoAnalysis.Models;
namespace StellaOps.Scanner.CryptoAnalysis.Analyzers;
public sealed class ProtocolAnalyzer : ICryptoCheck
{
private static readonly string[] WeakCipherMarkers =
[
"NULL",
"EXPORT",
"RC4",
"DES",
"3DES",
"MD5",
"SHA1"
];
public Task<CryptoAnalysisResult> AnalyzeAsync(
CryptoAnalysisContext context,
CancellationToken ct = default)
{
var findings = new List<CryptoFinding>();
foreach (var component in context.Components)
{
ct.ThrowIfCancellationRequested();
var crypto = component.CryptoProperties;
if (crypto is null || crypto.AssetType != CryptoAssetType.Protocol)
{
continue;
}
var protocol = crypto.ProtocolProperties;
if (protocol is null)
{
continue;
}
var protocolType = protocol.Type ?? component.Name ?? "protocol";
if (IsDeprecatedProtocol(protocolType, protocol.Version))
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.DeprecatedProtocol,
Severity = Severity.High,
Title = $"Deprecated protocol version detected ({protocolType} {protocol.Version})",
Description = "Deprecated protocol versions should be upgraded to TLS 1.2+ or equivalent.",
Remediation = "Upgrade the protocol version and remove legacy cipher support.",
Protocol = protocolType,
Metadata = BuildMetadata(protocol)
});
}
if (!protocol.CipherSuites.IsDefaultOrEmpty)
{
foreach (var suite in protocol.CipherSuites)
{
if (string.IsNullOrWhiteSpace(suite))
{
continue;
}
if (WeakCipherMarkers.Any(marker => suite.Contains(marker, StringComparison.OrdinalIgnoreCase)))
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.WeakCipherSuite,
Severity = Severity.High,
Title = $"Weak cipher suite detected ({suite})",
Description = "Cipher suite includes weak or deprecated algorithms.",
Remediation = "Remove weak cipher suites and enforce modern TLS profiles.",
Protocol = protocolType,
Metadata = BuildMetadata(protocol, ("cipherSuite", suite))
});
}
}
if (context.Policy.RequiredFeatures.PerfectForwardSecrecy
&& !protocol.CipherSuites.Any(suite => suite.Contains("DHE", StringComparison.OrdinalIgnoreCase)))
{
findings.Add(new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.WeakCipherSuite,
Severity = Severity.Medium,
Title = "Perfect forward secrecy not detected",
Description = "No cipher suites with (EC)DHE were declared, which weakens forward secrecy guarantees.",
Remediation = "Prefer ECDHE/DHE cipher suites for forward secrecy.",
Protocol = protocolType,
Metadata = BuildMetadata(protocol)
});
}
}
}
return Task.FromResult(new CryptoAnalysisResult
{
Findings = findings.ToImmutableArray()
});
}
private static bool IsDeprecatedProtocol(string protocolType, string? version)
{
if (string.IsNullOrWhiteSpace(version))
{
return false;
}
var normalizedType = protocolType.Trim().ToLowerInvariant();
if (!normalizedType.Contains("tls", StringComparison.OrdinalIgnoreCase)
&& !normalizedType.Contains("ssl", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!Version.TryParse(NormalizeVersion(version), out var parsed))
{
return false;
}
return parsed.Major < 1 || (parsed.Major == 1 && parsed.Minor < 2);
}
private static string NormalizeVersion(string version)
{
var trimmed = version.Trim();
if (trimmed.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed[1..];
}
return trimmed.Replace("TLS", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("SSL", string.Empty, StringComparison.OrdinalIgnoreCase)
.Trim();
}
private static ImmutableDictionary<string, string> BuildMetadata(
ParsedProtocolProperties protocol,
params (string Key, string Value)[] additions)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(protocol.Type))
{
metadata["type"] = protocol.Type!;
}
if (!string.IsNullOrWhiteSpace(protocol.Version))
{
metadata["version"] = protocol.Version!;
}
foreach (var (key, value) in additions)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
{
continue;
}
metadata[key] = value;
}
return metadata.Count == 0
? ImmutableDictionary<string, string>.Empty
: metadata.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,87 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.CryptoAnalysis.Models;
namespace StellaOps.Scanner.CryptoAnalysis.Analyzers;
public sealed class RegionalComplianceChecker : ICryptoCheck
{
public Task<CryptoAnalysisResult> AnalyzeAsync(
CryptoAnalysisContext context,
CancellationToken ct = default)
{
if (!RequiresRegionalCompliance(context.Policy))
{
return Task.FromResult(CryptoAnalysisResult.Empty);
}
var findings = new List<CryptoFinding>();
foreach (var component in context.Components)
{
ct.ThrowIfCancellationRequested();
var crypto = component.CryptoProperties;
if (crypto is null || crypto.AssetType != CryptoAssetType.Algorithm)
{
continue;
}
var algorithm = CryptoAlgorithmCatalog.ResolveAlgorithmName(component);
if (string.IsNullOrWhiteSpace(algorithm))
{
continue;
}
if (context.IsExempted(component, algorithm))
{
continue;
}
if (context.Policy.RegionalRequirements.Eidas && !CryptoAlgorithmCatalog.IsEidasAlgorithm(algorithm))
{
findings.Add(BuildFinding(component, algorithm, "eIDAS"));
}
if (context.Policy.RegionalRequirements.Gost && !CryptoAlgorithmCatalog.IsGostAlgorithm(algorithm))
{
findings.Add(BuildFinding(component, algorithm, "GOST"));
}
if (context.Policy.RegionalRequirements.Sm && !CryptoAlgorithmCatalog.IsSmAlgorithm(algorithm))
{
findings.Add(BuildFinding(component, algorithm, "SM"));
}
}
return Task.FromResult(new CryptoAnalysisResult
{
Findings = findings.ToImmutableArray()
});
}
private static bool RequiresRegionalCompliance(Policy.CryptoPolicy policy)
{
return policy.RegionalRequirements.Eidas
|| policy.RegionalRequirements.Gost
|| policy.RegionalRequirements.Sm;
}
private static CryptoFinding BuildFinding(ParsedComponent component, string algorithm, string region)
{
return new CryptoFinding
{
ComponentBomRef = component.BomRef,
ComponentName = component.Name,
Type = CryptoFindingType.NonFipsCompliant,
Severity = Severity.Medium,
Title = $"{region} compliance gap detected",
Description = $"Algorithm {algorithm} is not recognized as {region}-approved for component {component.Name ?? component.BomRef}.",
Remediation = "Select a region-approved algorithm or document an exemption with expiration.",
Algorithm = algorithm,
Metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["region"] = region
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)
};
}
}

View File

@@ -0,0 +1,164 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.CryptoAnalysis.Analyzers;
using StellaOps.Scanner.CryptoAnalysis.Models;
using StellaOps.Scanner.CryptoAnalysis.Policy;
namespace StellaOps.Scanner.CryptoAnalysis;
public interface ICryptoAnalyzer
{
Task<CryptoAnalysisReport> AnalyzeAsync(
IReadOnlyList<ParsedComponent> componentsWithCrypto,
CryptoPolicy policy,
CancellationToken ct = default);
}
public sealed class CryptoAnalysisAnalyzer : ICryptoAnalyzer
{
private readonly IReadOnlyList<ICryptoCheck> _checks;
private readonly TimeProvider _timeProvider;
public CryptoAnalysisAnalyzer(
IEnumerable<ICryptoCheck> checks,
TimeProvider? timeProvider = null)
{
_checks = (checks ?? Array.Empty<ICryptoCheck>()).ToList();
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<CryptoAnalysisReport> AnalyzeAsync(
IReadOnlyList<ParsedComponent> componentsWithCrypto,
CryptoPolicy policy,
CancellationToken ct = default)
{
var context = CryptoAnalysisContext.Create(componentsWithCrypto, policy, _timeProvider);
var findings = new List<CryptoFinding>();
CryptoInventory? inventory = null;
PostQuantumReadiness? quantumReadiness = null;
foreach (var check in _checks)
{
ct.ThrowIfCancellationRequested();
var result = await check.AnalyzeAsync(context, ct).ConfigureAwait(false);
if (!result.Findings.IsDefaultOrEmpty)
{
findings.AddRange(result.Findings);
}
inventory ??= result.Inventory;
quantumReadiness ??= result.QuantumReadiness;
}
var summary = BuildSummary(findings);
var complianceStatus = BuildComplianceStatus(policy, findings, _timeProvider.GetUtcNow());
return new CryptoAnalysisReport
{
Inventory = inventory ?? CryptoInventory.Empty,
Findings = findings.ToImmutableArray(),
ComplianceStatus = complianceStatus,
QuantumReadiness = quantumReadiness ?? PostQuantumReadiness.Empty,
Summary = summary,
GeneratedAtUtc = _timeProvider.GetUtcNow(),
PolicyVersion = policy.Version
};
}
private static CryptoSummary BuildSummary(IReadOnlyList<CryptoFinding> findings)
{
if (findings.Count == 0)
{
return CryptoSummary.Empty;
}
var bySeverity = findings
.GroupBy(f => f.Severity)
.ToImmutableDictionary(g => g.Key, g => g.Count());
var byType = findings
.GroupBy(f => f.Type)
.ToImmutableDictionary(g => g.Key, g => g.Count());
return new CryptoSummary
{
TotalFindings = findings.Count,
FindingsBySeverity = bySeverity,
FindingsByType = byType
};
}
private static CryptoComplianceStatus BuildComplianceStatus(
CryptoPolicy policy,
IReadOnlyList<CryptoFinding> findings,
DateTimeOffset generatedAtUtc)
{
var frameworks = GetFrameworks(policy);
var violations = findings
.Select(f => $"{f.Type}:{f.ComponentName ?? f.ComponentBomRef}")
.ToImmutableArray();
var isCompliant = violations.Length == 0;
var frameworkStatuses = frameworks
.Select(framework => new ComplianceFrameworkStatus
{
Framework = framework,
IsCompliant = isCompliant,
ViolationCount = violations.Length
})
.ToImmutableArray();
return new CryptoComplianceStatus
{
Frameworks = frameworkStatuses,
IsCompliant = isCompliant,
Violations = violations,
Attestation = new CryptoComplianceAttestation
{
Frameworks = frameworks,
IsCompliant = isCompliant,
GeneratedAtUtc = generatedAtUtc,
EvidenceNote = isCompliant ? "All crypto checks passed." : "Crypto findings require review."
}
};
}
private static ImmutableArray<string> GetFrameworks(CryptoPolicy policy)
{
var frameworks = new List<string>();
if (!policy.ComplianceFrameworks.IsDefaultOrEmpty)
{
frameworks.AddRange(policy.ComplianceFrameworks
.Where(f => !string.IsNullOrWhiteSpace(f))
.Select(f => f.Trim()));
}
if (!string.IsNullOrWhiteSpace(policy.ComplianceFramework))
{
var framework = policy.ComplianceFramework!.Trim();
if (!frameworks.Any(existing => existing.Equals(framework, StringComparison.OrdinalIgnoreCase)))
{
frameworks.Add(framework);
}
}
if (frameworks.Count == 0)
{
frameworks.Add("custom");
}
return frameworks
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
}
public interface ICryptoCheck
{
Task<CryptoAnalysisResult> AnalyzeAsync(
CryptoAnalysisContext context,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,27 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.CryptoAnalysis.Analyzers;
using StellaOps.Scanner.CryptoAnalysis.Policy;
using StellaOps.Scanner.CryptoAnalysis.Reporting;
namespace StellaOps.Scanner.CryptoAnalysis;
public static class CryptoAnalysisServiceCollectionExtensions
{
public static IServiceCollection AddCryptoAnalysis(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<ICryptoPolicyLoader, CryptoPolicyLoader>();
services.AddSingleton<ICryptoCheck, CryptoInventoryGenerator>();
services.AddSingleton<ICryptoCheck, AlgorithmStrengthAnalyzer>();
services.AddSingleton<ICryptoCheck, FipsComplianceChecker>();
services.AddSingleton<ICryptoCheck, RegionalComplianceChecker>();
services.AddSingleton<ICryptoCheck, PostQuantumAnalyzer>();
services.AddSingleton<ICryptoCheck, CertificateAnalyzer>();
services.AddSingleton<ICryptoCheck, ProtocolAnalyzer>();
services.AddSingleton<ICryptoAnalyzer, CryptoAnalysisAnalyzer>();
services.AddSingleton<CryptoAnalysisSarifExporter>();
return services;
}
}

View File

@@ -0,0 +1,176 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.CryptoAnalysis.Models;
public sealed record CryptoAnalysisReport
{
public CryptoInventory Inventory { get; init; } = CryptoInventory.Empty;
public ImmutableArray<CryptoFinding> Findings { get; init; } = [];
public CryptoComplianceStatus ComplianceStatus { get; init; } = CryptoComplianceStatus.Empty;
public PostQuantumReadiness QuantumReadiness { get; init; } = PostQuantumReadiness.Empty;
public CryptoSummary Summary { get; init; } = CryptoSummary.Empty;
public DateTimeOffset GeneratedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public string? PolicyVersion { get; init; }
}
public sealed record CryptoInventory
{
public static CryptoInventory Empty { get; } = new();
public ImmutableArray<CryptoAlgorithmUsage> Algorithms { get; init; } = [];
public ImmutableArray<CryptoCertificateUsage> Certificates { get; init; } = [];
public ImmutableArray<CryptoProtocolUsage> Protocols { get; init; } = [];
public ImmutableArray<CryptoKeyMaterial> KeyMaterials { get; init; } = [];
}
public sealed record CryptoAlgorithmUsage
{
public required string ComponentBomRef { get; init; }
public string? ComponentName { get; init; }
public string? Algorithm { get; init; }
public string? AlgorithmIdentifier { get; init; }
public string? Primitive { get; init; }
public string? Mode { get; init; }
public string? Padding { get; init; }
public int? KeySize { get; init; }
public string? Curve { get; init; }
public string? ExecutionEnvironment { get; init; }
public string? CertificationLevel { get; init; }
public ImmutableArray<string> CryptoFunctions { get; init; } = [];
}
public sealed record CryptoCertificateUsage
{
public required string ComponentBomRef { get; init; }
public string? ComponentName { get; init; }
public string? SubjectName { get; init; }
public string? IssuerName { get; init; }
public DateTimeOffset? NotValidBefore { get; init; }
public DateTimeOffset? NotValidAfter { get; init; }
public string? SignatureAlgorithmRef { get; init; }
public string? SubjectPublicKeyRef { get; init; }
public string? CertificateFormat { get; init; }
public string? CertificateExtension { get; init; }
}
public sealed record CryptoProtocolUsage
{
public required string ComponentBomRef { get; init; }
public string? ComponentName { get; init; }
public string? Type { get; init; }
public string? Version { get; init; }
public ImmutableArray<string> CipherSuites { get; init; } = [];
public ImmutableArray<string> IkeV2TransformTypes { get; init; } = [];
public ImmutableArray<string> CryptoRefArray { get; init; } = [];
}
public sealed record CryptoKeyMaterial
{
public required string ComponentBomRef { get; init; }
public string? ComponentName { get; init; }
public string? Type { get; init; }
public string? Reference { get; init; }
public ImmutableArray<string> MaterialRefs { get; init; } = [];
}
public sealed record CryptoFinding
{
public required string ComponentBomRef { get; init; }
public string? ComponentName { get; init; }
public required CryptoFindingType Type { get; init; }
public required Severity Severity { get; init; }
public required string Title { get; init; }
public required string Description { get; init; }
public string? Remediation { get; init; }
public string? Algorithm { get; init; }
public string? Protocol { get; init; }
public string? Certificate { get; init; }
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
public enum CryptoFindingType
{
WeakAlgorithm,
ShortKeyLength,
DeprecatedProtocol,
NonFipsCompliant,
QuantumVulnerable,
ExpiredCertificate,
WeakCipherSuite,
InsecureMode,
MissingIntegrity
}
public enum Severity
{
Unknown,
Low,
Medium,
High,
Critical
}
public enum AlgorithmStrength
{
Broken,
Weak,
Legacy,
Acceptable,
Strong,
PostQuantum
}
public sealed record CryptoComplianceStatus
{
public static CryptoComplianceStatus Empty { get; } = new();
public ImmutableArray<ComplianceFrameworkStatus> Frameworks { get; init; } = [];
public bool IsCompliant { get; init; }
public ImmutableArray<string> Violations { get; init; } = [];
public CryptoComplianceAttestation? Attestation { get; init; }
}
public sealed record ComplianceFrameworkStatus
{
public required string Framework { get; init; }
public bool IsCompliant { get; init; }
public int ViolationCount { get; init; }
}
public sealed record CryptoComplianceAttestation
{
public ImmutableArray<string> Frameworks { get; init; } = [];
public bool IsCompliant { get; init; }
public DateTimeOffset GeneratedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public string? EvidenceNote { get; init; }
}
public sealed record PostQuantumReadiness
{
public static PostQuantumReadiness Empty { get; } = new();
public int Score { get; init; }
public int TotalAlgorithms { get; init; }
public int QuantumVulnerableAlgorithms { get; init; }
public int PostQuantumAlgorithms { get; init; }
public int HybridAlgorithms { get; init; }
public ImmutableArray<string> MigrationRecommendations { get; init; } = [];
public string? Notes { get; init; }
}
public sealed record CryptoSummary
{
public static CryptoSummary Empty { get; } = new()
{
TotalFindings = 0,
FindingsBySeverity = ImmutableDictionary<Severity, int>.Empty,
FindingsByType = ImmutableDictionary<CryptoFindingType, int>.Empty
};
public int TotalFindings { get; init; }
public ImmutableDictionary<Severity, int> FindingsBySeverity { get; init; } =
ImmutableDictionary<Severity, int>.Empty;
public ImmutableDictionary<CryptoFindingType, int> FindingsByType { get; init; } =
ImmutableDictionary<CryptoFindingType, int>.Empty;
}

View File

@@ -0,0 +1,53 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.CryptoAnalysis.Policy;
public sealed record CryptoPolicy
{
public string? ComplianceFramework { get; init; }
public ImmutableArray<string> ComplianceFrameworks { get; init; } = [];
public ImmutableDictionary<string, int> MinimumKeyLengths { get; init; } =
ImmutableDictionary<string, int>.Empty;
public ImmutableArray<string> ProhibitedAlgorithms { get; init; } = [];
public ImmutableArray<string> ApprovedAlgorithms { get; init; } = [];
public CryptoRequiredFeatures RequiredFeatures { get; init; } = new();
public PostQuantumPolicy PostQuantum { get; init; } = new();
public CertificatePolicy Certificates { get; init; } = new();
public RegionalCryptoPolicy RegionalRequirements { get; init; } = new();
public ImmutableArray<CryptoPolicyExemption> Exemptions { get; init; } = [];
public string? Version { get; init; }
}
public sealed record CryptoRequiredFeatures
{
public bool PerfectForwardSecrecy { get; init; }
public bool AuthenticatedEncryption { get; init; }
}
public sealed record PostQuantumPolicy
{
public bool Enabled { get; init; }
public bool RequireHybridForLongLived { get; init; }
public int LongLivedDataThresholdYears { get; init; } = 10;
}
public sealed record CertificatePolicy
{
public int ExpirationWarningDays { get; init; } = 90;
public string? MinimumSignatureAlgorithm { get; init; }
}
public sealed record RegionalCryptoPolicy
{
public bool Eidas { get; init; }
public bool Gost { get; init; }
public bool Sm { get; init; }
}
public sealed record CryptoPolicyExemption
{
public required string ComponentPattern { get; init; }
public ImmutableArray<string> Algorithms { get; init; } = [];
public string? Reason { get; init; }
public DateTimeOffset? ExpirationDate { get; init; }
}

View File

@@ -0,0 +1,130 @@
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.CryptoAnalysis.Models;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Scanner.CryptoAnalysis.Policy;
public interface ICryptoPolicyLoader
{
Task<CryptoPolicy> LoadAsync(string? path, CancellationToken ct = default);
}
public static class CryptoPolicyDefaults
{
public static CryptoPolicy Default { get; } = new()
{
MinimumKeyLengths = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["RSA"] = 2048,
["DSA"] = 2048,
["ECDSA"] = 256,
["ECDH"] = 256,
["ECC"] = 256,
["AES"] = 128
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
ProhibitedAlgorithms = ["MD5", "SHA1", "DES", "3DES", "RC4"],
RequiredFeatures = new CryptoRequiredFeatures
{
PerfectForwardSecrecy = false,
AuthenticatedEncryption = false
},
Certificates = new CertificatePolicy
{
ExpirationWarningDays = 90,
MinimumSignatureAlgorithm = "SHA256"
}
};
}
public sealed class CryptoPolicyLoader : ICryptoPolicyLoader
{
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
private readonly IDeserializer _yamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
public async Task<CryptoPolicy> LoadAsync(string? path, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return CryptoPolicyDefaults.Default;
}
var extension = Path.GetExtension(path).ToLowerInvariant();
await using var stream = File.OpenRead(path);
return extension switch
{
".yaml" or ".yml" => LoadFromYaml(stream),
_ => await LoadFromJsonAsync(stream, ct).ConfigureAwait(false)
};
}
private CryptoPolicy LoadFromYaml(Stream stream)
{
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var yamlObject = _yamlDeserializer.Deserialize(reader);
if (yamlObject is null)
{
return CryptoPolicyDefaults.Default;
}
var payload = JsonSerializer.Serialize(yamlObject);
using var document = JsonDocument.Parse(payload);
return ExtractPolicy(document.RootElement);
}
private static async Task<CryptoPolicy> LoadFromJsonAsync(Stream stream, CancellationToken ct)
{
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct)
.ConfigureAwait(false);
return ExtractPolicy(document.RootElement);
}
private static CryptoPolicy ExtractPolicy(JsonElement root)
{
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty("cryptoPolicy", out var policyElement))
{
return JsonSerializer.Deserialize<CryptoPolicy>(policyElement, JsonOptions)
?? CryptoPolicyDefaults.Default;
}
return JsonSerializer.Deserialize<CryptoPolicy>(root, JsonOptions)
?? CryptoPolicyDefaults.Default;
}
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
options.Converters.Add(new FlexibleBooleanConverter());
return options;
}
private sealed class FlexibleBooleanConverter : JsonConverter<bool>
{
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.True => true,
JsonTokenType.False => false,
JsonTokenType.String when bool.TryParse(reader.GetString(), out var value) => value,
_ => throw new JsonException($"Expected boolean value or boolean string, got {reader.TokenType}.")
};
}
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
{
writer.WriteBooleanValue(value);
}
}
}

View File

@@ -0,0 +1,219 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.CryptoAnalysis.Models;
using StellaOps.Scanner.Sarif;
using CryptoSeverity = StellaOps.Scanner.CryptoAnalysis.Models.Severity;
using SarifSeverity = StellaOps.Scanner.Sarif.Severity;
namespace StellaOps.Scanner.CryptoAnalysis.Reporting;
public static class CryptoAnalysisReportFormatter
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
public static byte[] ToJsonBytes(CryptoAnalysisReport report)
{
return JsonSerializer.SerializeToUtf8Bytes(report, JsonOptions);
}
public static string ToText(CryptoAnalysisReport report)
{
var builder = new StringBuilder();
builder.AppendLine("Crypto Analysis Report");
builder.AppendLine($"Findings: {report.Summary.TotalFindings}");
foreach (var severityGroup in report.Summary.FindingsBySeverity.OrderByDescending(kvp => kvp.Key))
{
builder.AppendLine($" {severityGroup.Key}: {severityGroup.Value}");
}
if (!report.ComplianceStatus.Frameworks.IsDefaultOrEmpty)
{
builder.AppendLine();
builder.AppendLine("Compliance:");
foreach (var framework in report.ComplianceStatus.Frameworks)
{
builder.AppendLine($" {framework.Framework}: {(framework.IsCompliant ? "Compliant" : "Non-compliant")} ({framework.ViolationCount} violations)");
}
}
if (report.QuantumReadiness.TotalAlgorithms > 0)
{
builder.AppendLine();
builder.AppendLine($"Post-Quantum Readiness Score: {report.QuantumReadiness.Score}");
}
builder.AppendLine();
foreach (var finding in report.Findings)
{
builder.AppendLine($"- [{finding.Severity}] {finding.Title} ({finding.ComponentName ?? finding.ComponentBomRef})");
if (!string.IsNullOrWhiteSpace(finding.Description))
{
builder.AppendLine($" {finding.Description}");
}
if (!string.IsNullOrWhiteSpace(finding.Remediation))
{
builder.AppendLine($" Remediation: {finding.Remediation}");
}
}
return builder.ToString();
}
public static byte[] ToPdfBytes(CryptoAnalysisReport report)
{
return SimplePdfBuilder.Build(ToText(report));
}
}
public sealed class CryptoAnalysisSarifExporter
{
private readonly ISarifExportService _sarifExporter;
public CryptoAnalysisSarifExporter(ISarifExportService sarifExporter)
{
_sarifExporter = sarifExporter ?? throw new ArgumentNullException(nameof(sarifExporter));
}
public async Task<object?> ExportAsync(CryptoAnalysisReport report, CancellationToken ct = default)
{
if (report.Findings.IsDefaultOrEmpty)
{
return null;
}
var inputs = report.Findings.Select(MapToFindingInput).ToList();
var options = new SarifExportOptions
{
ToolName = "StellaOps Scanner",
ToolVersion = "1.0.0",
Category = "crypto-analysis",
IncludeEvidenceUris = false,
IncludeReachability = false,
IncludeVexStatus = false
};
return await _sarifExporter.ExportAsync(inputs, options, ct).ConfigureAwait(false);
}
private static FindingInput MapToFindingInput(CryptoFinding finding)
{
return new FindingInput
{
Type = FindingType.Configuration,
VulnerabilityId = finding.Algorithm,
ComponentName = finding.ComponentName,
Severity = MapSeverity(finding.Severity),
Title = finding.Title,
Description = finding.Description,
Recommendation = finding.Remediation,
Properties = new Dictionary<string, object>
{
["componentBomRef"] = finding.ComponentBomRef,
["findingType"] = finding.Type.ToString(),
["algorithm"] = finding.Algorithm ?? string.Empty,
["protocol"] = finding.Protocol ?? string.Empty,
["certificate"] = finding.Certificate ?? string.Empty
}
};
}
private static SarifSeverity MapSeverity(CryptoSeverity severity)
{
return severity switch
{
CryptoSeverity.Critical => SarifSeverity.Critical,
CryptoSeverity.High => SarifSeverity.High,
CryptoSeverity.Medium => SarifSeverity.Medium,
CryptoSeverity.Low => SarifSeverity.Low,
_ => SarifSeverity.Unknown
};
}
}
internal static class SimplePdfBuilder
{
public static byte[] Build(string text)
{
var lines = text.Replace("\r", string.Empty).Split('\n');
var contentStream = BuildContentStream(lines);
var objects = new List<string>
{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
$"<< /Length {contentStream.Length} >>\\nstream\\n{contentStream}\\nendstream",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
};
using var stream = new MemoryStream();
WriteLine(stream, "%PDF-1.4");
var offsets = new List<long> { 0 };
for (var i = 0; i < objects.Count; i++)
{
offsets.Add(stream.Position);
WriteLine(stream, $"{i + 1} 0 obj");
WriteLine(stream, objects[i]);
WriteLine(stream, "endobj");
}
var xrefStart = stream.Position;
WriteLine(stream, "xref");
WriteLine(stream, $"0 {objects.Count + 1}");
WriteLine(stream, "0000000000 65535 f ");
for (var i = 1; i < offsets.Count; i++)
{
WriteLine(stream, $"{offsets[i]:0000000000} 00000 n ");
}
WriteLine(stream, "trailer");
WriteLine(stream, $"<< /Size {objects.Count + 1} /Root 1 0 R >>");
WriteLine(stream, "startxref");
WriteLine(stream, xrefStart.ToString());
WriteLine(stream, "%%EOF");
return stream.ToArray();
}
private static string BuildContentStream(IEnumerable<string> lines)
{
var builder = new StringBuilder();
builder.AppendLine("BT");
builder.AppendLine("/F1 10 Tf");
var y = 760;
foreach (var line in lines)
{
var escaped = EscapeText(line);
builder.AppendLine($"72 {y} Td ({escaped}) Tj");
y -= 14;
if (y < 60)
{
break;
}
}
builder.AppendLine("ET");
return builder.ToString();
}
private static string EscapeText(string value)
{
return value.Replace("\\\\", "\\\\\\\\")
.Replace("(", "\\\\(")
.Replace(")", "\\\\)");
}
private static void WriteLine(Stream stream, string line)
{
var bytes = Encoding.ASCII.GetBytes(line + "\\n");
stream.Write(bytes, 0, bytes.Length);
}
}

View File

@@ -0,0 +1,312 @@
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.CryptoAnalysis.Models;
namespace StellaOps.Scanner.CryptoAnalysis.Reporting;
public enum CryptoInventoryFormat
{
Json,
Csv,
Xlsx
}
public static class CryptoInventoryExporter
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
public static byte[] Export(CryptoInventory inventory, CryptoInventoryFormat format)
{
ArgumentNullException.ThrowIfNull(inventory);
return format switch
{
CryptoInventoryFormat.Json => JsonSerializer.SerializeToUtf8Bytes(inventory, JsonOptions),
CryptoInventoryFormat.Csv => ExportCsv(inventory),
CryptoInventoryFormat.Xlsx => ExportXlsx(inventory),
_ => JsonSerializer.SerializeToUtf8Bytes(inventory, JsonOptions)
};
}
private static byte[] ExportCsv(CryptoInventory inventory)
{
var (headers, rows) = BuildRows(inventory);
var builder = new StringBuilder();
builder.AppendLine(string.Join(',', headers.Select(EscapeCsv)));
foreach (var row in rows)
{
builder.AppendLine(string.Join(',', row.Select(EscapeCsv)));
}
return Encoding.UTF8.GetBytes(builder.ToString());
}
private static byte[] ExportXlsx(CryptoInventory inventory)
{
var (headers, rows) = BuildRows(inventory);
return XlsxExporter.Export(headers, rows);
}
private static (string[] Headers, List<string[]> Rows) BuildRows(CryptoInventory inventory)
{
var headers = new[]
{
"assetType",
"componentBomRef",
"componentName",
"algorithm",
"algorithmIdentifier",
"keySize",
"mode",
"padding",
"certificateSubject",
"certificateIssuer",
"certificateNotValidAfter",
"protocolType",
"protocolVersion",
"cipherSuites",
"keyMaterialType",
"keyMaterialReference"
};
var rows = new List<string[]>();
foreach (var algorithm in inventory.Algorithms)
{
rows.Add(new[]
{
"algorithm",
algorithm.ComponentBomRef,
algorithm.ComponentName ?? string.Empty,
algorithm.Algorithm ?? string.Empty,
algorithm.AlgorithmIdentifier ?? string.Empty,
algorithm.KeySize?.ToString() ?? string.Empty,
algorithm.Mode ?? string.Empty,
algorithm.Padding ?? string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty
});
}
foreach (var certificate in inventory.Certificates)
{
rows.Add(new[]
{
"certificate",
certificate.ComponentBomRef,
certificate.ComponentName ?? string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
certificate.SubjectName ?? string.Empty,
certificate.IssuerName ?? string.Empty,
certificate.NotValidAfter?.ToString("O") ?? string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty
});
}
foreach (var protocol in inventory.Protocols)
{
rows.Add(new[]
{
"protocol",
protocol.ComponentBomRef,
protocol.ComponentName ?? string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
protocol.Type ?? string.Empty,
protocol.Version ?? string.Empty,
string.Join(';', protocol.CipherSuites),
string.Empty,
string.Empty
});
}
foreach (var material in inventory.KeyMaterials)
{
rows.Add(new[]
{
"key-material",
material.ComponentBomRef,
material.ComponentName ?? string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
material.Type ?? string.Empty,
material.Reference ?? string.Empty
});
}
return (headers, rows);
}
private static string EscapeCsv(string value)
{
var sanitized = value ?? string.Empty;
if (sanitized.Contains('"') || sanitized.Contains(',') || sanitized.Contains('\n'))
{
sanitized = '"' + sanitized.Replace("\"", "\"\"") + '"';
}
return sanitized;
}
private static class XlsxExporter
{
public static byte[] Export(string[] headers, IReadOnlyList<string[]> rows)
{
using var memoryStream = new MemoryStream();
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
{
AddEntry(archive, "[Content_Types].xml", BuildContentTypes());
AddEntry(archive, "_rels/.rels", BuildRootRels());
AddEntry(archive, "xl/workbook.xml", BuildWorkbook());
AddEntry(archive, "xl/_rels/workbook.xml.rels", BuildWorkbookRels());
AddEntry(archive, "xl/styles.xml", BuildStyles());
AddEntry(archive, "xl/worksheets/sheet1.xml", BuildSheet(headers, rows));
}
return memoryStream.ToArray();
}
private static void AddEntry(ZipArchive archive, string path, string content)
{
var entry = archive.CreateEntry(path, CompressionLevel.Optimal);
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
writer.Write(content);
}
private static string BuildContentTypes() =>
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">" +
"<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>" +
"<Default Extension=\"xml\" ContentType=\"application/xml\"/>" +
"<Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>" +
"<Override PartName=\"/xl/worksheets/sheet1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>" +
"<Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>" +
"</Types>";
private static string BuildRootRels() =>
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">" +
"<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"xl/workbook.xml\"/>" +
"</Relationships>";
private static string BuildWorkbook() =>
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" " +
"xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">" +
"<sheets><sheet name=\"CryptoInventory\" sheetId=\"1\" r:id=\"rId1\"/></sheets>" +
"</workbook>";
private static string BuildWorkbookRels() =>
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">" +
"<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" Target=\"worksheets/sheet1.xml\"/>" +
"<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/>" +
"</Relationships>";
private static string BuildStyles() =>
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">" +
"<fonts count=\"1\"><font><sz val=\"11\"/><name val=\"Calibri\"/></font></fonts>" +
"<fills count=\"1\"><fill><patternFill patternType=\"none\"/></fill></fills>" +
"<borders count=\"1\"><border/></borders>" +
"<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>" +
"<cellXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/></cellXfs>" +
"</styleSheet>";
private static string BuildSheet(string[] headers, IReadOnlyList<string[]> rows)
{
var builder = new StringBuilder();
builder.Append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
builder.Append("<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">");
builder.Append("<sheetData>");
AppendRow(builder, 1, headers);
var rowIndex = 2;
foreach (var row in rows)
{
AppendRow(builder, rowIndex, row);
rowIndex++;
}
builder.Append("</sheetData></worksheet>");
return builder.ToString();
}
private static void AppendRow(StringBuilder builder, int rowIndex, IReadOnlyList<string> values)
{
builder.Append("<row r=\"").Append(rowIndex).Append("\">");
for (var colIndex = 0; colIndex < values.Count; colIndex++)
{
var cellRef = GetCellReference(colIndex, rowIndex);
var value = EscapeXml(values[colIndex] ?? string.Empty);
builder.Append("<c r=\"").Append(cellRef).Append("\" t=\"inlineStr\"><is><t>")
.Append(value).Append("</t></is></c>");
}
builder.Append("</row>");
}
private static string GetCellReference(int columnIndex, int rowIndex)
{
return ColumnName(columnIndex) + rowIndex.ToString();
}
private static string ColumnName(int index)
{
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
var dividend = index + 1;
var columnName = string.Empty;
while (dividend > 0)
{
var modulo = (dividend - 1) % 26;
columnName = alphabet[modulo] + columnName;
dividend = (dividend - modulo - 1) / 26;
}
return columnName;
}
private static string EscapeXml(string value)
{
return value.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&apos;");
}
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Scanner.Sarif/StellaOps.Scanner.Sarif.csproj" />
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.SbomIntegration/StellaOps.Concelier.SbomIntegration.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
</Project>

View File

@@ -4,6 +4,7 @@
using System.Collections.Immutable;
using CycloneDX.Models;
using StellaOps.Scanner.Analyzers.Lang.Core.Licensing;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Evidence;
@@ -11,6 +12,7 @@ namespace StellaOps.Scanner.Emit.Evidence;
/// <summary>
/// Builds CycloneDX 1.7 license evidence from component license detection.
/// Sprint: SPRINT_20260107_005_001 Task EV-004
/// Enhanced: SPRINT_20260119_024 Task TASK-024-011
/// </summary>
public sealed class LicenseEvidenceBuilder
{
@@ -42,6 +44,152 @@ public sealed class LicenseEvidenceBuilder
.ToImmutableArray();
}
/// <summary>
/// Builds enhanced license evidence from a LicenseDetectionResult.
/// Includes category, obligations, and optional properties.
/// </summary>
/// <param name="result">The license detection result.</param>
/// <returns>Enhanced license evidence with full metadata.</returns>
public EnhancedLicenseEvidence BuildEnhanced(LicenseDetectionResult result)
{
ArgumentNullException.ThrowIfNull(result);
var license = CreateLicenseFromResult(result);
return new EnhancedLicenseEvidence
{
License = new LicenseChoice
{
License = result.IsExpression ? null : license,
Expression = result.IsExpression ? result.SpdxId : null,
},
Acknowledgement = result.Confidence switch
{
LicenseDetectionConfidence.High => LicenseAcknowledgement.Concluded,
LicenseDetectionConfidence.Medium => LicenseAcknowledgement.Concluded,
_ => LicenseAcknowledgement.Declared
},
Category = result.Category,
Obligations = result.Obligations,
Copyright = result.CopyrightNotice,
TextHash = result.LicenseTextHash,
SourceFile = result.SourceFile,
Confidence = result.Confidence,
Method = result.Method,
Comment = BuildComment(result),
Properties = BuildProperties(result),
};
}
/// <summary>
/// Builds enhanced license evidence from multiple detection results.
/// </summary>
/// <param name="results">The license detection results.</param>
/// <returns>Array of enhanced license evidence records.</returns>
public ImmutableArray<EnhancedLicenseEvidence> BuildEnhanced(
IEnumerable<LicenseDetectionResult> results)
{
ArgumentNullException.ThrowIfNull(results);
return results
.Select(BuildEnhanced)
.Distinct(EnhancedLicenseEvidenceComparer.Instance)
.ToImmutableArray();
}
/// <summary>
/// Creates a CycloneDX License from a detection result.
/// </summary>
private static License CreateLicenseFromResult(LicenseDetectionResult result)
{
var license = new License();
// Set ID if it's a known SPDX ID
if (!result.IsExpression && !result.SpdxId.StartsWith("LicenseRef-", StringComparison.Ordinal))
{
license.Id = result.SpdxId;
}
else if (result.SpdxId.StartsWith("LicenseRef-", StringComparison.Ordinal))
{
// Custom license reference
license.Name = result.OriginalText ?? result.SpdxId;
}
else
{
license.Name = result.OriginalText ?? result.SpdxId;
}
// Set URL if available
if (!string.IsNullOrWhiteSpace(result.LicenseUrl))
{
license.Url = result.LicenseUrl;
}
// Set license text if available
if (!string.IsNullOrWhiteSpace(result.LicenseText))
{
license.Text = new AttachedText
{
Content = result.LicenseText,
ContentType = "text/plain",
};
}
return license;
}
private static string? BuildComment(LicenseDetectionResult result)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(result.SourceFile))
{
parts.Add($"Detected at {result.SourceFile}");
}
if (result.Method != LicenseDetectionMethod.KeywordFallback)
{
parts.Add($"Method: {result.Method}");
}
return parts.Count > 0 ? string.Join("; ", parts) : null;
}
private static ImmutableDictionary<string, string> BuildProperties(LicenseDetectionResult result)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>();
builder["stellaops:license:id"] = result.SpdxId;
builder["stellaops:license:category"] = result.Category.ToString();
if (result.Obligations.Length > 0)
{
builder["stellaops:license:obligations"] = string.Join(",", result.Obligations);
}
if (!string.IsNullOrWhiteSpace(result.CopyrightNotice))
{
builder["stellaops:license:copyright"] = result.CopyrightNotice;
}
if (!string.IsNullOrWhiteSpace(result.LicenseTextHash))
{
builder["stellaops:license:textHash"] = result.LicenseTextHash;
}
if (!string.IsNullOrWhiteSpace(result.OriginalText))
{
builder["stellaops:license:originalText"] = result.OriginalText;
}
if (result.IsExpression && result.ExpressionComponents.Length > 0)
{
builder["stellaops:license:components"] = string.Join(",", result.ExpressionComponents);
}
return builder.ToImmutable();
}
private static LicenseChoice CreateLicenseChoiceFromValue(string value)
{
// Check for SPDX expression operators first (AND, OR, WITH)
@@ -108,6 +256,82 @@ public sealed record LicenseEvidence
public string? Comment { get; init; }
}
/// <summary>
/// Enhanced CycloneDX 1.7 License Evidence with category, obligations, and extended metadata.
/// Sprint: SPRINT_20260119_024 Task TASK-024-011
/// </summary>
public sealed record EnhancedLicenseEvidence
{
/// <summary>
/// Gets the license choice (license or expression).
/// </summary>
public required LicenseChoice License { get; init; }
/// <summary>
/// Gets how the license was acknowledged.
/// </summary>
public LicenseAcknowledgement Acknowledgement { get; init; } = LicenseAcknowledgement.Declared;
/// <summary>
/// Gets the license category (Permissive, WeakCopyleft, etc.).
/// </summary>
public LicenseCategory Category { get; init; } = LicenseCategory.Unknown;
/// <summary>
/// Gets the license obligations (Attribution, SourceDisclosure, etc.).
/// </summary>
public ImmutableArray<LicenseObligation> Obligations { get; init; } = [];
/// <summary>
/// Gets the copyright notice if extracted.
/// </summary>
public string? Copyright { get; init; }
/// <summary>
/// Gets the hash of the license text for deduplication.
/// </summary>
public string? TextHash { get; init; }
/// <summary>
/// Gets the source file where the license was detected.
/// </summary>
public string? SourceFile { get; init; }
/// <summary>
/// Gets the detection confidence level.
/// </summary>
public LicenseDetectionConfidence Confidence { get; init; } = LicenseDetectionConfidence.None;
/// <summary>
/// Gets the detection method used.
/// </summary>
public LicenseDetectionMethod Method { get; init; } = LicenseDetectionMethod.KeywordFallback;
/// <summary>
/// Gets optional comment about the license evidence.
/// </summary>
public string? Comment { get; init; }
/// <summary>
/// Gets extended properties in stellaops: namespace format.
/// </summary>
public ImmutableDictionary<string, string> Properties { get; init; } =
ImmutableDictionary<string, string>.Empty;
/// <summary>
/// Gets the SPDX identifier from the license choice.
/// </summary>
public string GetSpdxId()
{
if (!string.IsNullOrWhiteSpace(License.Expression))
{
return License.Expression;
}
return License.License?.Id ?? License.License?.Name ?? "Unknown";
}
}
/// <summary>
/// CycloneDX 1.7 License Acknowledgement types.
/// Sprint: SPRINT_20260107_005_001 Task EV-004
@@ -170,3 +394,48 @@ internal sealed class LicenseEvidenceComparer : IEqualityComparer<LicenseEvidenc
return null;
}
}
/// <summary>
/// Comparer for enhanced license evidence to eliminate duplicates.
/// Sprint: SPRINT_20260119_024 Task TASK-024-011
/// </summary>
internal sealed class EnhancedLicenseEvidenceComparer : IEqualityComparer<EnhancedLicenseEvidence>
{
public static readonly EnhancedLicenseEvidenceComparer Instance = new();
public bool Equals(EnhancedLicenseEvidence? x, EnhancedLicenseEvidence? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
// Use text hash for deduplication if available
if (!string.IsNullOrWhiteSpace(x.TextHash) && !string.IsNullOrWhiteSpace(y.TextHash))
{
return string.Equals(x.TextHash, y.TextHash, StringComparison.OrdinalIgnoreCase);
}
// Fall back to SPDX ID comparison
return string.Equals(x.GetSpdxId(), y.GetSpdxId(), StringComparison.OrdinalIgnoreCase) &&
x.Acknowledgement == y.Acknowledgement;
}
public int GetHashCode(EnhancedLicenseEvidence obj)
{
// Prefer text hash for uniqueness
if (!string.IsNullOrWhiteSpace(obj.TextHash))
{
return obj.TextHash.ToLowerInvariant().GetHashCode();
}
return HashCode.Combine(
obj.GetSpdxId()?.ToLowerInvariant(),
obj.Acknowledgement);
}
}

View File

@@ -12,6 +12,7 @@
<ProjectReference Include="..\StellaOps.Scanner.EntryTrace\StellaOps.Scanner.EntryTrace.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Validation\StellaOps.Scanner.Validation.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,395 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Scanner.Reachability.Dependencies;
/// <summary>
/// Conditional reachability analysis that tags paths gated by optional scopes or SBOM conditions.
/// </summary>
public sealed class ConditionalReachabilityAnalyzer
{
private static readonly string[] ConditionPropertyKeys =
[
"stellaops.reachability.condition",
"stellaops.reachability.conditions"
];
private static readonly char[] ConditionSeparators = [',', ';'];
private const string OptionalDependencyCondition = "dependency.scope.optional";
private const string OptionalComponentCondition = "component.scope.optional";
public ReachabilityReport Analyze(
DependencyGraph graph,
ParsedSbom sbom,
ImmutableArray<string> entryPoints,
ReachabilityPolicy? policy = null)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(sbom);
var resolvedPolicy = policy ?? new ReachabilityPolicy();
var optionalHandling = resolvedPolicy.ScopeHandling.IncludeOptional;
var includeOptionalCondition =
optionalHandling == OptionalDependencyHandling.AsPotentiallyReachable;
var componentConditions = BuildComponentConditions(sbom, includeOptionalCondition);
var normalizedEntryPoints = NormalizeEntryPoints(entryPoints);
var results = new Dictionary<string, ReachabilityStatus>(StringComparer.Ordinal);
var findings = new List<ReachabilityFinding>();
if (normalizedEntryPoints.IsDefaultOrEmpty)
{
foreach (var node in graph.Nodes)
{
results[node] = ReachabilityStatus.Unknown;
findings.Add(new ReachabilityFinding
{
ComponentRef = node,
Status = ReachabilityStatus.Unknown,
Reason = "no-entrypoints"
});
}
return ReachabilityReportBuilder.Build(graph, results, findings);
}
var predecessor = new Dictionary<string, string?>(StringComparer.Ordinal);
var pathKind = new Dictionary<string, PathKind>(StringComparer.Ordinal);
var pathConditions = new Dictionary<string, ImmutableArray<string>>(StringComparer.Ordinal);
var queue = new Queue<TraversalState>();
foreach (var entryPoint in normalizedEntryPoints)
{
var entryConditions = componentConditions.TryGetValue(entryPoint, out var conditions)
? conditions
: ImmutableArray<string>.Empty;
var kind = entryConditions.IsDefaultOrEmpty ? PathKind.Required : PathKind.Conditional;
if (!pathKind.TryAdd(entryPoint, kind))
{
if (pathKind[entryPoint] == PathKind.Conditional && kind == PathKind.Required)
{
pathKind[entryPoint] = PathKind.Required;
pathConditions.Remove(entryPoint);
}
continue;
}
predecessor[entryPoint] = null;
if (!entryConditions.IsDefaultOrEmpty)
{
pathConditions[entryPoint] = entryConditions;
}
queue.Enqueue(new TraversalState(entryPoint, kind, entryConditions));
}
while (queue.Count > 0)
{
var state = queue.Dequeue();
if (!graph.Edges.TryGetValue(state.Node, out var edges))
{
continue;
}
foreach (var edge in edges)
{
if (!IsScopeIncluded(edge.Scope, resolvedPolicy.ScopeHandling))
{
continue;
}
var edgeConditions = BuildEdgeConditions(
edge,
componentConditions,
optionalHandling);
var mergedConditions = MergeConditions(state.Conditions, edgeConditions);
var nextKind = mergedConditions.IsDefaultOrEmpty
? PathKind.Required
: PathKind.Conditional;
var target = edge.To;
if (nextKind == PathKind.Required)
{
if (!pathKind.TryGetValue(target, out var existingKind))
{
predecessor[target] = state.Node;
pathKind[target] = PathKind.Required;
queue.Enqueue(new TraversalState(target, PathKind.Required, []));
}
else if (existingKind == PathKind.Conditional)
{
predecessor[target] = state.Node;
pathKind[target] = PathKind.Required;
pathConditions.Remove(target);
queue.Enqueue(new TraversalState(target, PathKind.Required, []));
}
continue;
}
if (pathKind.ContainsKey(target))
{
continue;
}
predecessor[target] = state.Node;
pathKind[target] = PathKind.Conditional;
pathConditions[target] = mergedConditions;
queue.Enqueue(new TraversalState(target, PathKind.Conditional, mergedConditions));
}
}
foreach (var node in graph.Nodes)
{
if (pathKind.TryGetValue(node, out var kind))
{
if (kind == PathKind.Required)
{
results[node] = ReachabilityStatus.Reachable;
findings.Add(new ReachabilityFinding
{
ComponentRef = node,
Status = ReachabilityStatus.Reachable,
Path = BuildPath(node, predecessor)
});
}
else
{
results[node] = ReachabilityStatus.PotentiallyReachable;
findings.Add(new ReachabilityFinding
{
ComponentRef = node,
Status = ReachabilityStatus.PotentiallyReachable,
Path = BuildPath(node, predecessor),
Conditions = pathConditions.TryGetValue(node, out var conditions)
? conditions
: [],
Reason = "conditional-path"
});
}
continue;
}
results[node] = ReachabilityStatus.Unreachable;
findings.Add(new ReachabilityFinding
{
ComponentRef = node,
Status = ReachabilityStatus.Unreachable,
Reason = "no-path"
});
}
return ReachabilityReportBuilder.Build(graph, results, findings);
}
private static ImmutableArray<string> NormalizeEntryPoints(ImmutableArray<string> entryPoints)
{
if (entryPoints.IsDefaultOrEmpty)
{
return [];
}
return entryPoints
.Select(NormalizeRef)
.Where(value => value is not null)
.Select(value => value!)
.Distinct(StringComparer.Ordinal)
.OrderBy(value => value, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableDictionary<string, ImmutableArray<string>> BuildComponentConditions(
ParsedSbom sbom,
bool includeOptionalCondition)
{
if (sbom.Components.IsDefaultOrEmpty)
{
return ImmutableDictionary<string, ImmutableArray<string>>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, ImmutableArray<string>>(
StringComparer.Ordinal);
foreach (var component in sbom.Components)
{
var bomRef = NormalizeRef(component.BomRef);
if (bomRef is null)
{
continue;
}
var conditions = new List<string>();
if (includeOptionalCondition && component.Scope == ComponentScope.Optional)
{
conditions.Add(OptionalComponentCondition);
}
AppendPropertyConditions(conditions, component.Properties);
if (conditions.Count == 0)
{
continue;
}
builder[bomRef] = NormalizeConditions(conditions);
}
return builder.ToImmutable();
}
private static void AppendPropertyConditions(
List<string> conditions,
ImmutableDictionary<string, string> properties)
{
if (properties is null || properties.IsEmpty)
{
return;
}
foreach (var key in ConditionPropertyKeys)
{
if (!properties.TryGetValue(key, out var value))
{
continue;
}
AppendConditions(conditions, value);
}
}
private static void AppendConditions(List<string> conditions, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
var tokens = value.Split(
ConditionSeparators,
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var token in tokens)
{
if (!string.IsNullOrWhiteSpace(token))
{
conditions.Add(token);
}
}
}
private static ImmutableArray<string> BuildEdgeConditions(
DependencyEdge edge,
ImmutableDictionary<string, ImmutableArray<string>> componentConditions,
OptionalDependencyHandling optionalHandling)
{
var conditions = new List<string>();
if (edge.Scope == DependencyScope.Optional &&
optionalHandling == OptionalDependencyHandling.AsPotentiallyReachable)
{
conditions.Add(OptionalDependencyCondition);
}
if (componentConditions.TryGetValue(edge.To, out var targetConditions))
{
conditions.AddRange(targetConditions);
}
return NormalizeConditions(conditions);
}
private static ImmutableArray<string> MergeConditions(
ImmutableArray<string> current,
ImmutableArray<string> next)
{
if (current.IsDefaultOrEmpty)
{
return next;
}
if (next.IsDefaultOrEmpty)
{
return current;
}
return NormalizeConditions(current.Concat(next));
}
private static ImmutableArray<string> NormalizeConditions(IEnumerable<string> conditions)
{
return conditions
.Select(value => value?.Trim())
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value!)
.Distinct(StringComparer.Ordinal)
.OrderBy(value => value, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<string> BuildPath(
string target,
IReadOnlyDictionary<string, string?> predecessor)
{
var path = new List<string>();
var current = target;
var seen = new HashSet<string>(StringComparer.Ordinal);
while (true)
{
if (!seen.Add(current))
{
break;
}
path.Add(current);
if (!predecessor.TryGetValue(current, out var previous) ||
string.IsNullOrWhiteSpace(previous))
{
break;
}
current = previous;
}
path.Reverse();
return path.ToImmutableArray();
}
private static bool IsScopeIncluded(DependencyScope scope, ReachabilityScopePolicy policy)
{
return scope switch
{
DependencyScope.Runtime => policy.IncludeRuntime,
DependencyScope.Development => policy.IncludeDevelopment,
DependencyScope.Test => policy.IncludeTest,
DependencyScope.Optional => policy.IncludeOptional != OptionalDependencyHandling.Exclude,
_ => true
};
}
private static string? NormalizeRef(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
private sealed record TraversalState(
string Node,
PathKind Kind,
ImmutableArray<string> Conditions);
private enum PathKind
{
Required,
Conditional
}
}

View File

@@ -0,0 +1,121 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Scanner.Reachability.Dependencies;
/// <summary>
/// Builds adjacency-list dependency graphs from ParsedSbom dependencies.
/// </summary>
public sealed class DependencyGraphBuilder
{
public DependencyGraph Build(ParsedSbom sbom)
{
ArgumentNullException.ThrowIfNull(sbom);
var nodes = new HashSet<string>(StringComparer.Ordinal);
var incoming = new HashSet<string>(StringComparer.Ordinal);
var edges = new Dictionary<string, List<DependencyEdge>>(StringComparer.Ordinal);
foreach (var component in sbom.Components)
{
AddNode(nodes, component.BomRef);
}
foreach (var dependency in sbom.Dependencies)
{
var source = NormalizeRef(dependency.SourceRef);
if (source is null)
{
continue;
}
AddNode(nodes, source);
if (dependency.DependsOn.IsDefaultOrEmpty)
{
continue;
}
foreach (var targetRef in dependency.DependsOn)
{
var target = NormalizeRef(targetRef);
if (target is null)
{
continue;
}
AddNode(nodes, target);
incoming.Add(target);
if (!edges.TryGetValue(source, out var list))
{
list = new List<DependencyEdge>();
edges[source] = list;
}
list.Add(new DependencyEdge
{
From = source,
To = target,
Scope = dependency.Scope
});
}
}
var roots = new HashSet<string>(StringComparer.Ordinal);
var metadataRoot = NormalizeRef(sbom.Metadata.RootComponentRef);
if (metadataRoot is not null)
{
AddNode(nodes, metadataRoot);
roots.Add(metadataRoot);
}
foreach (var node in nodes)
{
if (!incoming.Contains(node))
{
roots.Add(node);
}
}
var edgeMap = edges
.ToImmutableDictionary(
kvp => kvp.Key,
kvp => kvp.Value
.Distinct()
.OrderBy(edge => edge.To, StringComparer.Ordinal)
.ThenBy(edge => edge.Scope)
.ToImmutableArray(),
StringComparer.Ordinal);
return new DependencyGraph
{
Nodes = nodes
.OrderBy(node => node, StringComparer.Ordinal)
.ToImmutableArray(),
Edges = edgeMap,
Roots = roots
.OrderBy(root => root, StringComparer.Ordinal)
.ToImmutableArray()
};
}
private static void AddNode(HashSet<string> nodes, string? value)
{
var normalized = NormalizeRef(value);
if (normalized is not null)
{
nodes.Add(normalized);
}
}
private static string? NormalizeRef(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
}

View File

@@ -0,0 +1,106 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Scanner.Reachability.Dependencies;
/// <summary>
/// Infers component reachability using SBOM dependency graphs.
/// </summary>
public interface IReachabilityInferrer
{
/// <summary>
/// Computes reachability for every component in the SBOM.
/// </summary>
Task<ReachabilityReport> InferAsync(
ParsedSbom sbom,
ReachabilityPolicy policy,
CancellationToken ct);
/// <summary>
/// Computes reachability for a single component PURL.
/// </summary>
Task<ComponentReachability> CheckComponentReachabilityAsync(
string componentPurl,
ParsedSbom sbom,
CancellationToken ct);
}
/// <summary>
/// Reachability inference result.
/// </summary>
public sealed record ReachabilityReport
{
public required DependencyGraph Graph { get; init; }
public ImmutableDictionary<string, ReachabilityStatus> ComponentReachability { get; init; } =
ImmutableDictionary<string, ReachabilityStatus>.Empty;
public ImmutableArray<ReachabilityFinding> Findings { get; init; } = [];
public required ReachabilityStatistics Statistics { get; init; }
}
/// <summary>
/// Reachability state for a component.
/// </summary>
public enum ReachabilityStatus
{
Reachable,
PotentiallyReachable,
Unreachable,
Unknown
}
/// <summary>
/// Aggregate reachability statistics for a scan.
/// </summary>
public sealed record ReachabilityStatistics
{
public int TotalComponents { get; init; }
public int ReachableComponents { get; init; }
public int UnreachableComponents { get; init; }
public int UnknownComponents { get; init; }
public double VulnerabilityReductionPercent { get; init; }
}
/// <summary>
/// Reachability finding for a component.
/// </summary>
public sealed record ReachabilityFinding
{
public required string ComponentRef { get; init; }
public required ReachabilityStatus Status { get; init; }
public ImmutableArray<string> Path { get; init; } = [];
public ImmutableArray<string> Conditions { get; init; } = [];
public string? Reason { get; init; }
}
/// <summary>
/// Reachability status for a single component lookup.
/// </summary>
public sealed record ComponentReachability
{
public required string ComponentRef { get; init; }
public ReachabilityStatus Status { get; init; }
public ImmutableArray<string> Path { get; init; } = [];
public ImmutableArray<string> Conditions { get; init; } = [];
public string? Reason { get; init; }
}
/// <summary>
/// Dependency graph with adjacency list edges.
/// </summary>
public sealed record DependencyGraph
{
public ImmutableArray<string> Nodes { get; init; } = [];
public ImmutableDictionary<string, ImmutableArray<DependencyEdge>> Edges { get; init; } =
ImmutableDictionary<string, ImmutableArray<DependencyEdge>>.Empty;
public ImmutableArray<string> Roots { get; init; } = [];
}
/// <summary>
/// Directed dependency edge between components.
/// </summary>
public sealed record DependencyEdge
{
public required string From { get; init; }
public required string To { get; init; }
public DependencyScope Scope { get; init; } = DependencyScope.Runtime;
}

View File

@@ -0,0 +1,70 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Scanner.Reachability.Dependencies;
/// <summary>
/// Determines entry points from ParsedSbom metadata and component types.
/// </summary>
public sealed class EntryPointDetector
{
public ImmutableArray<string> DetectEntryPoints(
ParsedSbom sbom,
ReachabilityPolicy? policy = null)
{
ArgumentNullException.ThrowIfNull(sbom);
var entryPoints = new HashSet<string>(StringComparer.Ordinal);
var entryPolicy = policy?.EntryPoints ?? new ReachabilityEntryPointPolicy();
foreach (var explicitEntry in entryPolicy.Additional)
{
AddEntry(entryPoints, explicitEntry);
}
if (entryPolicy.DetectFromSbom)
{
AddEntry(entryPoints, sbom.Metadata.RootComponentRef);
foreach (var component in sbom.Components)
{
if (IsApplicationComponent(component))
{
AddEntry(entryPoints, component.BomRef);
}
}
}
if (entryPoints.Count == 0)
{
foreach (var component in sbom.Components)
{
AddEntry(entryPoints, component.BomRef);
}
}
return entryPoints
.OrderBy(entry => entry, StringComparer.Ordinal)
.ToImmutableArray();
}
private static void AddEntry(HashSet<string> entryPoints, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
entryPoints.Add(value.Trim());
}
private static bool IsApplicationComponent(ParsedComponent component)
{
if (string.IsNullOrWhiteSpace(component.Type))
{
return false;
}
return component.Type.Contains("application", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,390 @@
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Scanner.Reachability;
namespace StellaOps.Scanner.Reachability.Dependencies;
/// <summary>
/// Combines SBOM dependency reachability with reachgraph call analysis.
/// </summary>
public sealed class ReachGraphReachabilityCombiner
{
private readonly DependencyGraphBuilder _graphBuilder = new();
private readonly EntryPointDetector _entryPointDetector = new();
private readonly ConditionalReachabilityAnalyzer _conditionalAnalyzer = new();
private readonly CallGraphReachabilityAnalyzer _callGraphAnalyzer = new();
public ReachabilityReport Analyze(
ParsedSbom sbom,
RichGraph? callGraph,
ReachabilityPolicy? policy = null)
{
ArgumentNullException.ThrowIfNull(sbom);
var resolvedPolicy = policy ?? new ReachabilityPolicy();
var dependencyGraph = _graphBuilder.Build(sbom);
var entryPoints = _entryPointDetector.DetectEntryPoints(sbom, resolvedPolicy);
var sbomReport = _conditionalAnalyzer.Analyze(
dependencyGraph,
sbom,
entryPoints,
resolvedPolicy);
return Combine(sbomReport, sbom, callGraph, resolvedPolicy);
}
public ReachabilityReport Combine(
ReachabilityReport sbomReport,
ParsedSbom sbom,
RichGraph? callGraph,
ReachabilityPolicy? policy = null)
{
ArgumentNullException.ThrowIfNull(sbomReport);
ArgumentNullException.ThrowIfNull(sbom);
var resolvedPolicy = policy ?? new ReachabilityPolicy();
if (resolvedPolicy.AnalysisMode == ReachabilityAnalysisMode.SbomOnly || callGraph is null)
{
return sbomReport;
}
var callGraphResult = _callGraphAnalyzer.Analyze(callGraph);
if (!callGraphResult.HasEntrypoints || callGraphResult.PurlReachability.Count == 0)
{
return sbomReport;
}
var componentPurls = BuildComponentPurlLookup(sbom);
var sbomFindings = sbomReport.Findings.ToDictionary(
finding => finding.ComponentRef,
StringComparer.Ordinal);
var combinedResults = new Dictionary<string, ReachabilityStatus>(StringComparer.Ordinal);
var combinedFindings = new List<ReachabilityFinding>(sbomReport.ComponentReachability.Count);
foreach (var entry in sbomReport.ComponentReachability)
{
var componentRef = entry.Key;
var sbomStatus = entry.Value;
var callStatus = ResolveCallGraphStatus(
componentRef,
componentPurls,
callGraphResult);
var combinedStatus = CombineStatus(
sbomStatus,
callStatus,
resolvedPolicy.AnalysisMode);
combinedResults[componentRef] = combinedStatus;
combinedFindings.Add(BuildFinding(
componentRef,
sbomStatus,
callStatus,
combinedStatus,
sbomFindings.TryGetValue(componentRef, out var baseFinding) ? baseFinding : null,
resolvedPolicy.AnalysisMode,
resolvedPolicy.Reporting));
}
return ReachabilityReportBuilder.Build(sbomReport.Graph, combinedResults, combinedFindings);
}
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) ||
string.IsNullOrWhiteSpace(component.Purl))
{
continue;
}
lookup[component.BomRef] = component.Purl!;
}
return lookup;
}
private static ReachabilityStatus ResolveCallGraphStatus(
string componentRef,
IReadOnlyDictionary<string, string> componentPurls,
CallGraphReachabilityResult callGraphResult)
{
if (!componentPurls.TryGetValue(componentRef, out var purl))
{
return ReachabilityStatus.Unknown;
}
return callGraphResult.PurlReachability.TryGetValue(purl, out var status)
? status
: ReachabilityStatus.Unknown;
}
private static ReachabilityStatus CombineStatus(
ReachabilityStatus sbomStatus,
ReachabilityStatus callStatus,
ReachabilityAnalysisMode mode)
{
if (mode == ReachabilityAnalysisMode.CallGraph)
{
return callStatus;
}
if (sbomStatus == ReachabilityStatus.Unreachable)
{
return ReachabilityStatus.Unreachable;
}
return callStatus switch
{
ReachabilityStatus.Reachable => ReachabilityStatus.Reachable,
ReachabilityStatus.Unreachable => ReachabilityStatus.Unreachable,
_ => sbomStatus
};
}
private static ReachabilityFinding BuildFinding(
string componentRef,
ReachabilityStatus sbomStatus,
ReachabilityStatus callStatus,
ReachabilityStatus combinedStatus,
ReachabilityFinding? baseFinding,
ReachabilityAnalysisMode mode,
ReachabilityReportingPolicy reporting)
{
var reason = baseFinding?.Reason;
if (mode != ReachabilityAnalysisMode.SbomOnly)
{
if (callStatus == ReachabilityStatus.Unknown)
{
if (mode == ReachabilityAnalysisMode.CallGraph)
{
reason = MergeReason(reason, "call-graph-missing");
}
}
else if (mode == ReachabilityAnalysisMode.CallGraph || combinedStatus != sbomStatus)
{
reason = MergeReason(
reason,
$"call-graph-{combinedStatus.ToString().ToLowerInvariant()}");
}
}
return new ReachabilityFinding
{
ComponentRef = componentRef,
Status = combinedStatus,
Path = reporting.IncludeReachabilityPaths
? baseFinding?.Path ?? ImmutableArray<string>.Empty
: ImmutableArray<string>.Empty,
Conditions = baseFinding?.Conditions ?? ImmutableArray<string>.Empty,
Reason = reason
};
}
private static string? MergeReason(string? existing, string addition)
{
if (string.IsNullOrWhiteSpace(addition))
{
return existing;
}
if (string.IsNullOrWhiteSpace(existing))
{
return addition;
}
var parts = existing.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Contains(addition, StringComparer.Ordinal))
{
return existing;
}
return $"{existing};{addition}";
}
}
internal sealed class CallGraphReachabilityAnalyzer
{
public CallGraphReachabilityResult Analyze(RichGraph graph)
{
ArgumentNullException.ThrowIfNull(graph);
var entrypoints = ResolveEntrypoints(graph);
if (entrypoints.Length == 0)
{
return new CallGraphReachabilityResult
{
PurlReachability = ImmutableDictionary<string, ReachabilityStatus>.Empty,
Entrypoints = ImmutableArray<string>.Empty,
HasEntrypoints = false
};
}
var reachableNodes = Traverse(graph, entrypoints);
var purlStates = new Dictionary<string, PurlAccumulator>(StringComparer.OrdinalIgnoreCase);
foreach (var node in graph.Nodes)
{
var purl = ResolvePurl(node);
if (string.IsNullOrWhiteSpace(purl))
{
continue;
}
if (!purlStates.TryGetValue(purl, out var state))
{
state = new PurlAccumulator();
purlStates[purl] = state;
}
state.HasNode = true;
if (reachableNodes.Contains(node.Id))
{
state.IsReachable = true;
}
}
var reachability = purlStates
.ToImmutableDictionary(
entry => entry.Key,
entry => entry.Value.IsReachable
? ReachabilityStatus.Reachable
: ReachabilityStatus.Unreachable,
StringComparer.OrdinalIgnoreCase);
return new CallGraphReachabilityResult
{
PurlReachability = reachability,
Entrypoints = entrypoints,
HasEntrypoints = true
};
}
private static ImmutableArray<string> ResolveEntrypoints(RichGraph graph)
{
var entrypoints = new HashSet<string>(StringComparer.Ordinal);
if (graph.Roots is not null)
{
foreach (var root in graph.Roots)
{
if (!string.IsNullOrWhiteSpace(root.Id))
{
entrypoints.Add(root.Id.Trim());
}
}
}
foreach (var node in graph.Nodes)
{
if (IsEntrypoint(node))
{
entrypoints.Add(node.Id);
}
}
return entrypoints.ToImmutableArray();
}
private static bool IsEntrypoint(RichGraphNode node)
{
if (node.Attributes?.TryGetValue(RichGraphSemanticAttributes.IsEntrypoint, out var value) == true &&
bool.TryParse(value, out var isEntrypoint) &&
isEntrypoint)
{
return true;
}
return node.Kind.Equals("entrypoint", StringComparison.OrdinalIgnoreCase) ||
node.Kind.Equals("export", StringComparison.OrdinalIgnoreCase) ||
node.Kind.Equals("main", StringComparison.OrdinalIgnoreCase) ||
node.Kind.Equals("handler", StringComparison.OrdinalIgnoreCase);
}
private static string? ResolvePurl(RichGraphNode node)
{
if (!string.IsNullOrWhiteSpace(node.Purl))
{
return node.Purl.Trim();
}
if (node.Attributes?.TryGetValue("purl", out var purl) == true &&
!string.IsNullOrWhiteSpace(purl))
{
return purl.Trim();
}
return null;
}
private static HashSet<string> Traverse(RichGraph graph, ImmutableArray<string> entrypoints)
{
var adjacency = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var edge in graph.Edges)
{
if (string.IsNullOrWhiteSpace(edge.From) || string.IsNullOrWhiteSpace(edge.To))
{
continue;
}
if (!adjacency.TryGetValue(edge.From, out var targets))
{
targets = [];
adjacency[edge.From] = targets;
}
targets.Add(edge.To);
}
var reachable = new HashSet<string>(StringComparer.Ordinal);
var queue = new Queue<string>();
foreach (var entry in entrypoints)
{
if (string.IsNullOrWhiteSpace(entry))
{
continue;
}
var normalized = entry.Trim();
if (reachable.Add(normalized))
{
queue.Enqueue(normalized);
}
}
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!adjacency.TryGetValue(current, out var targets))
{
continue;
}
foreach (var target in targets)
{
if (reachable.Add(target))
{
queue.Enqueue(target);
}
}
}
return reachable;
}
private sealed class PurlAccumulator
{
public bool HasNode { get; set; }
public bool IsReachable { get; set; }
}
}
internal sealed record CallGraphReachabilityResult
{
public required ImmutableDictionary<string, ReachabilityStatus> PurlReachability { get; init; }
public ImmutableArray<string> Entrypoints { get; init; } = [];
public bool HasEntrypoints { get; init; }
}

View File

@@ -0,0 +1,98 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Reachability.Dependencies;
/// <summary>
/// Policy options for SBOM dependency reachability inference.
/// </summary>
public sealed record ReachabilityPolicy
{
public ReachabilityAnalysisMode AnalysisMode { get; init; } =
ReachabilityAnalysisMode.SbomOnly;
public ReachabilityScopePolicy ScopeHandling { get; init; } = new();
public ReachabilityEntryPointPolicy EntryPoints { get; init; } = new();
public ReachabilityVulnerabilityFilteringPolicy VulnerabilityFiltering { get; init; }
= new();
public ReachabilityReportingPolicy Reporting { get; init; } = new();
public ReachabilityConfidencePolicy Confidence { get; init; } = new();
}
public enum ReachabilityAnalysisMode
{
SbomOnly,
CallGraph,
Combined
}
/// <summary>
/// Scope handling rules for dependency edges.
/// </summary>
public sealed record ReachabilityScopePolicy
{
public bool IncludeRuntime { get; init; } = true;
public OptionalDependencyHandling IncludeOptional { get; init; } =
OptionalDependencyHandling.AsPotentiallyReachable;
public bool IncludeDevelopment { get; init; }
public bool IncludeTest { get; init; }
}
public enum OptionalDependencyHandling
{
Exclude,
AsPotentiallyReachable,
Reachable
}
/// <summary>
/// Entry point detection configuration.
/// </summary>
public sealed record ReachabilityEntryPointPolicy
{
public bool DetectFromSbom { get; init; } = true;
public ImmutableArray<string> Additional { get; init; } = [];
}
/// <summary>
/// Vulnerability filtering and severity adjustment options.
/// </summary>
public sealed record ReachabilityVulnerabilityFilteringPolicy
{
public bool FilterUnreachable { get; init; } = true;
public ReachabilitySeverityAdjustmentPolicy SeverityAdjustment { get; init; } = new();
}
public sealed record ReachabilitySeverityAdjustmentPolicy
{
public ReachabilitySeverityAdjustment PotentiallyReachable { get; init; } =
ReachabilitySeverityAdjustment.ReduceBySeverityLevel;
public ReachabilitySeverityAdjustment Unreachable { get; init; } =
ReachabilitySeverityAdjustment.InformationalOnly;
public double ReduceByPercentage { get; init; } = 0.5;
}
public enum ReachabilitySeverityAdjustment
{
None,
ReduceBySeverityLevel,
ReduceByPercentage,
InformationalOnly
}
/// <summary>
/// Reporting options for reachability outputs.
/// </summary>
public sealed record ReachabilityReportingPolicy
{
public bool ShowFilteredVulnerabilities { get; init; } = true;
public bool IncludeReachabilityPaths { get; init; } = true;
}
/// <summary>
/// Confidence thresholds for reachability inference.
/// </summary>
public sealed record ReachabilityConfidencePolicy
{
public double MinimumConfidence { get; init; } = 0.8;
public ReachabilityStatus MarkUnknownAs { get; init; } =
ReachabilityStatus.PotentiallyReachable;
}

View File

@@ -0,0 +1,115 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Scanner.Reachability.Dependencies;
public interface IReachabilityPolicyLoader
{
Task<ReachabilityPolicy> LoadAsync(string? path, CancellationToken ct = default);
}
public static class ReachabilityPolicyDefaults
{
public static ReachabilityPolicy Default { get; } = new();
}
public sealed class ReachabilityPolicyLoader : IReachabilityPolicyLoader
{
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
private readonly IDeserializer _yamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
public async Task<ReachabilityPolicy> LoadAsync(string? path, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return ReachabilityPolicyDefaults.Default;
}
var extension = Path.GetExtension(path).ToLowerInvariant();
await using var stream = File.OpenRead(path);
return extension switch
{
".yaml" or ".yml" => LoadFromYaml(stream),
_ => await LoadFromJsonAsync(stream, ct).ConfigureAwait(false)
};
}
private ReachabilityPolicy LoadFromYaml(Stream stream)
{
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var yamlObject = _yamlDeserializer.Deserialize(reader);
if (yamlObject is null)
{
return ReachabilityPolicyDefaults.Default;
}
var payload = JsonSerializer.Serialize(yamlObject);
using var document = JsonDocument.Parse(payload);
return ExtractPolicy(document.RootElement);
}
private static async Task<ReachabilityPolicy> LoadFromJsonAsync(
Stream stream,
CancellationToken ct)
{
using var document = await JsonDocument.ParseAsync(
stream,
cancellationToken: ct)
.ConfigureAwait(false);
return ExtractPolicy(document.RootElement);
}
private static ReachabilityPolicy ExtractPolicy(JsonElement root)
{
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("reachabilityPolicy", out var policyElement))
{
return JsonSerializer.Deserialize<ReachabilityPolicy>(policyElement, JsonOptions)
?? ReachabilityPolicyDefaults.Default;
}
return JsonSerializer.Deserialize<ReachabilityPolicy>(root, JsonOptions)
?? ReachabilityPolicyDefaults.Default;
}
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
options.Converters.Add(new FlexibleBooleanConverter());
return options;
}
private sealed class FlexibleBooleanConverter : JsonConverter<bool>
{
public override bool Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.True => true,
JsonTokenType.False => false,
JsonTokenType.String when bool.TryParse(reader.GetString(), out var value) => value,
_ => throw new JsonException(
$"Expected boolean value or boolean string, got {reader.TokenType}.")
};
}
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
{
writer.WriteBooleanValue(value);
}
}
}

View File

@@ -0,0 +1,42 @@
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Scanner.Reachability.Dependencies;
internal static class ReachabilityReportBuilder
{
public static ReachabilityReport Build(
DependencyGraph graph,
Dictionary<string, ReachabilityStatus> results,
List<ReachabilityFinding> findings)
{
var total = results.Count;
var reachableCount = results.Values.Count(status =>
status == ReachabilityStatus.Reachable);
var unknownCount = results.Values.Count(status =>
status == ReachabilityStatus.Unknown);
var unreachableCount = results.Values.Count(status =>
status == ReachabilityStatus.Unreachable);
var reductionPercent = total == 0
? 0
: (double)unreachableCount / total * 100.0;
return new ReachabilityReport
{
Graph = graph,
ComponentReachability = results.ToImmutableDictionary(StringComparer.Ordinal),
Findings = findings
.OrderBy(finding => finding.ComponentRef, StringComparer.Ordinal)
.ToImmutableArray(),
Statistics = new ReachabilityStatistics
{
TotalComponents = total,
ReachableComponents = reachableCount,
UnreachableComponents = unreachableCount,
UnknownComponents = unknownCount,
VulnerabilityReductionPercent = reductionPercent
}
};
}
}

View File

@@ -0,0 +1,58 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Reachability.Dependencies.Reporting;
public sealed record DependencyReachabilityReport
{
public required DependencyReachabilitySummary Summary { get; init; }
public ImmutableArray<DependencyReachabilityComponent> Components { get; init; } = [];
public ImmutableArray<DependencyReachabilityVulnerabilityFinding> Vulnerabilities { get; init; } = [];
public ImmutableArray<DependencyReachabilityVulnerabilityFinding> FilteredVulnerabilities { get; init; } = [];
public ReachabilityAnalysisMode AnalysisMode { get; init; } = ReachabilityAnalysisMode.SbomOnly;
}
public sealed record DependencyReachabilitySummary
{
public required ReachabilityStatistics ComponentStatistics { get; init; }
public required VulnerabilityReachabilityStatistics VulnerabilityStatistics { get; init; }
public double FalsePositiveReductionPercent { get; init; }
}
public sealed record DependencyReachabilityComponent
{
public required string ComponentRef { get; init; }
public string? Purl { get; init; }
public required ReachabilityStatus Status { get; init; }
public ImmutableArray<string> Path { get; init; } = [];
public ImmutableArray<string> Conditions { get; init; } = [];
public string? Reason { get; init; }
}
public sealed record DependencyReachabilityVulnerabilityFinding
{
public required Guid CanonicalId { get; init; }
public required string VulnerabilityId { get; init; }
public required string Purl { get; init; }
public string? ComponentRef { get; init; }
public ReachabilityStatus Status { get; init; }
public ReachabilityStatus RawStatus { get; init; }
public bool IsReachable { get; init; }
public bool IsFiltered { get; init; }
public double Confidence { get; init; }
public string? OriginalSeverity { get; init; }
public string? AdjustedSeverity { get; init; }
public string? AffectedVersions { get; init; }
public string? Title { get; init; }
public string? Summary { get; init; }
public ImmutableArray<string> ReachabilityPath { get; init; } = [];
}
public sealed record DependencyReachabilityAdvisorySummary
{
public required Guid CanonicalId { get; init; }
public required string VulnerabilityId { get; init; }
public string? Severity { get; init; }
public string? Title { get; init; }
public string? Summary { get; init; }
public string? AffectedVersions { get; init; }
}

Some files were not shown because too many files have changed in this diff Show More