save progress

This commit is contained in:
master
2026-01-09 18:27:36 +02:00
parent e608752924
commit a21d3dbc1f
361 changed files with 63068 additions and 1192 deletions

View File

@@ -14,6 +14,7 @@ using ProtoSerializer = CycloneDX.Protobuf.Serializer;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
using StellaOps.Scanner.Emit.Evidence;
using StellaOps.Scanner.Emit.Pedigree;
namespace StellaOps.Scanner.Emit.Composition;
@@ -178,7 +179,7 @@ public sealed class CycloneDxComposer
SpecVersion = SpecificationVersion.v1_6,
Version = 1,
Metadata = BuildMetadata(request, view, generatedAt),
Components = BuildComponents(components),
Components = BuildComponents(request, components),
Dependencies = BuildDependencies(components),
};
@@ -318,9 +319,19 @@ public sealed class CycloneDxComposer
return purlBuilder.ToString();
}
private static List<Component> BuildComponents(ImmutableArray<AggregatedComponent> components)
/// <summary>
/// Builds CycloneDX component models from aggregated components.
/// Sprint: SPRINT_20260107_005_002 Task PD-009 - Added pedigree support.
/// </summary>
private static List<Component> BuildComponents(
SbomCompositionRequest request,
ImmutableArray<AggregatedComponent> components)
{
var evidenceMapper = new CycloneDxEvidenceMapper();
var pedigreeMapper = request.IncludePedigree && request.PedigreeDataByPurl is not null
? new CycloneDxPedigreeMapper()
: null;
var result = new List<Component>(components.Length);
foreach (var component in components)
{
@@ -337,6 +348,16 @@ public sealed class CycloneDxComposer
Evidence = evidenceMapper.Map(component),
};
// Apply pedigree data if available and enabled
// Sprint: SPRINT_20260107_005_002 Task PD-009
if (pedigreeMapper is not null && !string.IsNullOrEmpty(component.Identity.Purl))
{
if (request.PedigreeDataByPurl!.TryGetValue(component.Identity.Purl, out var pedigreeData))
{
model.Pedigree = pedigreeMapper.Map(pedigreeData);
}
}
result.Add(model);
}

View File

@@ -4,6 +4,7 @@ using System.Collections.Immutable;
using System.Linq;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
using StellaOps.Scanner.Emit.Pedigree;
namespace StellaOps.Scanner.Emit.Composition;
@@ -45,6 +46,21 @@ public sealed record SbomCompositionRequest
public ImmutableArray<SbomPolicyFinding> PolicyFindings { get; init; }
= ImmutableArray<SbomPolicyFinding>.Empty;
/// <summary>
/// Gets the pre-fetched pedigree data keyed by component PURL.
/// This enables synchronous composition while allowing async pedigree lookups
/// to happen before calling <see cref="CycloneDxComposer.Compose"/>.
/// Sprint: SPRINT_20260107_005_002 Task PD-009
/// </summary>
public IReadOnlyDictionary<string, PedigreeData>? PedigreeDataByPurl { get; init; }
= null;
/// <summary>
/// Gets whether pedigree data should be included in the SBOM.
/// Defaults to true if pedigree data is provided.
/// </summary>
public bool IncludePedigree { get; init; } = true;
public static SbomCompositionRequest Create(
ImageArtifactDescriptor image,
IEnumerable<LayerComponentFragment> fragments,
@@ -52,7 +68,9 @@ public sealed record SbomCompositionRequest
string? generatorName = null,
string? generatorVersion = null,
IReadOnlyDictionary<string, string>? properties = null,
IEnumerable<SbomPolicyFinding>? policyFindings = null)
IEnumerable<SbomPolicyFinding>? policyFindings = null,
IReadOnlyDictionary<string, PedigreeData>? pedigreeData = null,
bool includePedigree = true)
{
ArgumentNullException.ThrowIfNull(image);
ArgumentNullException.ThrowIfNull(fragments);
@@ -75,6 +93,8 @@ public sealed record SbomCompositionRequest
GeneratorVersion = Normalize(generatorVersion),
AdditionalProperties = properties,
PolicyFindings = NormalizePolicyFindings(policyFindings),
PedigreeDataByPurl = pedigreeData,
IncludePedigree = includePedigree,
};
}

View File

@@ -0,0 +1,604 @@
// <copyright file="SbomValidationPipeline.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Diagnostics.Metrics;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Validation;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Pipeline configuration for SBOM validation after generation.
/// </summary>
public sealed class SbomValidationPipelineOptions
{
/// <summary>
/// Whether validation is enabled. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Whether to fail composition when validation fails. Default: true.
/// </summary>
public bool FailOnError { get; set; } = true;
/// <summary>
/// Whether to validate CycloneDX SBOMs. Default: true.
/// </summary>
public bool ValidateCycloneDx { get; set; } = true;
/// <summary>
/// Whether to validate SPDX SBOMs. Default: true.
/// </summary>
public bool ValidateSpdx { get; set; } = true;
/// <summary>
/// Timeout for validation operations. Default: 60 seconds.
/// </summary>
public TimeSpan ValidationTimeout { get; set; } = TimeSpan.FromSeconds(60);
}
/// <summary>
/// Result of SBOM validation pipeline execution.
/// </summary>
public sealed record SbomValidationPipelineResult
{
/// <summary>
/// Gets whether all validations passed.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Gets the CycloneDX inventory validation result, if performed.
/// </summary>
public SbomValidationResult? CycloneDxInventoryResult { get; init; }
/// <summary>
/// Gets the CycloneDX usage validation result, if performed.
/// </summary>
public SbomValidationResult? CycloneDxUsageResult { get; init; }
/// <summary>
/// Gets the SPDX inventory validation result, if performed.
/// </summary>
public SbomValidationResult? SpdxInventoryResult { get; init; }
/// <summary>
/// Gets the per-layer validation results, if performed.
/// </summary>
public ImmutableArray<LayerValidationResult> LayerResults { get; init; } = [];
/// <summary>
/// Gets the total number of errors across all validations.
/// </summary>
public int TotalErrorCount =>
(CycloneDxInventoryResult?.ErrorCount ?? 0) +
(CycloneDxUsageResult?.ErrorCount ?? 0) +
(SpdxInventoryResult?.ErrorCount ?? 0) +
LayerResults.Sum(r => r.CycloneDxResult?.ErrorCount ?? 0) +
LayerResults.Sum(r => r.SpdxResult?.ErrorCount ?? 0);
/// <summary>
/// Gets the total number of warnings across all validations.
/// </summary>
public int TotalWarningCount =>
(CycloneDxInventoryResult?.WarningCount ?? 0) +
(CycloneDxUsageResult?.WarningCount ?? 0) +
(SpdxInventoryResult?.WarningCount ?? 0) +
LayerResults.Sum(r => r.CycloneDxResult?.WarningCount ?? 0) +
LayerResults.Sum(r => r.SpdxResult?.WarningCount ?? 0);
/// <summary>
/// Gets whether validation was skipped entirely.
/// </summary>
public bool WasSkipped { get; init; }
/// <summary>
/// Creates a successful validation result.
/// </summary>
public static SbomValidationPipelineResult Success(
SbomValidationResult? cycloneDxInventory = null,
SbomValidationResult? cycloneDxUsage = null,
SbomValidationResult? spdxInventory = null,
ImmutableArray<LayerValidationResult>? layerResults = null) =>
new()
{
IsValid = true,
CycloneDxInventoryResult = cycloneDxInventory,
CycloneDxUsageResult = cycloneDxUsage,
SpdxInventoryResult = spdxInventory,
LayerResults = layerResults ?? []
};
/// <summary>
/// Creates a failed validation result.
/// </summary>
public static SbomValidationPipelineResult Failure(
SbomValidationResult? cycloneDxInventory = null,
SbomValidationResult? cycloneDxUsage = null,
SbomValidationResult? spdxInventory = null,
ImmutableArray<LayerValidationResult>? layerResults = null) =>
new()
{
IsValid = false,
CycloneDxInventoryResult = cycloneDxInventory,
CycloneDxUsageResult = cycloneDxUsage,
SpdxInventoryResult = spdxInventory,
LayerResults = layerResults ?? []
};
/// <summary>
/// Creates a skipped validation result.
/// </summary>
public static SbomValidationPipelineResult Skipped() =>
new() { IsValid = true, WasSkipped = true };
}
/// <summary>
/// Validation result for a single layer.
/// </summary>
public sealed record LayerValidationResult
{
/// <summary>
/// Gets the layer identifier (digest or index).
/// </summary>
public required string LayerId { get; init; }
/// <summary>
/// Gets the CycloneDX validation result for this layer.
/// </summary>
public SbomValidationResult? CycloneDxResult { get; init; }
/// <summary>
/// Gets the SPDX validation result for this layer.
/// </summary>
public SbomValidationResult? SpdxResult { get; init; }
/// <summary>
/// Gets whether this layer's validation passed.
/// </summary>
public bool IsValid =>
(CycloneDxResult?.IsValid ?? true) &&
(SpdxResult?.IsValid ?? true);
}
/// <summary>
/// Pipeline for validating generated SBOMs.
/// </summary>
/// <remarks>
/// Sprint: SPRINT_20260107_005_003 Task VG-005
/// This pipeline runs validation after SBOM generation and can optionally
/// fail the composition if validation errors are detected.
/// </remarks>
public sealed class SbomValidationPipeline
{
private readonly ISbomValidator _validator;
private readonly IOptions<SbomValidationPipelineOptions> _options;
private readonly ILogger<SbomValidationPipeline> _logger;
private readonly TimeProvider _timeProvider;
// Metrics
private readonly Counter<long> _validationRuns;
private readonly Counter<long> _validationPassed;
private readonly Counter<long> _validationFailed;
private readonly Counter<long> _validationSkipped;
private readonly Histogram<double> _validationDuration;
/// <summary>
/// Initializes a new instance of the <see cref="SbomValidationPipeline"/> class.
/// </summary>
public SbomValidationPipeline(
ISbomValidator validator,
IOptions<SbomValidationPipelineOptions> options,
ILogger<SbomValidationPipeline> logger,
TimeProvider timeProvider,
IMeterFactory? meterFactory = null)
{
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
// Initialize metrics
var meter = meterFactory?.Create("StellaOps.Scanner.Validation") ??
new Meter("StellaOps.Scanner.Validation");
_validationRuns = meter.CreateCounter<long>(
"sbom.validation.runs",
"runs",
"Total number of validation pipeline runs");
_validationPassed = meter.CreateCounter<long>(
"sbom.validation.passed",
"runs",
"Number of validation runs that passed");
_validationFailed = meter.CreateCounter<long>(
"sbom.validation.failed",
"runs",
"Number of validation runs that failed");
_validationSkipped = meter.CreateCounter<long>(
"sbom.validation.skipped",
"runs",
"Number of validation runs that were skipped");
_validationDuration = meter.CreateHistogram<double>(
"sbom.validation.duration",
"ms",
"Duration of validation pipeline execution");
}
/// <summary>
/// Validates a composition result.
/// </summary>
/// <param name="result">The composition result to validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The validation pipeline result.</returns>
/// <exception cref="SbomValidationException">
/// Thrown when validation fails and <see cref="SbomValidationPipelineOptions.FailOnError"/> is true.
/// </exception>
public async Task<SbomValidationPipelineResult> ValidateAsync(
SbomCompositionResult result,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
var opts = _options.Value;
var startTime = _timeProvider.GetTimestamp();
_validationRuns.Add(1);
try
{
if (!opts.Enabled)
{
_logger.LogDebug("SBOM validation is disabled, skipping");
_validationSkipped.Add(1);
return SbomValidationPipelineResult.Skipped();
}
_logger.LogInformation("Starting SBOM validation pipeline");
var validationOptions = new SbomValidationOptions
{
Timeout = opts.ValidationTimeout
};
// Validate main SBOMs in parallel
var tasks = new List<Task<(string Name, SbomValidationResult? Result)>>();
if (opts.ValidateCycloneDx)
{
tasks.Add(ValidateCycloneDxAsync(
"CycloneDX-Inventory",
result.Inventory.JsonBytes,
validationOptions,
cancellationToken));
if (result.Usage is not null)
{
tasks.Add(ValidateCycloneDxAsync(
"CycloneDX-Usage",
result.Usage.JsonBytes,
validationOptions,
cancellationToken));
}
}
if (opts.ValidateSpdx && result.SpdxInventory is not null)
{
tasks.Add(ValidateSpdxAsync(
"SPDX-Inventory",
result.SpdxInventory.JsonBytes,
validationOptions,
cancellationToken));
}
var mainResults = await Task.WhenAll(tasks).ConfigureAwait(false);
// Extract results by name
SbomValidationResult? cdxInventory = null;
SbomValidationResult? cdxUsage = null;
SbomValidationResult? spdxInventory = null;
foreach (var (name, validationResult) in mainResults)
{
switch (name)
{
case "CycloneDX-Inventory":
cdxInventory = validationResult;
break;
case "CycloneDX-Usage":
cdxUsage = validationResult;
break;
case "SPDX-Inventory":
spdxInventory = validationResult;
break;
}
}
// Validate layer SBOMs if present
var layerResults = await ValidateLayersAsync(
result.LayerSbomArtifacts,
validationOptions,
opts,
cancellationToken).ConfigureAwait(false);
// Determine overall validity
var allValid =
(cdxInventory?.IsValid ?? true) &&
(cdxUsage?.IsValid ?? true) &&
(spdxInventory?.IsValid ?? true) &&
layerResults.All(r => r.IsValid);
var pipelineResult = allValid
? SbomValidationPipelineResult.Success(cdxInventory, cdxUsage, spdxInventory, layerResults)
: SbomValidationPipelineResult.Failure(cdxInventory, cdxUsage, spdxInventory, layerResults);
// Log summary
LogValidationSummary(pipelineResult);
// Update metrics
if (allValid)
{
_validationPassed.Add(1);
}
else
{
_validationFailed.Add(1);
}
// Throw if configured to fail on error
if (!allValid && opts.FailOnError)
{
throw new SbomValidationException(
$"SBOM validation failed with {pipelineResult.TotalErrorCount} error(s)",
pipelineResult);
}
return pipelineResult;
}
finally
{
var elapsed = _timeProvider.GetElapsedTime(startTime);
_validationDuration.Record(elapsed.TotalMilliseconds);
}
}
private async Task<(string Name, SbomValidationResult? Result)> ValidateCycloneDxAsync(
string name,
byte[] content,
SbomValidationOptions options,
CancellationToken cancellationToken)
{
try
{
_logger.LogDebug("Validating {Name} ({Size} bytes)", name, content.Length);
var result = await _validator.ValidateAsync(
content,
SbomFormat.CycloneDxJson,
options,
cancellationToken).ConfigureAwait(false);
LogValidationResult(name, result);
return (name, result);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to validate {Name}: {Message}", name, ex.Message);
return (name, SbomValidationResult.ValidatorUnavailable(
SbomFormat.CycloneDxJson,
"CycloneDX",
ex.Message));
}
}
private async Task<(string Name, SbomValidationResult? Result)> ValidateSpdxAsync(
string name,
byte[] content,
SbomValidationOptions options,
CancellationToken cancellationToken)
{
try
{
_logger.LogDebug("Validating {Name} ({Size} bytes)", name, content.Length);
var result = await _validator.ValidateAsync(
content,
SbomFormat.Spdx3JsonLd,
options,
cancellationToken).ConfigureAwait(false);
LogValidationResult(name, result);
return (name, result);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to validate {Name}: {Message}", name, ex.Message);
return (name, SbomValidationResult.ValidatorUnavailable(
SbomFormat.Spdx3JsonLd,
"SPDX",
ex.Message));
}
}
private async Task<ImmutableArray<LayerValidationResult>> ValidateLayersAsync(
ImmutableArray<LayerSbomArtifact> layerArtifacts,
SbomValidationOptions options,
SbomValidationPipelineOptions pipelineOptions,
CancellationToken cancellationToken)
{
if (layerArtifacts.IsDefaultOrEmpty)
{
return [];
}
_logger.LogDebug("Validating {Count} layer SBOMs", layerArtifacts.Length);
var results = new List<LayerValidationResult>();
foreach (var layer in layerArtifacts)
{
SbomValidationResult? cdxResult = null;
SbomValidationResult? spdxResult = null;
if (pipelineOptions.ValidateCycloneDx && layer.CycloneDxJsonBytes is not null)
{
var (_, result) = await ValidateCycloneDxAsync(
$"Layer-{layer.LayerDigest}-CDX",
layer.CycloneDxJsonBytes,
options,
cancellationToken).ConfigureAwait(false);
cdxResult = result;
}
if (pipelineOptions.ValidateSpdx && layer.SpdxJsonBytes is not null)
{
var (_, result) = await ValidateSpdxAsync(
$"Layer-{layer.LayerDigest}-SPDX",
layer.SpdxJsonBytes,
options,
cancellationToken).ConfigureAwait(false);
spdxResult = result;
}
results.Add(new LayerValidationResult
{
LayerId = layer.LayerDigest,
CycloneDxResult = cdxResult,
SpdxResult = spdxResult
});
}
return [.. results];
}
private void LogValidationResult(string name, SbomValidationResult result)
{
if (result.IsValid)
{
if (result.WarningCount > 0)
{
_logger.LogInformation(
"{Name} validation passed with {WarningCount} warning(s)",
name,
result.WarningCount);
}
else
{
_logger.LogInformation("{Name} validation passed", name);
}
}
else
{
_logger.LogWarning(
"{Name} validation failed with {ErrorCount} error(s), {WarningCount} warning(s)",
name,
result.ErrorCount,
result.WarningCount);
foreach (var diagnostic in result.Diagnostics.Where(d => d.Severity == SbomValidationSeverity.Error))
{
_logger.LogWarning(" [{Code}] {Message}", diagnostic.Code, diagnostic.Message);
}
}
}
private void LogValidationSummary(SbomValidationPipelineResult result)
{
var sb = new StringBuilder();
sb.AppendLine("SBOM validation summary:");
if (result.CycloneDxInventoryResult is not null)
{
sb.AppendLine($" - CycloneDX Inventory: {(result.CycloneDxInventoryResult.IsValid ? "PASSED" : "FAILED")}");
}
if (result.CycloneDxUsageResult is not null)
{
sb.AppendLine($" - CycloneDX Usage: {(result.CycloneDxUsageResult.IsValid ? "PASSED" : "FAILED")}");
}
if (result.SpdxInventoryResult is not null)
{
sb.AppendLine($" - SPDX Inventory: {(result.SpdxInventoryResult.IsValid ? "PASSED" : "FAILED")}");
}
if (!result.LayerResults.IsDefaultOrEmpty)
{
var passedLayers = result.LayerResults.Count(r => r.IsValid);
sb.AppendLine($" - Layers: {passedLayers}/{result.LayerResults.Length} passed");
}
sb.AppendLine($" Total errors: {result.TotalErrorCount}");
sb.AppendLine($" Total warnings: {result.TotalWarningCount}");
_logger.LogInformation(sb.ToString());
}
}
/// <summary>
/// Exception thrown when SBOM validation fails.
/// </summary>
public sealed class SbomValidationException : Exception
{
/// <summary>
/// Gets the validation pipeline result.
/// </summary>
public SbomValidationPipelineResult? Result { get; }
/// <summary>
/// Initializes a new instance of the <see cref="SbomValidationException"/> class.
/// </summary>
public SbomValidationException(string message)
: base(message)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SbomValidationException"/> class.
/// </summary>
public SbomValidationException(string message, SbomValidationPipelineResult result)
: base(message)
{
Result = result;
}
/// <summary>
/// Initializes a new instance of the <see cref="SbomValidationException"/> class.
/// </summary>
public SbomValidationException(string message, Exception innerException)
: base(message, innerException)
{
}
}
/// <summary>
/// Extension methods for registering the validation pipeline.
/// </summary>
public static class SbomValidationPipelineExtensions
{
/// <summary>
/// Adds the SBOM validation pipeline to the service collection.
/// </summary>
public static IServiceCollection AddSbomValidationPipeline(
this IServiceCollection services,
Action<SbomValidationPipelineOptions>? configure = null)
{
services.AddOptions<SbomValidationPipelineOptions>()
.Configure(configure ?? (_ => { }))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddSingleton<SbomValidationPipeline>();
return services;
}
}

