tests fixes and sprints work
This commit is contained in:
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -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('-')}";
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = []
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user