View File

@@ -3,11 +3,12 @@
// </copyright>
using CycloneDX.Models;
using CdxPedigree = CycloneDX.Models.Pedigree;
namespace StellaOps.Scanner.Emit.Pedigree;
/// <summary>
/// Maps <see cref="PedigreeData"/> to CycloneDX <see cref="Pedigree"/> model.
/// Maps <see cref="PedigreeData"/> to CycloneDX <see cref="CdxPedigree"/> model.
/// Sprint: SPRINT_20260107_005_002 Task PD-003
/// </summary>
public sealed class CycloneDxPedigreeMapper
@@ -17,14 +18,14 @@ public sealed class CycloneDxPedigreeMapper
/// </summary>
/// <param name="data">The pedigree data to map.</param>
/// <returns>CycloneDX pedigree model, or null if no data.</returns>
public Pedigree? Map(PedigreeData? data)
public CdxPedigree? Map(PedigreeData? data)
{
if (data is null || !data.HasData)
{
return null;
}
return new Pedigree
return new CdxPedigree
{
Ancestors = MapAncestors(data.Ancestors),
Variants = MapVariants(data.Variants),
@@ -158,7 +159,7 @@ public sealed class CycloneDxPedigreeMapper
{
Name = actor.Name,
Email = actor.Email,
Timestamp = actor.Timestamp
Timestamp = actor.Timestamp?.UtcDateTime
};
}

View File

@@ -10,6 +10,7 @@
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
<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.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
</ItemGroup>

View File

@@ -10,6 +10,7 @@ using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Encodings.Web;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -28,7 +29,8 @@ public sealed class DriftAttestationService : IDriftAttestationService
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private readonly IDriftSignerClient? _signerClient;

View File

@@ -0,0 +1,242 @@
// <copyright file="FingerprintGeneratorTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Scanner.Sarif.Fingerprints;
using StellaOps.Scanner.Sarif.Rules;
using Xunit;
namespace StellaOps.Scanner.Sarif.Tests;
/// <summary>
/// Tests for <see cref="FingerprintGenerator"/>.
/// </summary>
[Trait("Category", "Unit")]
public class FingerprintGeneratorTests
{
private readonly FingerprintGenerator _generator;
public FingerprintGeneratorTests()
{
_generator = new FingerprintGenerator(new SarifRuleRegistry());
}
[Fact]
public void GeneratePrimary_Standard_ReturnsDeterministicFingerprint()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability",
VulnerabilityId = "CVE-2024-1234",
ComponentPurl = "pkg:npm/lodash@4.17.20",
Severity = Severity.High
};
// Act
var fp1 = _generator.GeneratePrimary(finding, FingerprintStrategy.Standard);
var fp2 = _generator.GeneratePrimary(finding, FingerprintStrategy.Standard);
// Assert
fp1.Should().NotBeNullOrEmpty();
fp1.Should().Be(fp2, "fingerprints should be deterministic");
fp1.Should().HaveLength(64, "should be SHA-256 hex string");
}
[Fact]
public void GeneratePrimary_DifferentFindings_ProduceDifferentFingerprints()
{
// Arrange
var finding1 = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability 1",
VulnerabilityId = "CVE-2024-1234",
ComponentPurl = "pkg:npm/lodash@4.17.20",
Severity = Severity.High
};
var finding2 = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability 2",
VulnerabilityId = "CVE-2024-5678",
ComponentPurl = "pkg:npm/lodash@4.17.20",
Severity = Severity.High
};
// Act
var fp1 = _generator.GeneratePrimary(finding1, FingerprintStrategy.Standard);
var fp2 = _generator.GeneratePrimary(finding2, FingerprintStrategy.Standard);
// Assert
fp1.Should().NotBe(fp2);
}
[Fact]
public void GeneratePrimary_Minimal_UsesFewerFields()
{
// Arrange
var finding1 = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability",
VulnerabilityId = "CVE-2024-1234",
ComponentPurl = "pkg:npm/lodash@4.17.20",
Severity = Severity.High
};
var finding2 = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability",
VulnerabilityId = "CVE-2024-1234",
ComponentPurl = "pkg:npm/express@4.18.0", // Different component
Severity = Severity.High
};
// Act
var fp1Standard = _generator.GeneratePrimary(finding1, FingerprintStrategy.Standard);
var fp2Standard = _generator.GeneratePrimary(finding2, FingerprintStrategy.Standard);
var fp1Minimal = _generator.GeneratePrimary(finding1, FingerprintStrategy.Minimal);
var fp2Minimal = _generator.GeneratePrimary(finding2, FingerprintStrategy.Minimal);
// Assert
fp1Standard.Should().NotBe(fp2Standard, "standard fingerprints differ by component");
fp1Minimal.Should().Be(fp2Minimal, "minimal fingerprints ignore component");
}
[Fact]
public void GeneratePrimary_Extended_IncludesReachabilityAndVex()
{
// Arrange
// Use reachability statuses that don't affect the rule ID
var finding1 = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability",
VulnerabilityId = "CVE-2024-1234",
ComponentPurl = "pkg:npm/lodash@4.17.20",
Severity = Severity.High,
Reachability = ReachabilityStatus.Unknown,
VexStatus = VexStatus.Affected
};
var finding2 = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability",
VulnerabilityId = "CVE-2024-1234",
ComponentPurl = "pkg:npm/lodash@4.17.20",
Severity = Severity.High,
Reachability = ReachabilityStatus.Contested,
VexStatus = VexStatus.NotAffected
};
// Act
var fp1Standard = _generator.GeneratePrimary(finding1, FingerprintStrategy.Standard);
var fp2Standard = _generator.GeneratePrimary(finding2, FingerprintStrategy.Standard);
var fp1Extended = _generator.GeneratePrimary(finding1, FingerprintStrategy.Extended);
var fp2Extended = _generator.GeneratePrimary(finding2, FingerprintStrategy.Extended);
// Assert
fp1Standard.Should().Be(fp2Standard, "standard fingerprints ignore reachability/vex");
fp1Extended.Should().NotBe(fp2Extended, "extended fingerprints include reachability/vex");
}
[Fact]
public void GeneratePartial_WithComponent_IncludesComponentFingerprint()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
ComponentPurl = "pkg:npm/lodash@4.17.20"
};
// Act
var partials = _generator.GeneratePartial(finding);
// Assert
partials.Should().ContainKey("stellaops/component/v1");
}
[Fact]
public void GeneratePartial_WithVulnerability_IncludesVulnFingerprint()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
VulnerabilityId = "CVE-2024-1234"
};
// Act
var partials = _generator.GeneratePartial(finding);
// Assert
partials.Should().ContainKey("stellaops/vuln/v1");
}
[Fact]
public void GeneratePartial_WithLocation_IncludesLocationFingerprint()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
FilePath = "src/app.ts",
StartLine = 42
};
// Act
var partials = _generator.GeneratePartial(finding);
// Assert
partials.Should().ContainKey("primaryLocationLineHash/v1");
}
[Fact]
public void GeneratePartial_Secret_IncludesTitleFingerprint()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Secret,
Title = "AWS Access Key"
};
// Act
var partials = _generator.GeneratePartial(finding);
// Assert
partials.Should().ContainKey("stellaops/title/v1");
}
[Fact]
public void GeneratePartial_SameInputs_ProduceDeterministicResults()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
VulnerabilityId = "CVE-2024-1234",
ComponentPurl = "pkg:npm/lodash@4.17.20",
FilePath = "src/app.ts",
StartLine = 42
};
// Act
var partials1 = _generator.GeneratePartial(finding);
var partials2 = _generator.GeneratePartial(finding);
// Assert
partials1.Should().BeEquivalentTo(partials2);
}
}

View File

@@ -0,0 +1,463 @@
// <copyright file="SarifExportServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Sarif.Fingerprints;
using StellaOps.Scanner.Sarif.Models;
using StellaOps.Scanner.Sarif.Rules;
using Xunit;
namespace StellaOps.Scanner.Sarif.Tests;
/// <summary>
/// Tests for <see cref="SarifExportService"/>.
/// </summary>
[Trait("Category", "Unit")]
public class SarifExportServiceTests
{
private readonly SarifExportService _service;
private readonly FakeTimeProvider _timeProvider;
public SarifExportServiceTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 9, 12, 0, 0, TimeSpan.Zero));
var ruleRegistry = new SarifRuleRegistry();
var fingerprintGenerator = new FingerprintGenerator(ruleRegistry);
_service = new SarifExportService(ruleRegistry, fingerprintGenerator, _timeProvider);
}
[Fact]
public async Task ExportAsync_EmptyFindings_ReturnsValidSarifLog()
{
// Arrange
var options = new SarifExportOptions { ToolVersion = "1.0.0" };
// Act
var log = await _service.ExportAsync([], options, TestContext.Current.CancellationToken);
// Assert
log.Should().NotBeNull();
log.Version.Should().Be("2.1.0");
log.Schema.Should().Contain("sarif-schema-2.1.0.json");
log.Runs.Should().HaveCount(1);
log.Runs[0].Results.Should().BeEmpty();
}
[Fact]
public async Task ExportAsync_SingleVulnerability_MapsCorrectly()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Remote Code Execution",
VulnerabilityId = "CVE-2024-1234",
ComponentPurl = "pkg:npm/lodash@4.17.20",
ComponentName = "lodash",
ComponentVersion = "4.17.20",
Severity = Severity.Critical,
CvssScore = 9.8,
CvssVector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
FilePath = "package.json",
StartLine = 10
}
};
var options = new SarifExportOptions { ToolVersion = "1.0.0" };
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
log.Runs.Should().HaveCount(1);
var run = log.Runs[0];
// Check tool
run.Tool.Driver.Name.Should().Be("StellaOps Scanner");
run.Tool.Driver.Version.Should().Be("1.0.0");
run.Tool.Driver.Rules.Should().NotBeNull();
run.Tool.Driver.Rules!.Value.Should().Contain(r => r.Id == "STELLA-VULN-001");
// Check result
run.Results.Should().HaveCount(1);
var result = run.Results[0];
result.RuleId.Should().Be("STELLA-VULN-001");
result.Level.Should().Be(SarifLevel.Error);
result.Message.Text.Should().Contain("CVE-2024-1234");
result.Message.Text.Should().Contain("lodash@4.17.20");
result.Fingerprints.Should().ContainKey("stellaops/v1");
result.Properties.Should().ContainKey("stellaops/cvss/score");
}
[Fact]
public async Task ExportAsync_WithMinimumSeverity_FiltersResults()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Critical vuln",
Severity = Severity.Critical
},
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Low vuln",
Severity = Severity.Low
}
};
var options = new SarifExportOptions
{
ToolVersion = "1.0.0",
MinimumSeverity = Severity.High
};
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
log.Runs[0].Results.Should().HaveCount(1);
log.Runs[0].Results[0].RuleId.Should().Be("STELLA-VULN-001");
}
[Fact]
public async Task ExportAsync_WithVersionControl_IncludesProvenance()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
Severity = Severity.Medium
}
};
var options = new SarifExportOptions
{
ToolVersion = "1.0.0",
VersionControl = new VersionControlInfo
{
RepositoryUri = "https://github.com/org/repo",
RevisionId = "abc123",
Branch = "main"
}
};
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
log.Runs[0].VersionControlProvenance.Should().NotBeNull();
log.Runs[0].VersionControlProvenance!.Value.Should().HaveCount(1);
var vcs = log.Runs[0].VersionControlProvenance!.Value[0];
vcs.RepositoryUri.Should().Be("https://github.com/org/repo");
vcs.RevisionId.Should().Be("abc123");
vcs.Branch.Should().Be("main");
}
[Fact]
public async Task ExportAsync_WithReachability_IncludesInProperties()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
Severity = Severity.Medium,
Reachability = ReachabilityStatus.RuntimeReachable
}
};
var options = new SarifExportOptions
{
ToolVersion = "1.0.0",
IncludeReachability = true
};
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
var result = log.Runs[0].Results[0];
result.Properties.Should().ContainKey("stellaops/reachability");
result.Properties!["stellaops/reachability"].Should().Be("RuntimeReachable");
}
[Fact]
public async Task ExportAsync_WithVexStatus_IncludesInProperties()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
Severity = Severity.Medium,
VexStatus = VexStatus.NotAffected,
VexJustification = "component_not_present"
}
};
var options = new SarifExportOptions
{
ToolVersion = "1.0.0",
IncludeVexStatus = true
};
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
var result = log.Runs[0].Results[0];
result.Properties.Should().ContainKey("stellaops/vex/status");
result.Properties.Should().ContainKey("stellaops/vex/justification");
}
[Fact]
public async Task ExportAsync_WithKev_IncludesInProperties()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
Severity = Severity.Medium,
IsKev = true
}
};
var options = new SarifExportOptions
{
ToolVersion = "1.0.0",
IncludeKev = true
};
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
var result = log.Runs[0].Results[0];
result.Properties.Should().ContainKey("stellaops/kev");
result.Properties!["stellaops/kev"].Should().Be(true);
}
[Fact]
public async Task ExportAsync_SecretFinding_MapsCorrectly()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Secret,
Title = "AWS Access Key detected",
FilePath = "config/settings.py",
StartLine = 42,
StartColumn = 10,
EndLine = 42,
EndColumn = 30
}
};
var options = new SarifExportOptions { ToolVersion = "1.0.0" };
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
var result = log.Runs[0].Results[0];
result.RuleId.Should().Be("STELLA-SEC-001");
result.Level.Should().Be(SarifLevel.Error);
result.Locations.Should().NotBeNull();
result.Locations!.Value.Should().HaveCount(1);
var location = result.Locations!.Value[0];
location.PhysicalLocation!.ArtifactLocation.Uri.Should().Be("config/settings.py");
location.PhysicalLocation!.Region!.StartLine.Should().Be(42);
}
[Fact]
public async Task ExportToJsonAsync_ProducesValidJson()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability",
Severity = Severity.High
}
};
var options = new SarifExportOptions
{
ToolVersion = "1.0.0",
IndentedJson = true
};
// Act
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
json.Should().NotBeNullOrEmpty();
// Validate it's parseable JSON
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("version").GetString().Should().Be("2.1.0");
doc.RootElement.GetProperty("$schema").GetString().Should().Contain("sarif");
doc.RootElement.GetProperty("runs").GetArrayLength().Should().Be(1);
}
[Fact]
public async Task ExportToStreamAsync_WritesToStream()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
Severity = Severity.Medium
}
};
var options = new SarifExportOptions { ToolVersion = "1.0.0" };
using var stream = new MemoryStream();
// Act
await _service.ExportToStreamAsync(findings, options, stream, TestContext.Current.CancellationToken);
// Assert
stream.Length.Should().BeGreaterThan(0);
stream.Position = 0;
using var reader = new StreamReader(stream);
var json = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
json.Should().Contain("\"version\":\"2.1.0\"");
}
[Fact]
public async Task ExportAsync_ResultsAreSortedDeterministically()
{
// Arrange
var findings = new[]
{
new FindingInput { Type = FindingType.Vulnerability, Title = "Z", Severity = Severity.Low },
new FindingInput { Type = FindingType.Secret, Title = "A" },
new FindingInput { Type = FindingType.Vulnerability, Title = "M", Severity = Severity.Critical }
};
var options = new SarifExportOptions { ToolVersion = "1.0.0" };
// Act
var log1 = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
var log2 = await _service.ExportAsync(findings.Reverse(), options, TestContext.Current.CancellationToken);
// Assert
var ruleIds1 = log1.Runs[0].Results.Select(r => r.RuleId).ToList();
var ruleIds2 = log2.Runs[0].Results.Select(r => r.RuleId).ToList();
ruleIds1.Should().Equal(ruleIds2, "results should be sorted deterministically regardless of input order");
}
[Fact]
public async Task ExportAsync_PathNormalization_RemovesSourceRoot()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
Severity = Severity.Medium,
FilePath = "C:\\workspace\\src\\app.ts"
}
};
var options = new SarifExportOptions
{
ToolVersion = "1.0.0",
SourceRoot = "C:\\workspace"
};
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
var location = log.Runs[0].Results[0].Locations!.Value[0];
location.PhysicalLocation!.ArtifactLocation.Uri.Should().Be("src/app.ts");
}
[Fact]
public async Task ExportAsync_IncludesInvocationTimestamp()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
Severity = Severity.Medium
}
};
var options = new SarifExportOptions { ToolVersion = "1.0.0" };
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
var invocations = log.Runs[0].Invocations;
invocations.Should().NotBeNull();
invocations!.Value.Should().HaveCount(1);
var invocation = invocations!.Value[0];
invocation.ExecutionSuccessful.Should().BeTrue();
invocation.StartTimeUtc.Should().Be(_timeProvider.GetUtcNow());
}
[Fact]
public async Task ExportAsync_WithCategory_IncludesGitHubAlertCategory()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
Severity = Severity.Medium
}
};
var options = new SarifExportOptions
{
ToolVersion = "1.0.0",
Category = "security"
};
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
var result = log.Runs[0].Results[0];
result.Properties.Should().ContainKey("github/alertCategory");
result.Properties!["github/alertCategory"].Should().Be("security");
}
}

View File

@@ -0,0 +1,312 @@
// <copyright file="SarifGoldenFixtureTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Sarif.Fingerprints;
using StellaOps.Scanner.Sarif.Models;
using StellaOps.Scanner.Sarif.Rules;
using Xunit;
namespace StellaOps.Scanner.Sarif.Tests;
/// <summary>
/// Golden fixture tests for SARIF export validation.
/// These tests ensure generated SARIF matches expected structure and is valid.
/// </summary>
[Trait("Category", "Unit")]
public class SarifGoldenFixtureTests
{
private readonly SarifExportService _service;
private readonly FakeTimeProvider _timeProvider;
public SarifGoldenFixtureTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 9, 12, 0, 0, TimeSpan.Zero));
var ruleRegistry = new SarifRuleRegistry();
var fingerprintGenerator = new FingerprintGenerator(ruleRegistry);
_service = new SarifExportService(ruleRegistry, fingerprintGenerator, _timeProvider);
}
[Fact]
public async Task GoldenFixture_SingleVulnerability_ValidStructure()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "SQL Injection in user input handler",
VulnerabilityId = "CVE-2024-12345",
ComponentPurl = "pkg:npm/mysql@2.18.0",
ComponentName = "mysql",
ComponentVersion = "2.18.0",
Severity = Severity.High,
CvssScore = 8.5,
CvssVector = "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N",
FilePath = "src/db/connection.js",
StartLine = 42
}
};
var options = new SarifExportOptions
{
ToolVersion = "1.0.0",
ToolName = "StellaOps Scanner"
};
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert - SARIF 2.1.0 structure requirements
log.Version.Should().Be("2.1.0");
log.Schema.Should().Contain("sarif-schema");
log.Runs.Should().HaveCount(1);
var run = log.Runs[0];
run.Tool.Should().NotBeNull();
run.Tool.Driver.Should().NotBeNull();
run.Tool.Driver.Name.Should().Be("StellaOps Scanner");
run.Tool.Driver.Version.Should().Be("1.0.0");
run.Tool.Driver.InformationUri.Should().NotBeNull();
run.Tool.Driver.Rules.Should().NotBeNull();
run.Results.Should().HaveCount(1);
var result = run.Results[0];
result.RuleId.Should().StartWith("STELLA-");
result.Level.Should().Be(SarifLevel.Warning); // High severity maps to warning
result.Message.Should().NotBeNull();
result.Message.Text.Should().Contain("SQL Injection");
// Location validation
result.Locations.Should().NotBeNull();
result.Locations.Should().HaveCountGreaterThan(0);
var location = result.Locations!.Value[0];
location.PhysicalLocation.Should().NotBeNull();
location.PhysicalLocation!.ArtifactLocation.Should().NotBeNull();
location.PhysicalLocation.ArtifactLocation!.Uri.Should().Be("src/db/connection.js");
location.PhysicalLocation.Region.Should().NotBeNull();
location.PhysicalLocation.Region!.StartLine.Should().Be(42);
// Fingerprint validation
result.PartialFingerprints.Should().NotBeNull();
result.PartialFingerprints.Should().ContainKey("primaryLocationLineHash");
}
[Fact]
public async Task GoldenFixture_MixedSeverities_CorrectLevelMapping()
{
// Arrange
var findings = new[]
{
CreateFinding("CVE-2024-0001", "Critical Finding", Severity.Critical, 10.0),
CreateFinding("CVE-2024-0002", "High Finding", Severity.High, 8.0),
CreateFinding("CVE-2024-0003", "Medium Finding", Severity.Medium, 5.0),
CreateFinding("CVE-2024-0004", "Low Finding", Severity.Low, 2.0)
};
var options = new SarifExportOptions { ToolVersion = "1.0.0" };
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
log.Runs[0].Results.Should().HaveCount(4);
var results = log.Runs[0].Results;
results[0].Level.Should().Be(SarifLevel.Error); // Critical -> Error
results[1].Level.Should().Be(SarifLevel.Warning); // High -> Warning
results[2].Level.Should().Be(SarifLevel.Warning); // Medium -> Warning
results[3].Level.Should().Be(SarifLevel.Note); // Low -> Note
}
[Fact]
public async Task GoldenFixture_WithReachabilityData_IncludesProperties()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Prototype Pollution",
VulnerabilityId = "CVE-2024-5678",
ComponentPurl = "pkg:npm/lodash@4.17.20",
ComponentName = "lodash",
ComponentVersion = "4.17.20",
Severity = Severity.High,
CvssScore = 7.5,
FilePath = "package-lock.json",
StartLine = 100,
Reachability = ReachabilityStatus.StaticReachable
}
};
var options = new SarifExportOptions
{
ToolVersion = "1.0.0",
IncludeReachability = true
};
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
var result = log.Runs[0].Results[0];
// Properties should be set when reachability data is included
result.Should().NotBeNull();
}
[Fact]
public async Task GoldenFixture_WithVexStatus_IncludesData()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Known but not affected",
VulnerabilityId = "CVE-2024-9999",
ComponentPurl = "pkg:npm/test@1.0.0",
ComponentName = "test",
ComponentVersion = "1.0.0",
Severity = Severity.Medium,
CvssScore = 5.0,
FilePath = "package.json",
VexStatus = VexStatus.NotAffected,
VexJustification = "vulnerable_code_not_present"
}
};
var options = new SarifExportOptions
{
ToolVersion = "1.0.0"
};
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
var result = log.Runs[0].Results[0];
result.Should().NotBeNull();
// VEX data should be captured somehow in the result
}
[Fact]
public async Task GoldenFixture_SecretFinding_UsesCorrectRule()
{
// Arrange
var findings = new[]
{
new FindingInput
{
Type = FindingType.Secret,
Title = "AWS Access Key Exposed",
FilePath = "config/settings.py",
StartLine = 15,
Severity = Severity.Critical
}
};
var options = new SarifExportOptions { ToolVersion = "1.0.0" };
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
var result = log.Runs[0].Results[0];
result.RuleId.Should().StartWith("STELLA-SEC-");
result.Level.Should().Be(SarifLevel.Error); // Secrets are always error level
}
[Fact]
public async Task GoldenFixture_LargeBatch_ProcessesEfficiently()
{
// Arrange - Create 100 findings
var findings = Enumerable.Range(1, 100)
.Select(i => CreateFinding(
$"CVE-2024-{i:D5}",
$"Finding {i}",
(Severity)(i % 4 + 1),
(i % 10) + 1.0))
.ToArray();
var options = new SarifExportOptions { ToolVersion = "1.0.0" };
// Act
var log = await _service.ExportAsync(findings, options, TestContext.Current.CancellationToken);
// Assert
log.Runs[0].Results.Should().HaveCount(100);
// All should have unique fingerprints
var fingerprints = log.Runs[0].Results
.Where(r => r.PartialFingerprints != null)
.Select(r => r.PartialFingerprints!.GetValueOrDefault("primaryLocationLineHash"))
.Where(f => f != null)
.ToList();
fingerprints.Distinct().Count().Should().Be(fingerprints.Count);
}
[Fact]
public async Task GoldenFixture_JsonSerialization_ValidJson()
{
// Arrange
var findings = new[]
{
CreateFinding("CVE-2024-TEST", "Test vulnerability", Severity.Medium, 5.0)
};
var options = new SarifExportOptions { ToolVersion = "1.0.0" };
// Act
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
// Assert - Should be valid JSON
var parsed = JsonDocument.Parse(json);
parsed.RootElement.GetProperty("version").GetString().Should().Be("2.1.0");
parsed.RootElement.GetProperty("$schema").GetString().Should().Contain("sarif");
parsed.RootElement.GetProperty("runs").GetArrayLength().Should().Be(1);
}
[Fact]
public async Task GoldenFixture_DeterministicOutput_SameInputSameOutput()
{
// Arrange
var findings = new[]
{
CreateFinding("CVE-2024-DET", "Determinism test", Severity.High, 7.5)
};
var options = new SarifExportOptions { ToolVersion = "1.0.0" };
// Act - Export twice
var json1 = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
var json2 = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
// Assert - Should be identical
json1.Should().Be(json2);
}
private static FindingInput CreateFinding(string cveId, string title, Severity severity, double cvssScore)
{
return new FindingInput
{
Type = FindingType.Vulnerability,
Title = title,
VulnerabilityId = cveId,
ComponentPurl = $"pkg:npm/test-{cveId}@1.0.0",
ComponentName = $"test-{cveId}",
ComponentVersion = "1.0.0",
Severity = severity,
CvssScore = cvssScore,
FilePath = $"package-{cveId}.json",
StartLine = Math.Abs(cveId.GetHashCode() % 1000) + 1
};
}
}

View File

@@ -0,0 +1,255 @@
// <copyright file="SarifRuleRegistryTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Scanner.Sarif.Models;
using StellaOps.Scanner.Sarif.Rules;
using Xunit;
namespace StellaOps.Scanner.Sarif.Tests;
/// <summary>
/// Tests for <see cref="SarifRuleRegistry"/>.
/// </summary>
[Trait("Category", "Unit")]
public class SarifRuleRegistryTests
{
private readonly SarifRuleRegistry _registry = new();
[Theory]
[InlineData(Severity.Critical, "STELLA-VULN-001")]
[InlineData(Severity.High, "STELLA-VULN-002")]
[InlineData(Severity.Medium, "STELLA-VULN-003")]
[InlineData(Severity.Low, "STELLA-VULN-004")]
public void GetRuleId_Vulnerability_MapsSeverityCorrectly(Severity severity, string expectedRuleId)
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability",
Severity = severity
};
// Act
var ruleId = _registry.GetRuleId(finding);
// Assert
ruleId.Should().Be(expectedRuleId);
}
[Fact]
public void GetRuleId_RuntimeReachable_ReturnsReachabilityRule()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability",
Severity = Severity.Low,
Reachability = ReachabilityStatus.RuntimeReachable
};
// Act
var ruleId = _registry.GetRuleId(finding);
// Assert
ruleId.Should().Be("STELLA-VULN-005");
}
[Fact]
public void GetRuleId_StaticReachable_ReturnsStaticReachabilityRule()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test vulnerability",
Severity = Severity.Low,
Reachability = ReachabilityStatus.StaticReachable
};
// Act
var ruleId = _registry.GetRuleId(finding);
// Assert
ruleId.Should().Be("STELLA-VULN-006");
}
[Fact]
public void GetRuleId_Secret_ReturnsSecretRule()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Secret,
Title = "API key detected"
};
// Act
var ruleId = _registry.GetRuleId(finding);
// Assert
ruleId.Should().Be("STELLA-SEC-001");
}
[Fact]
public void GetRuleId_PrivateKey_ReturnsPrivateKeyRule()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Secret,
Title = "Private key exposed in repository"
};
// Act
var ruleId = _registry.GetRuleId(finding);
// Assert
ruleId.Should().Be("STELLA-SEC-002");
}
[Fact]
public void GetRuleId_SupplyChain_ReturnsSupplyChainRule()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.SupplyChain,
Title = "Unsigned package"
};
// Act
var ruleId = _registry.GetRuleId(finding);
// Assert
ruleId.Should().Be("STELLA-SC-001");
}
[Fact]
public void GetRuleId_Typosquat_ReturnsTyposquatRule()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.SupplyChain,
Title = "Potential typosquat: lodasj"
};
// Act
var ruleId = _registry.GetRuleId(finding);
// Assert
ruleId.Should().Be("STELLA-SC-003");
}
[Theory]
[InlineData(Severity.Critical, SarifLevel.Error)]
[InlineData(Severity.High, SarifLevel.Error)]
[InlineData(Severity.Medium, SarifLevel.Warning)]
[InlineData(Severity.Low, SarifLevel.Note)]
public void GetLevel_MapsSeverityToLevel(Severity severity, SarifLevel expectedLevel)
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
Severity = severity
};
// Act
var level = _registry.GetLevel(finding);
// Assert
level.Should().Be(expectedLevel);
}
[Fact]
public void GetLevel_KevElevates_ToError()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
Severity = Severity.Low,
IsKev = true
};
// Act
var level = _registry.GetLevel(finding);
// Assert
level.Should().Be(SarifLevel.Error);
}
[Fact]
public void GetLevel_RuntimeReachable_ElevatesToError()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Test",
Severity = Severity.Low,
Reachability = ReachabilityStatus.RuntimeReachable
};
// Act
var level = _registry.GetLevel(finding);
// Assert
level.Should().Be(SarifLevel.Error);
}
[Fact]
public void GetAllRules_ReturnsAllDefinedRules()
{
// Act
var rules = _registry.GetAllRules();
// Assert
rules.Should().NotBeEmpty();
rules.Should().Contain(r => r.Id == "STELLA-VULN-001");
rules.Should().Contain(r => r.Id == "STELLA-SEC-001");
rules.Should().Contain(r => r.Id == "STELLA-SC-001");
rules.Should().Contain(r => r.Id == "STELLA-BIN-001");
}
[Fact]
public void GetRulesByType_Vulnerability_ReturnsVulnerabilityRules()
{
// Act
var rules = _registry.GetRulesByType(FindingType.Vulnerability);
// Assert
rules.Should().NotBeEmpty();
rules.Should().OnlyContain(r => r.Id.StartsWith("STELLA-VULN-", StringComparison.Ordinal));
}
[Fact]
public void GetRule_ReturnsRuleDefinition()
{
// Arrange
var finding = new FindingInput
{
Type = FindingType.Vulnerability,
Title = "Critical CVE",
Severity = Severity.Critical
};
// Act
var rule = _registry.GetRule(finding);
// Assert
rule.Should().NotBeNull();
rule.Id.Should().Be("STELLA-VULN-001");
rule.Name.Should().Be("CriticalVulnerability");
rule.ShortDescription.Should().NotBeNull();
rule.DefaultConfiguration.Should().NotBeNull();
rule.DefaultConfiguration!.Level.Should().Be(SarifLevel.Error);
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Sarif\StellaOps.Scanner.Sarif.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,208 @@
// <copyright file="FindingInput.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Scanner.Sarif;
/// <summary>
/// Input model for a finding to be exported to SARIF.
/// Sprint: SPRINT_20260109_010_001 Task: Implement findings mapper
/// </summary>
public sealed record FindingInput
{
/// <summary>
/// Gets the finding type.
/// </summary>
public required FindingType Type { get; init; }
/// <summary>
/// Gets the vulnerability ID (CVE, GHSA, etc.) if applicable.
/// </summary>
public string? VulnerabilityId { get; init; }
/// <summary>
/// Gets the component Package URL.
/// </summary>
public string? ComponentPurl { get; init; }
/// <summary>
/// Gets the component name.
/// </summary>
public string? ComponentName { get; init; }
/// <summary>
/// Gets the component version.
/// </summary>
public string? ComponentVersion { get; init; }
/// <summary>
/// Gets the severity.
/// </summary>
public Severity Severity { get; init; } = Severity.Unknown;
/// <summary>
/// Gets the CVSS v3 score (0.0-10.0).
/// </summary>
public double? CvssScore { get; init; }
/// <summary>
/// Gets the CVSS v3 vector string.
/// </summary>
public string? CvssVector { get; init; }
/// <summary>
/// Gets the EPSS probability (0.0-1.0).
/// </summary>
public double? EpssProbability { get; init; }
/// <summary>
/// Gets the EPSS percentile (0.0-1.0).
/// </summary>
public double? EpssPercentile { get; init; }
/// <summary>
/// Gets whether this is in the KEV catalog.
/// </summary>
public bool IsKev { get; init; }
/// <summary>
/// Gets the finding title/summary.
/// </summary>
public required string Title { get; init; }
/// <summary>
/// Gets the detailed description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Gets the recommendation/remediation.
/// </summary>
public string? Recommendation { get; init; }
/// <summary>
/// Gets the file path where the finding was detected.
/// </summary>
public string? FilePath { get; init; }
/// <summary>
/// Gets the start line number (1-based).
/// </summary>
public int? StartLine { get; init; }
/// <summary>
/// Gets the end line number (1-based).
/// </summary>
public int? EndLine { get; init; }
/// <summary>
/// Gets the start column (1-based).
/// </summary>
public int? StartColumn { get; init; }
/// <summary>
/// Gets the end column (1-based).
/// </summary>
public int? EndColumn { get; init; }
/// <summary>
/// Gets the artifact digest (sha256:...).
/// </summary>
public string? ArtifactDigest { get; init; }
/// <summary>
/// Gets the reachability status.
/// </summary>
public ReachabilityStatus? Reachability { get; init; }
/// <summary>
/// Gets the VEX status.
/// </summary>
public VexStatus? VexStatus { get; init; }
/// <summary>
/// Gets VEX justification.
/// </summary>
public string? VexJustification { get; init; }
/// <summary>
/// Gets StellaOps evidence URIs.
/// </summary>
public IReadOnlyList<string>? EvidenceUris { get; init; }
/// <summary>
/// Gets attestation digests.
/// </summary>
public IReadOnlyList<string>? AttestationDigests { get; init; }
/// <summary>
/// Gets custom properties to include.
/// </summary>
public IReadOnlyDictionary<string, object>? Properties { get; init; }
}
/// <summary>
/// Type of finding.
/// </summary>
public enum FindingType
{
/// <summary>Software vulnerability (CVE, GHSA, etc.).</summary>
Vulnerability,
/// <summary>Hardcoded secret or credential.</summary>
Secret,
/// <summary>Supply chain issue (unsigned, unknown provenance, etc.).</summary>
SupplyChain,
/// <summary>Binary hardening issue.</summary>
BinaryHardening,
/// <summary>License compliance issue.</summary>
License,
/// <summary>Configuration issue.</summary>
Configuration
}
/// <summary>
/// Reachability status.
/// </summary>
public enum ReachabilityStatus
{
/// <summary>Not analyzed.</summary>
Unknown,
/// <summary>Statically reachable.</summary>
StaticReachable,
/// <summary>Statically unreachable.</summary>
StaticUnreachable,
/// <summary>Confirmed reachable at runtime.</summary>
RuntimeReachable,
/// <summary>Confirmed unreachable at runtime.</summary>
RuntimeUnreachable,
/// <summary>Conflicting evidence.</summary>
Contested
}
/// <summary>
/// VEX status.
/// </summary>
public enum VexStatus
{
/// <summary>Affected by the vulnerability.</summary>
Affected,
/// <summary>Not affected by the vulnerability.</summary>
NotAffected,
/// <summary>Fixed in this version.</summary>
Fixed,
/// <summary>Under investigation.</summary>
UnderInvestigation
}

View File

@@ -0,0 +1,139 @@
// <copyright file="FingerprintGenerator.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Security.Cryptography;
using System.Text;
using StellaOps.Scanner.Sarif.Rules;
namespace StellaOps.Scanner.Sarif.Fingerprints;
/// <summary>
/// Default implementation of <see cref="IFingerprintGenerator"/>.
/// Sprint: SPRINT_20260109_010_001 Task: Implement fingerprint generator
/// </summary>
/// <remarks>
/// Fingerprint algorithms:
/// - stellaops/v1 (Standard): SHA-256(ruleId | componentPurl | vulnId | artifactDigest)
/// - stellaops/minimal (Minimal): SHA-256(ruleId | vulnId)
/// - stellaops/extended (Extended): SHA-256(ruleId | componentPurl | vulnId | artifactDigest | reachability | vexStatus)
/// </remarks>
public sealed class FingerprintGenerator : IFingerprintGenerator
{
private const string FingerprintVersion = "stellaops/v1";
private const char Separator = '|';
private readonly ISarifRuleRegistry _ruleRegistry;
/// <summary>
/// Initializes a new instance of the <see cref="FingerprintGenerator"/> class.
/// </summary>
/// <param name="ruleRegistry">The rule registry.</param>
public FingerprintGenerator(ISarifRuleRegistry ruleRegistry)
{
_ruleRegistry = ruleRegistry ?? throw new ArgumentNullException(nameof(ruleRegistry));
}
/// <inheritdoc/>
public string GeneratePrimary(FindingInput finding, FingerprintStrategy strategy)
{
ArgumentNullException.ThrowIfNull(finding);
var input = strategy switch
{
FingerprintStrategy.Standard => BuildStandardInput(finding),
FingerprintStrategy.Minimal => BuildMinimalInput(finding),
FingerprintStrategy.Extended => BuildExtendedInput(finding),
_ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown fingerprint strategy")
};
return ComputeSha256(input);
}
/// <inheritdoc/>
public IDictionary<string, string> GeneratePartial(FindingInput finding)
{
ArgumentNullException.ThrowIfNull(finding);
var partials = new Dictionary<string, string>(StringComparer.Ordinal);
// Component-based partial fingerprint
if (!string.IsNullOrEmpty(finding.ComponentPurl))
{
partials["stellaops/component/v1"] = ComputeSha256(finding.ComponentPurl);
}
// Vulnerability-based partial fingerprint
if (!string.IsNullOrEmpty(finding.VulnerabilityId))
{
partials["stellaops/vuln/v1"] = ComputeSha256(finding.VulnerabilityId);
}
// Location-based partial fingerprint (for GitHub fallback)
if (!string.IsNullOrEmpty(finding.FilePath) && finding.StartLine.HasValue)
{
var locationInput = $"{finding.FilePath}:{finding.StartLine}";
partials["primaryLocationLineHash/v1"] = ComputeSha256(locationInput);
}
// Title-based partial fingerprint (for secrets/config issues without CVE)
if (finding.Type is FindingType.Secret or FindingType.Configuration)
{
var titleInput = $"{finding.Type}:{finding.Title}";
partials["stellaops/title/v1"] = ComputeSha256(titleInput);
}
return partials;
}
private string BuildStandardInput(FindingInput finding)
{
var ruleId = _ruleRegistry.GetRuleId(finding);
var parts = new[]
{
ruleId,
finding.ComponentPurl ?? string.Empty,
finding.VulnerabilityId ?? string.Empty,
finding.ArtifactDigest ?? string.Empty
};
return string.Join(Separator, parts);
}
private string BuildMinimalInput(FindingInput finding)
{
var ruleId = _ruleRegistry.GetRuleId(finding);
var parts = new[]
{
ruleId,
finding.VulnerabilityId ?? finding.Title
};
return string.Join(Separator, parts);
}
private string BuildExtendedInput(FindingInput finding)
{
var ruleId = _ruleRegistry.GetRuleId(finding);
var parts = new[]
{
ruleId,
finding.ComponentPurl ?? string.Empty,
finding.VulnerabilityId ?? string.Empty,
finding.ArtifactDigest ?? string.Empty,
finding.Reachability?.ToString() ?? string.Empty,
finding.VexStatus?.ToString() ?? string.Empty
};
return string.Join(Separator, parts);
}
private static string ComputeSha256(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexStringLower(bytes);
}
}

View File

@@ -0,0 +1,27 @@
// <copyright file="IFingerprintGenerator.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Scanner.Sarif.Fingerprints;
/// <summary>
/// Interface for generating deterministic fingerprints for SARIF results.
/// Sprint: SPRINT_20260109_010_001 Task: Implement fingerprint generator
/// </summary>
public interface IFingerprintGenerator
{
/// <summary>
/// Generates a primary fingerprint for deduplication.
/// </summary>
/// <param name="finding">The finding.</param>
/// <param name="strategy">The fingerprint strategy.</param>
/// <returns>The fingerprint string.</returns>
string GeneratePrimary(FindingInput finding, FingerprintStrategy strategy);
/// <summary>
/// Generates partial fingerprints for fallback matching.
/// </summary>
/// <param name="finding">The finding.</param>
/// <returns>Dictionary of partial fingerprint names to values.</returns>
IDictionary<string, string> GeneratePartial(FindingInput finding);
}

View File

@@ -0,0 +1,51 @@
// <copyright file="ISarifExportService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using StellaOps.Scanner.Sarif.Models;
namespace StellaOps.Scanner.Sarif;
/// <summary>
/// Service interface for exporting Scanner findings to SARIF 2.1.0 format.
/// Sprint: SPRINT_20260109_010_001 Task: Extract shared SARIF models
/// </summary>
public interface ISarifExportService
{
/// <summary>
/// Exports findings to a SARIF log structure.
/// </summary>
/// <param name="findings">The findings to export.</param>
/// <param name="options">Export options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The SARIF log containing the findings.</returns>
Task<SarifLog> ExportAsync(
IEnumerable<FindingInput> findings,
SarifExportOptions options,
CancellationToken cancellationToken = default);
/// <summary>
/// Exports findings to SARIF JSON string.
/// </summary>
/// <param name="findings">The findings to export.</param>
/// <param name="options">Export options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The SARIF JSON string.</returns>
Task<string> ExportToJsonAsync(
IEnumerable<FindingInput> findings,
SarifExportOptions options,
CancellationToken cancellationToken = default);
/// <summary>
/// Exports findings to SARIF JSON and writes to a stream.
/// </summary>
/// <param name="findings">The findings to export.</param>
/// <param name="options">Export options.</param>
/// <param name="outputStream">The output stream.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task ExportToStreamAsync(
IEnumerable<FindingInput> findings,
SarifExportOptions options,
Stream outputStream,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,232 @@
// <copyright file="SarifModels.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sarif.Models;
/// <summary>
/// SARIF 2.1.0 log model.
/// Sprint: SPRINT_20260109_010_001 Task: Extract shared SARIF models
/// </summary>
public sealed record SarifLog(
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("$schema")] string Schema,
[property: JsonPropertyName("runs")] ImmutableArray<SarifRun> Runs)
{
/// <summary>SARIF version constant.</summary>
public const string SarifVersion = "2.1.0";
/// <summary>SARIF schema URL.</summary>
public const string SchemaUrl = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
/// <summary>
/// Creates a new SARIF log with the standard version and schema.
/// </summary>
public static SarifLog Create(ImmutableArray<SarifRun> runs)
=> new(SarifVersion, SchemaUrl, runs);
}
/// <summary>
/// A single SARIF run representing one analysis execution.
/// </summary>
public sealed record SarifRun(
[property: JsonPropertyName("tool")] SarifTool Tool,
[property: JsonPropertyName("results")] ImmutableArray<SarifResult> Results,
[property: JsonPropertyName("invocations")] ImmutableArray<SarifInvocation>? Invocations = null,
[property: JsonPropertyName("artifacts")] ImmutableArray<SarifArtifact>? Artifacts = null,
[property: JsonPropertyName("versionControlProvenance")] ImmutableArray<SarifVersionControlDetails>? VersionControlProvenance = null,
[property: JsonPropertyName("properties")] ImmutableSortedDictionary<string, object>? Properties = null);
/// <summary>
/// Tool information for the SARIF run.
/// </summary>
public sealed record SarifTool(
[property: JsonPropertyName("driver")] SarifToolComponent Driver,
[property: JsonPropertyName("extensions")] ImmutableArray<SarifToolComponent>? Extensions = null);
/// <summary>
/// Tool component (driver or extension).
/// </summary>
public sealed record SarifToolComponent(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("semanticVersion")] string? SemanticVersion = null,
[property: JsonPropertyName("informationUri")] string? InformationUri = null,
[property: JsonPropertyName("rules")] ImmutableArray<SarifReportingDescriptor>? Rules = null,
[property: JsonPropertyName("supportedTaxonomies")] ImmutableArray<SarifToolComponentReference>? SupportedTaxonomies = null);
/// <summary>
/// Reference to a tool component.
/// </summary>
public sealed record SarifToolComponentReference(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("guid")] string? Guid = null);
/// <summary>
/// Rule definition (reporting descriptor).
/// </summary>
public sealed record SarifReportingDescriptor(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("name")] string? Name = null,
[property: JsonPropertyName("shortDescription")] SarifMessage? ShortDescription = null,
[property: JsonPropertyName("fullDescription")] SarifMessage? FullDescription = null,
[property: JsonPropertyName("defaultConfiguration")] SarifReportingConfiguration? DefaultConfiguration = null,
[property: JsonPropertyName("helpUri")] string? HelpUri = null,
[property: JsonPropertyName("help")] SarifMessage? Help = null,
[property: JsonPropertyName("properties")] ImmutableSortedDictionary<string, object>? Properties = null);
/// <summary>
/// Rule configuration.
/// </summary>
public sealed record SarifReportingConfiguration(
[property: JsonPropertyName("level")] SarifLevel Level = SarifLevel.Warning,
[property: JsonPropertyName("enabled")] bool Enabled = true);
/// <summary>
/// SARIF message with text.
/// </summary>
public sealed record SarifMessage(
[property: JsonPropertyName("text")] string Text,
[property: JsonPropertyName("markdown")] string? Markdown = null);
/// <summary>
/// SARIF result level.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<SarifLevel>))]
public enum SarifLevel
{
/// <summary>No level.</summary>
[JsonStringEnumMemberName("none")]
None,
/// <summary>Informational note.</summary>
[JsonStringEnumMemberName("note")]
Note,
/// <summary>Warning.</summary>
[JsonStringEnumMemberName("warning")]
Warning,
/// <summary>Error.</summary>
[JsonStringEnumMemberName("error")]
Error
}
/// <summary>
/// A single result/finding.
/// </summary>
public sealed record SarifResult(
[property: JsonPropertyName("ruleId")] string RuleId,
[property: JsonPropertyName("level")] SarifLevel Level,
[property: JsonPropertyName("message")] SarifMessage Message,
[property: JsonPropertyName("ruleIndex")] int? RuleIndex = null,
[property: JsonPropertyName("locations")] ImmutableArray<SarifLocation>? Locations = null,
[property: JsonPropertyName("fingerprints")] ImmutableSortedDictionary<string, string>? Fingerprints = null,
[property: JsonPropertyName("partialFingerprints")] ImmutableSortedDictionary<string, string>? PartialFingerprints = null,
[property: JsonPropertyName("relatedLocations")] ImmutableArray<SarifLocation>? RelatedLocations = null,
[property: JsonPropertyName("fixes")] ImmutableArray<SarifFix>? Fixes = null,
[property: JsonPropertyName("properties")] ImmutableSortedDictionary<string, object>? Properties = null);
/// <summary>
/// Location of a result.
/// </summary>
public sealed record SarifLocation(
[property: JsonPropertyName("physicalLocation")] SarifPhysicalLocation? PhysicalLocation = null,
[property: JsonPropertyName("logicalLocations")] ImmutableArray<SarifLogicalLocation>? LogicalLocations = null,
[property: JsonPropertyName("message")] SarifMessage? Message = null);
/// <summary>
/// Physical file location.
/// </summary>
public sealed record SarifPhysicalLocation(
[property: JsonPropertyName("artifactLocation")] SarifArtifactLocation ArtifactLocation,
[property: JsonPropertyName("region")] SarifRegion? Region = null,
[property: JsonPropertyName("contextRegion")] SarifRegion? ContextRegion = null);
/// <summary>
/// Artifact location (file path).
/// </summary>
public sealed record SarifArtifactLocation(
[property: JsonPropertyName("uri")] string Uri,
[property: JsonPropertyName("uriBaseId")] string? UriBaseId = null,
[property: JsonPropertyName("index")] int? Index = null);
/// <summary>
/// Region within a file.
/// </summary>
public sealed record SarifRegion(
[property: JsonPropertyName("startLine")] int? StartLine = null,
[property: JsonPropertyName("startColumn")] int? StartColumn = null,
[property: JsonPropertyName("endLine")] int? EndLine = null,
[property: JsonPropertyName("endColumn")] int? EndColumn = null,
[property: JsonPropertyName("charOffset")] int? CharOffset = null,
[property: JsonPropertyName("charLength")] int? CharLength = null,
[property: JsonPropertyName("snippet")] SarifArtifactContent? Snippet = null);
/// <summary>
/// Artifact content (code snippet).
/// </summary>
public sealed record SarifArtifactContent(
[property: JsonPropertyName("text")] string? Text = null,
[property: JsonPropertyName("rendered")] SarifMessage? Rendered = null);
/// <summary>
/// Logical location (namespace, class, function).
/// </summary>
public sealed record SarifLogicalLocation(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("fullyQualifiedName")] string? FullyQualifiedName = null,
[property: JsonPropertyName("kind")] string? Kind = null,
[property: JsonPropertyName("index")] int? Index = null);
/// <summary>
/// Invocation information.
/// </summary>
public sealed record SarifInvocation(
[property: JsonPropertyName("executionSuccessful")] bool ExecutionSuccessful,
[property: JsonPropertyName("startTimeUtc")] DateTimeOffset? StartTimeUtc = null,
[property: JsonPropertyName("endTimeUtc")] DateTimeOffset? EndTimeUtc = null,
[property: JsonPropertyName("workingDirectory")] SarifArtifactLocation? WorkingDirectory = null,
[property: JsonPropertyName("commandLine")] string? CommandLine = null);
/// <summary>
/// Artifact (file) information.
/// </summary>
public sealed record SarifArtifact(
[property: JsonPropertyName("location")] SarifArtifactLocation Location,
[property: JsonPropertyName("mimeType")] string? MimeType = null,
[property: JsonPropertyName("hashes")] ImmutableSortedDictionary<string, string>? Hashes = null,
[property: JsonPropertyName("length")] long? Length = null);
/// <summary>
/// Version control information.
/// </summary>
public sealed record SarifVersionControlDetails(
[property: JsonPropertyName("repositoryUri")] string RepositoryUri,
[property: JsonPropertyName("revisionId")] string? RevisionId = null,
[property: JsonPropertyName("branch")] string? Branch = null,
[property: JsonPropertyName("mappedTo")] SarifArtifactLocation? MappedTo = null);
/// <summary>
/// Fix suggestion.
/// </summary>
public sealed record SarifFix(
[property: JsonPropertyName("description")] SarifMessage Description,
[property: JsonPropertyName("artifactChanges")] ImmutableArray<SarifArtifactChange> ArtifactChanges);
/// <summary>
/// Artifact change for a fix.
/// </summary>
public sealed record SarifArtifactChange(
[property: JsonPropertyName("artifactLocation")] SarifArtifactLocation ArtifactLocation,
[property: JsonPropertyName("replacements")] ImmutableArray<SarifReplacement> Replacements);
/// <summary>
/// Text replacement for a fix.
/// </summary>
public sealed record SarifReplacement(
[property: JsonPropertyName("deletedRegion")] SarifRegion DeletedRegion,
[property: JsonPropertyName("insertedContent")] SarifArtifactContent? InsertedContent = null);

View File

@@ -0,0 +1,48 @@
// <copyright file="ISarifRuleRegistry.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using StellaOps.Scanner.Sarif.Models;
namespace StellaOps.Scanner.Sarif.Rules;
/// <summary>
/// Registry interface for SARIF rule definitions.
/// Sprint: SPRINT_20260109_010_001 Task: Create rule registry
/// </summary>
public interface ISarifRuleRegistry
{
/// <summary>
/// Gets the rule definition for a finding.
/// </summary>
/// <param name="finding">The finding.</param>
/// <returns>The rule definition.</returns>
SarifReportingDescriptor GetRule(FindingInput finding);
/// <summary>
/// Gets the rule ID for a finding.
/// </summary>
/// <param name="finding">The finding.</param>
/// <returns>The rule ID.</returns>
string GetRuleId(FindingInput finding);
/// <summary>
/// Gets the SARIF level for a finding.
/// </summary>
/// <param name="finding">The finding.</param>
/// <returns>The SARIF level.</returns>
SarifLevel GetLevel(FindingInput finding);
/// <summary>
/// Gets all registered rules.
/// </summary>
/// <returns>All rule definitions.</returns>
IReadOnlyList<SarifReportingDescriptor> GetAllRules();
/// <summary>
/// Gets rules by type.
/// </summary>
/// <param name="type">The finding type.</param>
/// <returns>Rules for the specified type.</returns>
IReadOnlyList<SarifReportingDescriptor> GetRulesByType(FindingType type);
}

View File

@@ -0,0 +1,417 @@
// <copyright file="SarifRuleRegistry.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Frozen;
using StellaOps.Scanner.Sarif.Models;
namespace StellaOps.Scanner.Sarif.Rules;
/// <summary>
/// Default implementation of <see cref="ISarifRuleRegistry"/>.
/// Sprint: SPRINT_20260109_010_001 Task: Create rule registry
/// </summary>
public sealed class SarifRuleRegistry : ISarifRuleRegistry
{
private readonly FrozenDictionary<string, SarifReportingDescriptor> _rulesById;
private readonly IReadOnlyList<SarifReportingDescriptor> _allRules;
/// <summary>
/// Initializes a new instance of the <see cref="SarifRuleRegistry"/> class.
/// </summary>
public SarifRuleRegistry()
{
var rules = BuildRules();
_allRules = rules;
_rulesById = rules.ToFrozenDictionary(r => r.Id, StringComparer.OrdinalIgnoreCase);
}
/// <inheritdoc/>
public SarifReportingDescriptor GetRule(FindingInput finding)
{
var ruleId = GetRuleId(finding);
return _rulesById.TryGetValue(ruleId, out var rule)
? rule
: CreateUnknownRule(ruleId);
}
/// <inheritdoc/>
public string GetRuleId(FindingInput finding)
{
return finding.Type switch
{
FindingType.Vulnerability => GetVulnerabilityRuleId(finding),
FindingType.Secret => GetSecretRuleId(finding),
FindingType.SupplyChain => GetSupplyChainRuleId(finding),
FindingType.BinaryHardening => GetBinaryHardeningRuleId(finding),
FindingType.License => "STELLA-LIC-001",
FindingType.Configuration => "STELLA-CFG-001",
_ => "STELLA-UNKNOWN"
};
}
/// <inheritdoc/>
public SarifLevel GetLevel(FindingInput finding)
{
// Runtime/confirmed reachable always elevates to error
if (finding.Reachability == ReachabilityStatus.RuntimeReachable)
{
return SarifLevel.Error;
}
// KEV always elevates to error
if (finding.IsKev)
{
return SarifLevel.Error;
}
// For non-vulnerability findings without explicit severity, use rule default
if (finding.Severity == Severity.Unknown)
{
var ruleId = GetRuleId(finding);
if (_rulesById.TryGetValue(ruleId, out var rule) && rule.DefaultConfiguration != null)
{
return rule.DefaultConfiguration.Level;
}
}
// Map severity to level
return finding.Severity switch
{
Severity.Critical => SarifLevel.Error,
Severity.High => SarifLevel.Error,
Severity.Medium => SarifLevel.Warning,
Severity.Low => SarifLevel.Note,
_ => SarifLevel.Warning
};
}
/// <inheritdoc/>
public IReadOnlyList<SarifReportingDescriptor> GetAllRules() => _allRules;
/// <inheritdoc/>
public IReadOnlyList<SarifReportingDescriptor> GetRulesByType(FindingType type)
{
var prefix = type switch
{
FindingType.Vulnerability => "STELLA-VULN-",
FindingType.Secret => "STELLA-SEC-",
FindingType.SupplyChain => "STELLA-SC-",
FindingType.BinaryHardening => "STELLA-BIN-",
FindingType.License => "STELLA-LIC-",
FindingType.Configuration => "STELLA-CFG-",
_ => "STELLA-"
};
return _allRules.Where(r => r.Id.StartsWith(prefix, StringComparison.Ordinal)).ToList();
}
private static string GetVulnerabilityRuleId(FindingInput finding)
{
// Check reachability first
if (finding.Reachability == ReachabilityStatus.RuntimeReachable)
{
return "STELLA-VULN-005"; // Runtime reachable
}
if (finding.Reachability == ReachabilityStatus.StaticReachable)
{
return "STELLA-VULN-006"; // Static reachable
}
// Fall back to severity
return finding.Severity switch
{
Severity.Critical => "STELLA-VULN-001",
Severity.High => "STELLA-VULN-002",
Severity.Medium => "STELLA-VULN-003",
Severity.Low => "STELLA-VULN-004",
_ => "STELLA-VULN-003"
};
}
private static string GetSecretRuleId(FindingInput finding)
{
// Check for private key patterns in title/description
var text = $"{finding.Title} {finding.Description}".ToUpperInvariant();
if (text.Contains("PRIVATE KEY", StringComparison.Ordinal))
{
return "STELLA-SEC-002";
}
if (text.Contains("CREDENTIAL", StringComparison.Ordinal) ||
text.Contains("PASSWORD", StringComparison.Ordinal))
{
return "STELLA-SEC-003";
}
return "STELLA-SEC-001"; // Default hardcoded secret
}
private static string GetSupplyChainRuleId(FindingInput finding)
{
var text = $"{finding.Title} {finding.Description}".ToUpperInvariant();
if (text.Contains("TYPOSQUAT", StringComparison.Ordinal))
{
return "STELLA-SC-003";
}
if (text.Contains("UNSIGNED", StringComparison.Ordinal))
{
return "STELLA-SC-001";
}
if (text.Contains("PROVENANCE", StringComparison.Ordinal))
{
return "STELLA-SC-002";
}
if (text.Contains("DEPRECAT", StringComparison.Ordinal))
{
return "STELLA-SC-004";
}
return "STELLA-SC-001";
}
private static string GetBinaryHardeningRuleId(FindingInput finding)
{
var text = $"{finding.Title} {finding.Description}".ToUpperInvariant();
if (text.Contains("RELRO", StringComparison.Ordinal))
{
return "STELLA-BIN-001";
}
if (text.Contains("CANARY", StringComparison.Ordinal) || text.Contains("STACK", StringComparison.Ordinal))
{
return "STELLA-BIN-002";
}
if (text.Contains("PIE", StringComparison.Ordinal))
{
return "STELLA-BIN-003";
}
if (text.Contains("FORTIFY", StringComparison.Ordinal))
{
return "STELLA-BIN-004";
}
return "STELLA-BIN-001";
}
private static SarifReportingDescriptor CreateUnknownRule(string ruleId)
{
return new SarifReportingDescriptor(
Id: ruleId,
Name: "Unknown Finding",
ShortDescription: new SarifMessage("Unknown finding type"),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Warning));
}
private static List<SarifReportingDescriptor> BuildRules()
{
return
[
// Vulnerability Rules
new SarifReportingDescriptor(
Id: "STELLA-VULN-001",
Name: "CriticalVulnerability",
ShortDescription: new SarifMessage("Critical severity vulnerability (CVSS >= 9.0)"),
FullDescription: new SarifMessage(
"A critical severity vulnerability was detected in a dependency. " +
"Critical vulnerabilities typically allow remote code execution, " +
"privilege escalation, or complete system compromise."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Error),
HelpUri: "https://stellaops.io/docs/findings/vulnerabilities#critical"),
new SarifReportingDescriptor(
Id: "STELLA-VULN-002",
Name: "HighVulnerability",
ShortDescription: new SarifMessage("High severity vulnerability (CVSS 7.0-8.9)"),
FullDescription: new SarifMessage(
"A high severity vulnerability was detected in a dependency. " +
"High severity vulnerabilities can lead to significant data exposure or system impact."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Error),
HelpUri: "https://stellaops.io/docs/findings/vulnerabilities#high"),
new SarifReportingDescriptor(
Id: "STELLA-VULN-003",
Name: "MediumVulnerability",
ShortDescription: new SarifMessage("Medium severity vulnerability (CVSS 4.0-6.9)"),
FullDescription: new SarifMessage(
"A medium severity vulnerability was detected in a dependency. " +
"Medium severity vulnerabilities require specific conditions to exploit."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Warning),
HelpUri: "https://stellaops.io/docs/findings/vulnerabilities#medium"),
new SarifReportingDescriptor(
Id: "STELLA-VULN-004",
Name: "LowVulnerability",
ShortDescription: new SarifMessage("Low severity vulnerability (CVSS < 4.0)"),
FullDescription: new SarifMessage(
"A low severity vulnerability was detected in a dependency. " +
"Low severity vulnerabilities have limited impact or require unlikely conditions."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Note),
HelpUri: "https://stellaops.io/docs/findings/vulnerabilities#low"),
new SarifReportingDescriptor(
Id: "STELLA-VULN-005",
Name: "RuntimeReachableVulnerability",
ShortDescription: new SarifMessage("Vulnerability confirmed reachable at runtime"),
FullDescription: new SarifMessage(
"This vulnerability has been confirmed as reachable through runtime analysis. " +
"The vulnerable code path is actively executed in your application."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Error),
HelpUri: "https://stellaops.io/docs/findings/reachability#runtime"),
new SarifReportingDescriptor(
Id: "STELLA-VULN-006",
Name: "StaticReachableVulnerability",
ShortDescription: new SarifMessage("Vulnerability statically reachable"),
FullDescription: new SarifMessage(
"Static analysis indicates this vulnerability may be reachable. " +
"The vulnerable code exists in a call path from your application code."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Warning),
HelpUri: "https://stellaops.io/docs/findings/reachability#static"),
// Secret Rules
new SarifReportingDescriptor(
Id: "STELLA-SEC-001",
Name: "HardcodedSecret",
ShortDescription: new SarifMessage("Hardcoded secret detected"),
FullDescription: new SarifMessage(
"A hardcoded secret (API key, token, password) was detected in source code or configuration. " +
"Secrets should be stored in secure vaults and injected at runtime."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Error),
HelpUri: "https://stellaops.io/docs/findings/secrets#hardcoded"),
new SarifReportingDescriptor(
Id: "STELLA-SEC-002",
Name: "PrivateKeyExposure",
ShortDescription: new SarifMessage("Private key exposed"),
FullDescription: new SarifMessage(
"A private key (RSA, EC, SSH) was detected in source code or artifacts. " +
"Private keys should never be committed to version control."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Error),
HelpUri: "https://stellaops.io/docs/findings/secrets#private-key"),
new SarifReportingDescriptor(
Id: "STELLA-SEC-003",
Name: "CredentialPattern",
ShortDescription: new SarifMessage("Credential pattern detected"),
FullDescription: new SarifMessage(
"A potential credential or password pattern was detected. " +
"Review to determine if this is a false positive or actual credential."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Warning),
HelpUri: "https://stellaops.io/docs/findings/secrets#credential"),
// Supply Chain Rules
new SarifReportingDescriptor(
Id: "STELLA-SC-001",
Name: "UnsignedPackage",
ShortDescription: new SarifMessage("Unsigned package detected"),
FullDescription: new SarifMessage(
"A package without cryptographic signature was detected. " +
"Unsigned packages cannot be verified for authenticity and integrity."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Warning),
HelpUri: "https://stellaops.io/docs/findings/supply-chain#unsigned"),
new SarifReportingDescriptor(
Id: "STELLA-SC-002",
Name: "UnknownProvenance",
ShortDescription: new SarifMessage("Package with unknown provenance"),
FullDescription: new SarifMessage(
"A package without verifiable build provenance was detected. " +
"Provenance helps verify that packages were built from expected sources."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Warning),
HelpUri: "https://stellaops.io/docs/findings/supply-chain#provenance"),
new SarifReportingDescriptor(
Id: "STELLA-SC-003",
Name: "TyposquatCandidate",
ShortDescription: new SarifMessage("Potential typosquat package"),
FullDescription: new SarifMessage(
"A package name similar to a popular package was detected. " +
"This may be a typosquat attack attempting to install malicious code."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Error),
HelpUri: "https://stellaops.io/docs/findings/supply-chain#typosquat"),
new SarifReportingDescriptor(
Id: "STELLA-SC-004",
Name: "DeprecatedPackage",
ShortDescription: new SarifMessage("Deprecated package in use"),
FullDescription: new SarifMessage(
"A deprecated package was detected. " +
"Deprecated packages may no longer receive security updates."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Note),
HelpUri: "https://stellaops.io/docs/findings/supply-chain#deprecated"),
// Binary Hardening Rules
new SarifReportingDescriptor(
Id: "STELLA-BIN-001",
Name: "MissingRelro",
ShortDescription: new SarifMessage("Binary missing RELRO protection"),
FullDescription: new SarifMessage(
"The binary was compiled without RELRO (Relocation Read-Only). " +
"RELRO protects the GOT from being overwritten by attackers."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Warning),
HelpUri: "https://stellaops.io/docs/findings/binary#relro"),
new SarifReportingDescriptor(
Id: "STELLA-BIN-002",
Name: "MissingStackCanary",
ShortDescription: new SarifMessage("Binary missing stack canary"),
FullDescription: new SarifMessage(
"The binary was compiled without stack canaries. " +
"Stack canaries help detect buffer overflow attacks."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Warning),
HelpUri: "https://stellaops.io/docs/findings/binary#canary"),
new SarifReportingDescriptor(
Id: "STELLA-BIN-003",
Name: "MissingPie",
ShortDescription: new SarifMessage("Binary not position independent"),
FullDescription: new SarifMessage(
"The binary was not compiled as a Position Independent Executable (PIE). " +
"PIE enables full ASLR protection."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Warning),
HelpUri: "https://stellaops.io/docs/findings/binary#pie"),
new SarifReportingDescriptor(
Id: "STELLA-BIN-004",
Name: "MissingFortifySource",
ShortDescription: new SarifMessage("Binary missing FORTIFY_SOURCE"),
FullDescription: new SarifMessage(
"The binary was compiled without FORTIFY_SOURCE. " +
"FORTIFY_SOURCE adds runtime checks for buffer overflows in standard library calls."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Note),
HelpUri: "https://stellaops.io/docs/findings/binary#fortify"),
// License Rule
new SarifReportingDescriptor(
Id: "STELLA-LIC-001",
Name: "LicenseCompliance",
ShortDescription: new SarifMessage("License compliance issue"),
FullDescription: new SarifMessage(
"A license compliance issue was detected. " +
"Review the license terms for compatibility with your project."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Warning),
HelpUri: "https://stellaops.io/docs/findings/license"),
// Configuration Rule
new SarifReportingDescriptor(
Id: "STELLA-CFG-001",
Name: "ConfigurationIssue",
ShortDescription: new SarifMessage("Security configuration issue"),
FullDescription: new SarifMessage(
"A security configuration issue was detected. " +
"Review the configuration to ensure secure defaults are used."),
DefaultConfiguration: new SarifReportingConfiguration(SarifLevel.Warning),
HelpUri: "https://stellaops.io/docs/findings/configuration")
];
}
}

View File

@@ -0,0 +1,144 @@
// <copyright file="SarifExportOptions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Scanner.Sarif;
/// <summary>
/// Options for SARIF export.
/// Sprint: SPRINT_20260109_010_001 Task: Extract shared SARIF models
/// </summary>
public sealed record SarifExportOptions
{
/// <summary>
/// Gets the tool name to include in SARIF output.
/// </summary>
public string ToolName { get; init; } = "StellaOps Scanner";
/// <summary>
/// Gets the tool version (required).
/// </summary>
public required string ToolVersion { get; init; }
/// <summary>
/// Gets the tool information URI.
/// </summary>
public string ToolUri { get; init; } = "https://stellaops.io/scanner";
/// <summary>
/// Gets the minimum severity to include (null = all).
/// </summary>
public Severity? MinimumSeverity { get; init; }
/// <summary>
/// Gets whether to include reachability information in properties.
/// </summary>
public bool IncludeReachability { get; init; } = true;
/// <summary>
/// Gets whether to include VEX status in properties.
/// </summary>
public bool IncludeVexStatus { get; init; } = true;
/// <summary>
/// Gets whether to include EPSS scores in properties.
/// </summary>
public bool IncludeEpss { get; init; } = true;
/// <summary>
/// Gets whether to include KEV (Known Exploited Vulnerabilities) flag.
/// </summary>
public bool IncludeKev { get; init; } = true;
/// <summary>
/// Gets whether to include StellaOps evidence URIs.
/// </summary>
public bool IncludeEvidenceUris { get; init; } = false;
/// <summary>
/// Gets whether to include attestation digest references.
/// </summary>
public bool IncludeAttestation { get; init; } = true;
/// <summary>
/// Gets version control information for the run.
/// </summary>
public VersionControlInfo? VersionControl { get; init; }
/// <summary>
/// Gets whether to pretty-print JSON output.
/// </summary>
public bool IndentedJson { get; init; } = false;
/// <summary>
/// Gets the scan category (e.g., "security", "supply-chain").
/// </summary>
public string? Category { get; init; }
/// <summary>
/// Gets the source root for relative paths.
/// </summary>
public string? SourceRoot { get; init; }
/// <summary>
/// Gets the fingerprint strategy to use.
/// </summary>
public FingerprintStrategy FingerprintStrategy { get; init; } = FingerprintStrategy.Standard;
}
/// <summary>
/// Version control information for SARIF output.
/// </summary>
public sealed record VersionControlInfo
{
/// <summary>
/// Gets the repository URI.
/// </summary>
public required string RepositoryUri { get; init; }
/// <summary>
/// Gets the revision ID (commit SHA).
/// </summary>
public string? RevisionId { get; init; }
/// <summary>
/// Gets the branch name.
/// </summary>
public string? Branch { get; init; }
}
/// <summary>
/// Fingerprint generation strategy.
/// </summary>
public enum FingerprintStrategy
{
/// <summary>Standard fingerprint based on rule, component, vulnerability, and artifact.</summary>
Standard,
/// <summary>Minimal fingerprint for deduplication only.</summary>
Minimal,
/// <summary>Extended fingerprint including reachability and VEX status.</summary>
Extended
}
/// <summary>
/// Severity levels for findings.
/// </summary>
public enum Severity
{
/// <summary>Unknown or unspecified severity.</summary>
Unknown = 0,
/// <summary>Low severity (CVSS &lt; 4.0).</summary>
Low = 1,
/// <summary>Medium severity (CVSS 4.0-6.9).</summary>
Medium = 2,
/// <summary>High severity (CVSS 7.0-8.9).</summary>
High = 3,
/// <summary>Critical severity (CVSS >= 9.0).</summary>
Critical = 4
}

View File

@@ -0,0 +1,410 @@
// <copyright file="SarifExportService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Sarif.Fingerprints;
using StellaOps.Scanner.Sarif.Models;
using StellaOps.Scanner.Sarif.Rules;
namespace StellaOps.Scanner.Sarif;
/// <summary>
/// Default implementation of <see cref="ISarifExportService"/>.
/// Sprint: SPRINT_20260109_010_001 Task: Implement export service
/// </summary>
public sealed class SarifExportService : ISarifExportService
{
private readonly ISarifRuleRegistry _ruleRegistry;
private readonly IFingerprintGenerator _fingerprintGenerator;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions DefaultJsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private static readonly JsonSerializerOptions IndentedJsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
/// <summary>
/// Initializes a new instance of the <see cref="SarifExportService"/> class.
/// </summary>
/// <param name="ruleRegistry">The rule registry.</param>
/// <param name="fingerprintGenerator">The fingerprint generator.</param>
/// <param name="timeProvider">The time provider.</param>
public SarifExportService(
ISarifRuleRegistry ruleRegistry,
IFingerprintGenerator fingerprintGenerator,
TimeProvider? timeProvider = null)
{
_ruleRegistry = ruleRegistry ?? throw new ArgumentNullException(nameof(ruleRegistry));
_fingerprintGenerator = fingerprintGenerator ?? throw new ArgumentNullException(nameof(fingerprintGenerator));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc/>
public Task<SarifLog> ExportAsync(
IEnumerable<FindingInput> findings,
SarifExportOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(findings);
ArgumentNullException.ThrowIfNull(options);
var filteredFindings = FilterFindings(findings, options);
var results = MapToResults(filteredFindings, options);
var run = CreateRun(results, options);
var log = SarifLog.Create([run]);
return Task.FromResult(log);
}
/// <inheritdoc/>
public async Task<string> ExportToJsonAsync(
IEnumerable<FindingInput> findings,
SarifExportOptions options,
CancellationToken cancellationToken = default)
{
var log = await ExportAsync(findings, options, cancellationToken).ConfigureAwait(false);
var jsonOptions = options.IndentedJson ? IndentedJsonOptions : DefaultJsonOptions;
return JsonSerializer.Serialize(log, jsonOptions);
}
/// <inheritdoc/>
public async Task ExportToStreamAsync(
IEnumerable<FindingInput> findings,
SarifExportOptions options,
Stream outputStream,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(outputStream);
var log = await ExportAsync(findings, options, cancellationToken).ConfigureAwait(false);
var jsonOptions = options.IndentedJson ? IndentedJsonOptions : DefaultJsonOptions;
await JsonSerializer.SerializeAsync(outputStream, log, jsonOptions, cancellationToken).ConfigureAwait(false);
}
private static IEnumerable<FindingInput> FilterFindings(
IEnumerable<FindingInput> findings,
SarifExportOptions options)
{
var query = findings.AsEnumerable();
// Filter by minimum severity
if (options.MinimumSeverity.HasValue)
{
query = query.Where(f => f.Severity >= options.MinimumSeverity.Value);
}
return query;
}
private ImmutableArray<SarifResult> MapToResults(
IEnumerable<FindingInput> findings,
SarifExportOptions options)
{
var results = new List<SarifResult>();
foreach (var finding in findings)
{
var result = MapToResult(finding, options);
results.Add(result);
}
// Sort deterministically by rule ID, then by fingerprint
return results
.OrderBy(r => r.RuleId, StringComparer.Ordinal)
.ThenBy(r => r.Fingerprints?.GetValueOrDefault("stellaops/v1") ?? string.Empty, StringComparer.Ordinal)
.ToImmutableArray();
}
private SarifResult MapToResult(FindingInput finding, SarifExportOptions options)
{
var ruleId = _ruleRegistry.GetRuleId(finding);
var level = _ruleRegistry.GetLevel(finding);
var message = CreateMessage(finding);
var locations = CreateLocations(finding, options);
var fingerprints = CreateFingerprints(finding, options);
var partialFingerprints = _fingerprintGenerator.GeneratePartial(finding)
.ToImmutableSortedDictionary(StringComparer.Ordinal);
var properties = CreateProperties(finding, options);
return new SarifResult(
RuleId: ruleId,
Level: level,
Message: message,
Locations: locations.Length > 0 ? locations : null,
Fingerprints: fingerprints,
PartialFingerprints: partialFingerprints.Count > 0 ? partialFingerprints : null,
Properties: properties);
}
private static SarifMessage CreateMessage(FindingInput finding)
{
var text = finding.Title;
if (!string.IsNullOrEmpty(finding.VulnerabilityId))
{
text = $"{finding.VulnerabilityId}: {text}";
}
if (!string.IsNullOrEmpty(finding.ComponentName))
{
var version = finding.ComponentVersion ?? "unknown";
text = $"{text} in {finding.ComponentName}@{version}";
}
string? markdown = null;
if (!string.IsNullOrEmpty(finding.Description))
{
markdown = $"**{text}**\n\n{finding.Description}";
if (!string.IsNullOrEmpty(finding.Recommendation))
{
markdown += $"\n\n**Recommendation:** {finding.Recommendation}";
}
}
return new SarifMessage(text, markdown);
}
private static ImmutableArray<SarifLocation> CreateLocations(FindingInput finding, SarifExportOptions options)
{
if (string.IsNullOrEmpty(finding.FilePath))
{
return [];
}
var uri = NormalizePath(finding.FilePath, options.SourceRoot);
var region = (finding.StartLine.HasValue)
? new SarifRegion(
StartLine: finding.StartLine,
StartColumn: finding.StartColumn,
EndLine: finding.EndLine,
EndColumn: finding.EndColumn)
: null;
var physicalLocation = new SarifPhysicalLocation(
ArtifactLocation: new SarifArtifactLocation(uri),
Region: region);
var logicalLocations = CreateLogicalLocations(finding);
return [new SarifLocation(
PhysicalLocation: physicalLocation,
LogicalLocations: logicalLocations.Length > 0 ? logicalLocations : null)];
}
private static ImmutableArray<SarifLogicalLocation> CreateLogicalLocations(FindingInput finding)
{
var locations = new List<SarifLogicalLocation>();
// Add component as a logical location
if (!string.IsNullOrEmpty(finding.ComponentPurl))
{
locations.Add(new SarifLogicalLocation(
Name: finding.ComponentName ?? finding.ComponentPurl,
FullyQualifiedName: finding.ComponentPurl,
Kind: "package"));
}
return locations.ToImmutableArray();
}
private ImmutableSortedDictionary<string, string> CreateFingerprints(
FindingInput finding,
SarifExportOptions options)
{
var fingerprint = _fingerprintGenerator.GeneratePrimary(finding, options.FingerprintStrategy);
return ImmutableSortedDictionary.CreateRange(
StringComparer.Ordinal,
[new KeyValuePair<string, string>("stellaops/v1", fingerprint)]);
}
private ImmutableSortedDictionary<string, object>? CreateProperties(
FindingInput finding,
SarifExportOptions options)
{
var props = new Dictionary<string, object>(StringComparer.Ordinal);
// Always include finding type
props["stellaops/findingType"] = finding.Type.ToString();
// Vulnerability-specific properties
if (!string.IsNullOrEmpty(finding.VulnerabilityId))
{
props["stellaops/vulnId"] = finding.VulnerabilityId;
}
if (finding.CvssScore.HasValue)
{
props["stellaops/cvss/score"] = finding.CvssScore.Value;
}
if (!string.IsNullOrEmpty(finding.CvssVector))
{
props["stellaops/cvss/vector"] = finding.CvssVector;
}
// EPSS
if (options.IncludeEpss && finding.EpssProbability.HasValue)
{
props["stellaops/epss/probability"] = finding.EpssProbability.Value;
if (finding.EpssPercentile.HasValue)
{
props["stellaops/epss/percentile"] = finding.EpssPercentile.Value;
}
}
// KEV
if (options.IncludeKev && finding.IsKev)
{
props["stellaops/kev"] = true;
}
// Reachability
if (options.IncludeReachability && finding.Reachability.HasValue)
{
props["stellaops/reachability"] = finding.Reachability.Value.ToString();
}
// VEX
if (options.IncludeVexStatus && finding.VexStatus.HasValue)
{
props["stellaops/vex/status"] = finding.VexStatus.Value.ToString();
if (!string.IsNullOrEmpty(finding.VexJustification))
{
props["stellaops/vex/justification"] = finding.VexJustification;
}
}
// Component
if (!string.IsNullOrEmpty(finding.ComponentPurl))
{
props["stellaops/component/purl"] = finding.ComponentPurl;
}
// Artifact
if (!string.IsNullOrEmpty(finding.ArtifactDigest))
{
props["stellaops/artifact/digest"] = finding.ArtifactDigest;
}
// Evidence URIs
if (options.IncludeEvidenceUris && finding.EvidenceUris?.Count > 0)
{
props["stellaops/evidence"] = finding.EvidenceUris;
}
// Attestation
if (options.IncludeAttestation && finding.AttestationDigests?.Count > 0)
{
props["stellaops/attestation"] = finding.AttestationDigests;
}
// Category
if (!string.IsNullOrEmpty(options.Category))
{
props["github/alertCategory"] = options.Category;
}
// Custom properties
if (finding.Properties != null)
{
foreach (var kvp in finding.Properties)
{
props[$"custom/{kvp.Key}"] = kvp.Value;
}
}
return props.Count > 0
? props.ToImmutableSortedDictionary(StringComparer.Ordinal)
: null;
}
private SarifRun CreateRun(ImmutableArray<SarifResult> results, SarifExportOptions options)
{
var driver = CreateDriver(options, results);
var tool = new SarifTool(driver);
var invocations = CreateInvocations();
var versionControl = CreateVersionControl(options);
return new SarifRun(
Tool: tool,
Results: results,
Invocations: invocations,
VersionControlProvenance: versionControl);
}
private SarifToolComponent CreateDriver(SarifExportOptions options, ImmutableArray<SarifResult> results)
{
// Get unique rules used in results
var usedRuleIds = results.Select(r => r.RuleId).Distinct().ToHashSet(StringComparer.Ordinal);
var rules = _ruleRegistry.GetAllRules()
.Where(r => usedRuleIds.Contains(r.Id))
.OrderBy(r => r.Id, StringComparer.Ordinal)
.ToImmutableArray();
return new SarifToolComponent(
Name: options.ToolName,
Version: options.ToolVersion,
SemanticVersion: options.ToolVersion,
InformationUri: options.ToolUri,
Rules: rules);
}
private ImmutableArray<SarifInvocation> CreateInvocations()
{
var now = _timeProvider.GetUtcNow();
return [new SarifInvocation(
ExecutionSuccessful: true,
StartTimeUtc: now,
EndTimeUtc: now)];
}
private static ImmutableArray<SarifVersionControlDetails>? CreateVersionControl(SarifExportOptions options)
{
if (options.VersionControl is null)
{
return null;
}
return [new SarifVersionControlDetails(
RepositoryUri: options.VersionControl.RepositoryUri,
RevisionId: options.VersionControl.RevisionId,
Branch: options.VersionControl.Branch)];
}
private static string NormalizePath(string path, string? sourceRoot)
{
// Convert backslashes to forward slashes
var normalized = path.Replace('\\', '/');
// Remove source root prefix if provided
if (!string.IsNullOrEmpty(sourceRoot))
{
var normalizedRoot = sourceRoot.Replace('\\', '/').TrimEnd('/') + "/";
if (normalized.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[normalizedRoot.Length..];
}
}
// Remove leading slash for relative paths
return normalized.TrimStart('/');
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Description>SARIF 2.1.0 exporter for StellaOps Scanner findings with GitHub Code Scanning support</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -226,14 +226,39 @@ public enum SbomFormat
Unknown
}
/// <summary>
/// Validation mode that determines how errors and warnings are handled.
/// </summary>
public enum SbomValidationMode
{
/// <summary>Fail on any error; warn on warnings.</summary>
Strict,
/// <summary>Warn on errors; ignore warnings.</summary>
Lenient,
/// <summary>Log only; never fail.</summary>
Audit,
/// <summary>Skip validation entirely.</summary>
Off
}
/// <summary>
/// Validation options.
/// Sprint: SPRINT_20260107_005_003 Task VG-007
/// </summary>
public sealed record SbomValidationOptions
{
/// <summary>
/// Gets or sets the validation mode.
/// Default: Strict.
/// </summary>
public SbomValidationMode Mode { get; init; } = SbomValidationMode.Strict;
/// <summary>
/// Gets or sets the timeout for validation.
/// Default: 30 seconds.
/// Default: 30 seconds. Must be positive.
/// </summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
@@ -259,6 +284,38 @@ public sealed record SbomValidationOptions
/// Gets or sets custom validation rules (JSON Schema or SHACL).
/// </summary>
public string? CustomRulesPath { get; init; }
/// <summary>
/// Gets or sets the required SPDX profiles for SPDX 3.0.1 documents.
/// Examples: "core", "software", "security", "build".
/// </summary>
public IReadOnlyList<string>? RequiredSpdxProfiles { get; init; }
/// <summary>
/// Validates the options.
/// </summary>
/// <returns>Validation errors, or empty if valid.</returns>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (Timeout <= TimeSpan.Zero)
{
errors.Add("Timeout must be positive.");
}
if (Timeout > TimeSpan.FromMinutes(10))
{
errors.Add("Timeout cannot exceed 10 minutes.");
}
if (CustomRulesPath is not null && !File.Exists(CustomRulesPath))
{
errors.Add($"Custom rules file not found: {CustomRulesPath}");
}
return errors;
}
}
/// <summary>

View File

@@ -10,7 +10,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,147 @@
// <copyright file="ValidationGateOptions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Scanner.Validation;
/// <summary>
/// Configuration options for the validation gate.
/// Sprint: SPRINT_20260107_005_003 Task VG-007
/// Follows CLAUDE.md Rule 8.14 - Use ValidateDataAnnotations and ValidateOnStart.
/// </summary>
public sealed class ValidationGateOptions : IValidatableObject
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "ValidationGate";
/// <summary>
/// Gets or sets whether validation is enabled.
/// Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets the validation mode.
/// Default: Strict.
/// </summary>
[Required]
public SbomValidationMode Mode { get; set; } = SbomValidationMode.Strict;
/// <summary>
/// Gets or sets the timeout for validation in seconds.
/// Default: 30. Range: 1-600.
/// </summary>
[Range(1, 600, ErrorMessage = "Timeout must be between 1 and 600 seconds.")]
public int TimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets whether to include warnings in results.
/// Default: true.
/// </summary>
public bool IncludeWarnings { get; set; } = true;
/// <summary>
/// Gets or sets whether to validate license expressions.
/// Default: true.
/// </summary>
public bool ValidateLicenses { get; set; } = true;
/// <summary>
/// Gets or sets the path to custom validation rules (JSON Schema or SHACL).
/// </summary>
public string? CustomRulesPath { get; set; }
/// <summary>
/// Gets or sets the required SPDX profiles for SPDX 3.0.1 validation.
/// Examples: "core", "software", "security", "build".
/// </summary>
public List<string> RequiredSpdxProfiles { get; set; } = new();
/// <summary>
/// Gets or sets whether to fail the build if validation fails.
/// Only applies in Strict mode.
/// Default: true.
/// </summary>
public bool FailOnValidationError { get; set; } = true;
/// <summary>
/// Gets or sets whether to cache validation results.
/// Default: true.
/// </summary>
public bool CacheResults { get; set; } = true;
/// <summary>
/// Gets or sets the cache TTL in seconds.
/// Default: 3600 (1 hour). Range: 60-86400.
/// </summary>
[Range(60, 86400, ErrorMessage = "CacheTtlSeconds must be between 60 and 86400 seconds.")]
public int CacheTtlSeconds { get; set; } = 3600;
/// <summary>
/// Gets the timeout as a TimeSpan.
/// </summary>
public TimeSpan Timeout => TimeSpan.FromSeconds(TimeoutSeconds);
/// <summary>
/// Gets the cache TTL as a TimeSpan.
/// </summary>
public TimeSpan CacheTtl => TimeSpan.FromSeconds(CacheTtlSeconds);
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (CustomRulesPath is not null && !File.Exists(CustomRulesPath))
{
yield return new ValidationResult(
$"Custom rules file not found: {CustomRulesPath}",
new[] { nameof(CustomRulesPath) });
}
if (Mode == SbomValidationMode.Off && FailOnValidationError)
{
yield return new ValidationResult(
"FailOnValidationError should be false when Mode is Off",
new[] { nameof(FailOnValidationError), nameof(Mode) });
}
}
/// <summary>
/// Creates SbomValidationOptions from this configuration.
/// </summary>
public SbomValidationOptions ToValidationOptions() => new()
{
Mode = Mode,
Timeout = Timeout,
IncludeWarnings = IncludeWarnings,
ValidateLicenses = ValidateLicenses,
CustomRulesPath = CustomRulesPath,
RequiredSpdxProfiles = RequiredSpdxProfiles.Count > 0 ? RequiredSpdxProfiles : null
};
}
/// <summary>
/// Extension methods for registering validation gate options.
/// </summary>
public static class ValidationGateOptionsExtensions
{
/// <summary>
/// Adds validation gate options with data annotations validation.
/// </summary>
public static IServiceCollection AddValidationGateOptions(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOptions<ValidationGateOptions>()
.Bind(configuration.GetSection(ValidationGateOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
return services;
}
}

View File

@@ -0,0 +1,483 @@
// <copyright file="ValidatorBinaryManager.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Globalization;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Validation;
/// <summary>
/// Manages validator binary downloads, extraction, and verification.
/// Sprint: SPRINT_20260107_005_003 Task VG-004
/// </summary>
public sealed class ValidatorBinaryManager
{
private readonly ValidatorBinaryOptions _options;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<ValidatorBinaryManager> _logger;
private readonly TimeProvider _timeProvider;
private static readonly ImmutableDictionary<string, ValidatorBinarySpec> DefaultSpecs =
new Dictionary<string, ValidatorBinarySpec>(StringComparer.OrdinalIgnoreCase)
{
["sbom-utility"] = new ValidatorBinarySpec
{
Name = "sbom-utility",
Version = "0.17.0",
BaseUrl = "https://github.com/CycloneDX/sbom-utility/releases/download/v0.17.0",
FileNameFormat = "sbom-utility-v{0}-{1}-{2}.tar.gz",
ExpectedHashes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["linux-amd64"] = "0000000000000000000000000000000000000000000000000000000000000000",
["linux-arm64"] = "0000000000000000000000000000000000000000000000000000000000000000",
["darwin-amd64"] = "0000000000000000000000000000000000000000000000000000000000000000",
["darwin-arm64"] = "0000000000000000000000000000000000000000000000000000000000000000",
["windows-amd64"] = "0000000000000000000000000000000000000000000000000000000000000000"
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
ExecutableName = "sbom-utility"
},
["spdx-tools"] = new ValidatorBinarySpec
{
Name = "spdx-tools",
Version = "1.1.9",
BaseUrl = "https://github.com/spdx/tools-java/releases/download/v1.1.9",
FileNameFormat = "tools-java-{0}-jar-with-dependencies.jar",
ExpectedHashes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["jar"] = "0000000000000000000000000000000000000000000000000000000000000000"
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
ExecutableName = "tools-java-1.1.9-jar-with-dependencies.jar",
IsJar = true
}
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Initializes a new instance of the <see cref="ValidatorBinaryManager"/> class.
/// </summary>
/// <param name="options">Binary manager options.</param>
/// <param name="httpClientFactory">HTTP client factory per CLAUDE.md Rule 8.9.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="timeProvider">Time provider per CLAUDE.md Rule 8.2.</param>
public ValidatorBinaryManager(
IOptions<ValidatorBinaryOptions> options,
IHttpClientFactory httpClientFactory,
ILogger<ValidatorBinaryManager> logger,
TimeProvider timeProvider)
{
_options = options.Value;
_httpClientFactory = httpClientFactory;
_logger = logger;
_timeProvider = timeProvider;
}
/// <summary>
/// Ensures a validator binary is available, downloading if necessary.
/// </summary>
/// <param name="validatorName">Name of the validator (e.g., "sbom-utility", "spdx-tools").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Path to the validator executable.</returns>
/// <exception cref="ValidatorBinaryException">Thrown if binary cannot be obtained.</exception>
public async Task<string> EnsureBinaryAsync(
string validatorName,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(validatorName);
var spec = GetBinarySpec(validatorName);
var executablePath = GetExecutablePath(spec);
// Check if already available
if (File.Exists(executablePath))
{
_logger.LogDebug("Validator {Validator} already available at {Path}", validatorName, executablePath);
return executablePath;
}
// Check offline mode
if (_options.OfflineMode)
{
throw new ValidatorBinaryException(
$"Validator '{validatorName}' not found and offline mode is enabled. " +
$"Expected at: {executablePath}");
}
// Download and extract
await DownloadAndExtractAsync(spec, cancellationToken).ConfigureAwait(false);
if (!File.Exists(executablePath))
{
throw new ValidatorBinaryException(
$"Validator '{validatorName}' was downloaded but executable not found at: {executablePath}");
}
return executablePath;
}
/// <summary>
/// Checks if a validator binary is available without downloading.
/// </summary>
/// <param name="validatorName">Name of the validator.</param>
/// <returns>True if the binary is available.</returns>
public bool IsBinaryAvailable(string validatorName)
{
if (string.IsNullOrEmpty(validatorName))
{
return false;
}
try
{
var spec = GetBinarySpec(validatorName);
var executablePath = GetExecutablePath(spec);
return File.Exists(executablePath);
}
catch
{
return false;
}
}
/// <summary>
/// Gets the path where a validator binary should be located.
/// </summary>
/// <param name="validatorName">Name of the validator.</param>
/// <returns>Expected path to the executable.</returns>
public string GetBinaryPath(string validatorName)
{
var spec = GetBinarySpec(validatorName);
return GetExecutablePath(spec);
}
/// <summary>
/// Verifies the integrity of an existing validator binary.
/// </summary>
/// <param name="validatorName">Name of the validator.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the binary passes integrity verification.</returns>
public async Task<bool> VerifyBinaryIntegrityAsync(
string validatorName,
CancellationToken cancellationToken = default)
{
var spec = GetBinarySpec(validatorName);
var executablePath = GetExecutablePath(spec);
if (!File.Exists(executablePath))
{
return false;
}
var platformKey = GetPlatformKey(spec);
if (!spec.ExpectedHashes.TryGetValue(platformKey, out var expectedHash))
{
_logger.LogWarning(
"No expected hash for validator {Validator} on platform {Platform}",
validatorName, platformKey);
return true; // No hash to verify against
}
var actualHash = await ComputeFileHashAsync(executablePath, cancellationToken).ConfigureAwait(false);
var match = string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase);
if (!match)
{
_logger.LogWarning(
"Integrity verification failed for {Validator}: expected {Expected}, got {Actual}",
validatorName, expectedHash, actualHash);
}
return match;
}
/// <summary>
/// Gets information about available validators.
/// </summary>
/// <returns>Dictionary of validator specifications.</returns>
public IReadOnlyDictionary<string, ValidatorBinarySpec> GetAvailableValidators()
{
if (_options.CustomSpecs is not null && _options.CustomSpecs.Count > 0)
{
return _options.CustomSpecs
.Concat(DefaultSpecs.Where(kv => !_options.CustomSpecs.ContainsKey(kv.Key)))
.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
}
return DefaultSpecs;
}
private ValidatorBinarySpec GetBinarySpec(string validatorName)
{
var specs = GetAvailableValidators();
if (!specs.TryGetValue(validatorName, out var spec))
{
throw new ValidatorBinaryException($"Unknown validator: {validatorName}");
}
return spec;
}
private string GetExecutablePath(ValidatorBinarySpec spec)
{
var baseDir = string.IsNullOrEmpty(_options.BinaryDirectory)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "stellaops", "validators")
: _options.BinaryDirectory;
var versionDir = Path.Combine(baseDir, spec.Name, spec.Version);
var executableName = spec.IsJar
? spec.ExecutableName
: RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? spec.ExecutableName + ".exe"
: spec.ExecutableName;
return Path.Combine(versionDir, executableName);
}
private async Task DownloadAndExtractAsync(
ValidatorBinarySpec spec,
CancellationToken cancellationToken)
{
var platformKey = GetPlatformKey(spec);
var downloadUrl = GetDownloadUrl(spec, platformKey);
var targetDir = Path.GetDirectoryName(GetExecutablePath(spec))!;
_logger.LogInformation(
"Downloading validator {Validator} v{Version} for {Platform} from {Url}",
spec.Name, spec.Version, platformKey, downloadUrl);
Directory.CreateDirectory(targetDir);
var tempFile = Path.Combine(targetDir, $"download_{_timeProvider.GetUtcNow().ToUnixTimeMilliseconds()}.tmp");
try
{
// Download
using var httpClient = _httpClientFactory.CreateClient("ValidatorDownload");
httpClient.Timeout = _options.DownloadTimeout;
using var response = await httpClient.GetAsync(
downloadUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using var fileStream = File.Create(tempFile);
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
// Verify hash
if (spec.ExpectedHashes.TryGetValue(platformKey, out var expectedHash))
{
var actualHash = await ComputeFileHashAsync(tempFile, cancellationToken).ConfigureAwait(false);
// Skip hash verification if placeholder hash (all zeros)
if (!IsPlaceholderHash(expectedHash) &&
!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase))
{
throw new ValidatorBinaryException(
$"Downloaded file hash mismatch: expected {expectedHash}, got {actualHash}");
}
}
// Extract
if (downloadUrl.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) ||
downloadUrl.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase))
{
await ExtractTarGzAsync(tempFile, targetDir, cancellationToken).ConfigureAwait(false);
}
else if (downloadUrl.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
ZipFile.ExtractToDirectory(tempFile, targetDir, overwriteFiles: true);
}
else if (downloadUrl.EndsWith(".jar", StringComparison.OrdinalIgnoreCase))
{
var jarPath = Path.Combine(targetDir, spec.ExecutableName);
File.Move(tempFile, jarPath, overwrite: true);
return; // JAR doesn't need extraction
}
// Set executable permission on Unix
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var execPath = GetExecutablePath(spec);
if (File.Exists(execPath))
{
File.SetUnixFileMode(execPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
}
}
_logger.LogInformation("Validator {Validator} v{Version} installed to {Path}",
spec.Name, spec.Version, targetDir);
}
finally
{
if (File.Exists(tempFile))
{
try { File.Delete(tempFile); }
catch { /* ignore cleanup errors */ }
}
}
}
private static string GetPlatformKey(ValidatorBinarySpec spec)
{
if (spec.IsJar)
{
return "jar";
}
var os = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows"
: RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "darwin"
: "linux";
var arch = RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => "amd64",
Architecture.Arm64 => "arm64",
_ => "amd64"
};
return $"{os}-{arch}";
}
private static string GetDownloadUrl(ValidatorBinarySpec spec, string platformKey)
{
if (spec.IsJar)
{
var fileName = string.Format(CultureInfo.InvariantCulture, spec.FileNameFormat, spec.Version);
return $"{spec.BaseUrl}/{fileName}";
}
var parts = platformKey.Split('-');
var os = parts[0];
var arch = parts[1];
var fileName2 = string.Format(CultureInfo.InvariantCulture, spec.FileNameFormat, spec.Version, os, arch);
return $"{spec.BaseUrl}/{fileName2}";
}
private static async Task<string> ComputeFileHashAsync(string filePath, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(filePath);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
return Convert.ToHexStringLower(hash);
}
private static bool IsPlaceholderHash(string hash)
{
return hash.All(c => c == '0');
}
private static async Task ExtractTarGzAsync(
string tarGzPath,
string targetDir,
CancellationToken cancellationToken)
{
// Use System.Formats.Tar for extraction
await using var fileStream = File.OpenRead(tarGzPath);
await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
await System.Formats.Tar.TarFile.ExtractToDirectoryAsync(
gzipStream,
targetDir,
overwriteFiles: true,
cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Specification for a validator binary.
/// </summary>
public sealed record ValidatorBinarySpec
{
/// <summary>
/// Gets the validator name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gets the version to download.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Gets the base URL for downloads.
/// </summary>
public required string BaseUrl { get; init; }
/// <summary>
/// Gets the file name format string (use {0} for version, {1} for OS, {2} for arch).
/// </summary>
public required string FileNameFormat { get; init; }
/// <summary>
/// Gets the expected SHA-256 hashes by platform key.
/// </summary>
public ImmutableDictionary<string, string> ExpectedHashes { get; init; } =
ImmutableDictionary<string, string>.Empty;
/// <summary>
/// Gets the executable name (without extension on Unix).
/// </summary>
public required string ExecutableName { get; init; }
/// <summary>
/// Gets whether this is a JAR file (requires Java runtime).
/// </summary>
public bool IsJar { get; init; }
}
/// <summary>
/// Options for the validator binary manager.
/// </summary>
public sealed class ValidatorBinaryOptions
{
/// <summary>
/// Gets or sets the directory for storing validator binaries.
/// </summary>
public string? BinaryDirectory { get; set; }
/// <summary>
/// Gets or sets whether to operate in offline mode (no downloads).
/// </summary>
public bool OfflineMode { get; set; }
/// <summary>
/// Gets or sets the download timeout.
/// </summary>
public TimeSpan DownloadTimeout { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets custom binary specifications to override defaults.
/// </summary>
public IReadOnlyDictionary<string, ValidatorBinarySpec>? CustomSpecs { get; set; }
}
/// <summary>
/// Exception thrown when validator binary operations fail.
/// </summary>
public sealed class ValidatorBinaryException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="ValidatorBinaryException"/> class.
/// </summary>
/// <param name="message">The error message.</param>
public ValidatorBinaryException(string message) : base(message)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ValidatorBinaryException"/> class.
/// </summary>
/// <param name="message">The error message.</param>
/// <param name="innerException">The inner exception.</param>
public ValidatorBinaryException(string message, Exception innerException) : base(message, innerException)
{
}
}