Files
git.stella-ops.org/docs/modules/sarif-export/architecture.md
2026-01-09 18:27:46 +02:00

18 KiB

SARIF Export Module Architecture

Overview

The SARIF Export module provides SARIF 2.1.0 compliant output for StellaOps Scanner findings, enabling integration with GitHub Code Scanning, GitLab SAST, Azure DevOps, and other platforms that consume SARIF.

Current State

Component Status Location
SARIF 2.1.0 Models Implemented Scanner.Sarif/Models/SarifModels.cs
SmartDiff SARIF Generator Implemented Scanner.SmartDiff/Output/SarifOutputGenerator.cs
SmartDiff SARIF Endpoint Implemented GET /smart-diff/scans/{scanId}/sarif
Findings SARIF Mapper Implemented Scanner.Sarif/SarifExportService.cs
SARIF Rule Registry Implemented Scanner.Sarif/Rules/SarifRuleRegistry.cs
Fingerprint Generator Implemented Scanner.Sarif/Fingerprints/FingerprintGenerator.cs
GitHub Upload Client Not Implemented Proposed

Module Location

src/Scanner/__Libraries/StellaOps.Scanner.Sarif/
├── ISarifExportService.cs                   # Main export interface
├── SarifExportService.cs                    # Implementation (DONE)
├── SarifExportOptions.cs                    # Configuration (DONE)
├── FindingInput.cs                          # Input model (DONE)
├── Models/
│   └── SarifModels.cs                       # Complete SARIF 2.1.0 types (DONE)
├── Rules/
│   ├── ISarifRuleRegistry.cs                # Rule registry interface (DONE)
│   └── SarifRuleRegistry.cs                 # 21 rules implemented (DONE)
└── Fingerprints/
    ├── IFingerprintGenerator.cs             # Fingerprint interface (DONE)
    └── FingerprintGenerator.cs              # SHA-256 fingerprints (DONE)

Existing SmartDiff SARIF Implementation

The SmartDiff module provides a reference implementation:

SarifModels.cs (Existing)

// Already implemented record types
public sealed record SarifLog(
    string Version,
    string Schema,
    ImmutableArray<SarifRun> Runs);

public sealed record SarifRun(
    SarifTool Tool,
    ImmutableArray<SarifResult> Results,
    ImmutableArray<SarifArtifact> Artifacts,
    ImmutableArray<SarifVersionControlDetails> VersionControlProvenance,
    ImmutableDictionary<string, object> Properties);

public sealed record SarifResult(
    string RuleId,
    int? RuleIndex,
    SarifLevel Level,
    SarifMessage Message,
    ImmutableArray<SarifLocation> Locations,
    ImmutableDictionary<string, string> Fingerprints,
    ImmutableDictionary<string, string> PartialFingerprints,
    ImmutableDictionary<string, object> Properties);

SarifOutputGenerator.cs (Existing)

// Existing generator for SmartDiff findings
public class SarifOutputGenerator
{
    public SarifLog Generate(
        IEnumerable<MaterialRiskChangeResult> changes,
        SarifOutputOptions options);
}

New Findings SARIF Architecture

ISarifExportService

namespace StellaOps.Scanner.Sarif;

/// <summary>
/// Service for exporting scanner findings to SARIF format.
/// </summary>
public interface ISarifExportService
{
    /// <summary>
    /// Export findings to SARIF 2.1.0 format.
    /// </summary>
    /// <param name="findings">Scanner findings to export.</param>
    /// <param name="options">Export options.</param>
    /// <param name="ct">Cancellation token.</param>
    /// <returns>SARIF log document.</returns>
    Task<SarifLog> ExportAsync(
        IEnumerable<Finding> findings,
        SarifExportOptions options,
        CancellationToken ct);

    /// <summary>
    /// Export findings to SARIF JSON string.
    /// </summary>
    Task<string> ExportToJsonAsync(
        IEnumerable<Finding> findings,
        SarifExportOptions options,
        CancellationToken ct);

    /// <summary>
    /// Export findings to SARIF JSON stream.
    /// </summary>
    Task ExportToStreamAsync(
        IEnumerable<Finding> findings,
        SarifExportOptions options,
        Stream outputStream,
        CancellationToken ct);

    /// <summary>
    /// Validate SARIF output against schema.
    /// </summary>
    Task<SarifValidationResult> ValidateAsync(
        SarifLog log,
        CancellationToken ct);
}

SarifExportOptions

namespace StellaOps.Scanner.Sarif;

/// <summary>
/// Options for SARIF export.
/// </summary>
public sealed record SarifExportOptions
{
    /// <summary>Tool name in SARIF output.</summary>
    public string ToolName { get; init; } = "StellaOps Scanner";

    /// <summary>Tool version.</summary>
    public required string ToolVersion { get; init; }

    /// <summary>Tool information URI.</summary>
    public string ToolUri { get; init; } = "https://stellaops.io/scanner";

    /// <summary>Minimum severity to include.</summary>
    public Severity? MinimumSeverity { get; init; }

    /// <summary>Include reachability evidence in properties.</summary>
    public bool IncludeReachability { get; init; } = true;

    /// <summary>Include VEX status in properties.</summary>
    public bool IncludeVexStatus { get; init; } = true;

    /// <summary>Include EPSS scores in properties.</summary>
    public bool IncludeEpss { get; init; } = true;

    /// <summary>Include KEV status in properties.</summary>
    public bool IncludeKev { get; init; } = true;

    /// <summary>Include evidence URIs in properties.</summary>
    public bool IncludeEvidenceUris { get; init; } = false;

    /// <summary>Include attestation reference in run properties.</summary>
    public bool IncludeAttestation { get; init; } = true;

    /// <summary>Version control provenance.</summary>
    public VersionControlInfo? VersionControl { get; init; }

    /// <summary>Pretty-print JSON output.</summary>
    public bool IndentedJson { get; init; } = false;

    /// <summary>Category for GitHub upload (distinguishes multiple tools).</summary>
    public string? Category { get; init; }

    /// <summary>Base URI for source files.</summary>
    public string? SourceRoot { get; init; }
}

public sealed record VersionControlInfo
{
    public required string RepositoryUri { get; init; }
    public required string RevisionId { get; init; }
    public string? Branch { get; init; }
}

Rule Registry

ISarifRuleRegistry

namespace StellaOps.Scanner.Sarif.Rules;

/// <summary>
/// Registry of SARIF rules for StellaOps findings.
/// </summary>
public interface ISarifRuleRegistry
{
    /// <summary>Get rule by ID.</summary>
    SarifRule? GetRule(string ruleId);

    /// <summary>Get rule for finding type and severity.</summary>
    SarifRule GetRuleForFinding(FindingType type, Severity severity);

    /// <summary>Get all registered rules.</summary>
    IReadOnlyList<SarifRule> GetAllRules();

    /// <summary>Get rules by category.</summary>
    IReadOnlyList<SarifRule> GetRulesByCategory(string category);
}

Rule Definitions

namespace StellaOps.Scanner.Sarif.Rules;

public static class VulnerabilityRules
{
    public static readonly SarifRule Critical = new()
    {
        Id = "STELLA-VULN-001",
        Name = "CriticalVulnerability",
        ShortDescription = "Critical vulnerability detected (CVSS >= 9.0)",
        FullDescription = "A critical severity vulnerability was detected. " +
                          "This may be a known exploited vulnerability (KEV) or " +
                          "have a CVSS score of 9.0 or higher.",
        HelpUri = "https://stellaops.io/rules/STELLA-VULN-001",
        DefaultLevel = SarifLevel.Error,
        Properties = new Dictionary<string, object>
        {
            ["precision"] = "high",
            ["problem.severity"] = "error",
            ["security-severity"] = "10.0",
            ["tags"] = new[] { "security", "vulnerability", "critical" }
        }.ToImmutableDictionary()
    };

    public static readonly SarifRule High = new()
    {
        Id = "STELLA-VULN-002",
        Name = "HighVulnerability",
        ShortDescription = "High severity vulnerability detected (CVSS 7.0-8.9)",
        FullDescription = "A high severity vulnerability was detected with " +
                          "CVSS score between 7.0 and 8.9.",
        HelpUri = "https://stellaops.io/rules/STELLA-VULN-002",
        DefaultLevel = SarifLevel.Error,
        Properties = new Dictionary<string, object>
        {
            ["precision"] = "high",
            ["problem.severity"] = "error",
            ["security-severity"] = "8.0",
            ["tags"] = new[] { "security", "vulnerability", "high" }
        }.ToImmutableDictionary()
    };

    public static readonly SarifRule Medium = new()
    {
        Id = "STELLA-VULN-003",
        Name = "MediumVulnerability",
        ShortDescription = "Medium severity vulnerability detected (CVSS 4.0-6.9)",
        HelpUri = "https://stellaops.io/rules/STELLA-VULN-003",
        DefaultLevel = SarifLevel.Warning,
        Properties = new Dictionary<string, object>
        {
            ["precision"] = "high",
            ["problem.severity"] = "warning",
            ["security-severity"] = "5.5",
            ["tags"] = new[] { "security", "vulnerability", "medium" }
        }.ToImmutableDictionary()
    };

    public static readonly SarifRule Low = new()
    {
        Id = "STELLA-VULN-004",
        Name = "LowVulnerability",
        ShortDescription = "Low severity vulnerability detected (CVSS < 4.0)",
        HelpUri = "https://stellaops.io/rules/STELLA-VULN-004",
        DefaultLevel = SarifLevel.Note,
        Properties = new Dictionary<string, object>
        {
            ["precision"] = "high",
            ["problem.severity"] = "note",
            ["security-severity"] = "2.0",
            ["tags"] = new[] { "security", "vulnerability", "low" }
        }.ToImmutableDictionary()
    };

    // Reachability-enhanced rules
    public static readonly SarifRule RuntimeReachable = new()
    {
        Id = "STELLA-VULN-005",
        Name = "ReachableVulnerability",
        ShortDescription = "Runtime-confirmed reachable vulnerability",
        FullDescription = "A vulnerability with runtime-confirmed reachability. " +
                          "The vulnerable code path was observed during execution.",
        HelpUri = "https://stellaops.io/rules/STELLA-VULN-005",
        DefaultLevel = SarifLevel.Error,
        Properties = new Dictionary<string, object>
        {
            ["precision"] = "very-high",
            ["problem.severity"] = "error",
            ["security-severity"] = "9.5",
            ["tags"] = new[] { "security", "vulnerability", "reachable", "runtime" }
        }.ToImmutableDictionary()
    };
}

Fingerprint Generation

IFingerprintGenerator

namespace StellaOps.Scanner.Sarif.Fingerprints;

/// <summary>
/// Generates deterministic fingerprints for SARIF deduplication.
/// </summary>
public interface IFingerprintGenerator
{
    /// <summary>
    /// Generate primary fingerprint for a finding.
    /// </summary>
    string GeneratePrimary(Finding finding, FingerprintStrategy strategy);

    /// <summary>
    /// Generate partial fingerprints for GitHub fallback.
    /// </summary>
    ImmutableDictionary<string, string> GeneratePartial(
        Finding finding,
        string? sourceContent);
}

public enum FingerprintStrategy
{
    /// <summary>Hash of ruleId + purl + vulnId + artifactDigest.</summary>
    Standard,

    /// <summary>Hash including file location for source-level findings.</summary>
    WithLocation,

    /// <summary>Hash including content hash for maximum stability.</summary>
    ContentBased
}

Implementation

public class FingerprintGenerator : IFingerprintGenerator
{
    public string GeneratePrimary(Finding finding, FingerprintStrategy strategy)
    {
        var input = strategy switch
        {
            FingerprintStrategy.Standard => string.Join("|",
                finding.RuleId,
                finding.ComponentPurl,
                finding.VulnerabilityId ?? "",
                finding.ArtifactDigest),

            FingerprintStrategy.WithLocation => string.Join("|",
                finding.RuleId,
                finding.ComponentPurl,
                finding.VulnerabilityId ?? "",
                finding.ArtifactDigest,
                finding.FilePath ?? "",
                finding.LineNumber?.ToString(CultureInfo.InvariantCulture) ?? ""),

            FingerprintStrategy.ContentBased => string.Join("|",
                finding.RuleId,
                finding.ComponentPurl,
                finding.VulnerabilityId ?? "",
                finding.ContentHash ?? finding.ArtifactDigest),

            _ => throw new ArgumentOutOfRangeException(nameof(strategy))
        };

        return ComputeSha256(input);
    }

    public ImmutableDictionary<string, string> GeneratePartial(
        Finding finding,
        string? sourceContent)
    {
        var partial = new Dictionary<string, string>();

        // Line hash for GitHub deduplication
        if (!string.IsNullOrEmpty(sourceContent) && finding.LineNumber.HasValue)
        {
            var lines = sourceContent.Split('\n');
            if (finding.LineNumber.Value <= lines.Length)
            {
                var line = lines[finding.LineNumber.Value - 1];
                partial["primaryLocationLineHash"] = ComputeSha256(line.Trim());
            }
        }

        return partial.ToImmutableDictionary();
    }

    private static string ComputeSha256(string input)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
        return Convert.ToHexString(bytes).ToLowerInvariant();
    }
}

Severity Mapping

public static class SeverityMapper
{
    public static SarifLevel MapToSarifLevel(Severity severity, bool isReachable = false)
    {
        // Reachable vulnerabilities are always error level
        if (isReachable && severity >= Severity.Medium)
            return SarifLevel.Error;

        return severity switch
        {
            Severity.Critical => SarifLevel.Error,
            Severity.High => SarifLevel.Error,
            Severity.Medium => SarifLevel.Warning,
            Severity.Low => SarifLevel.Note,
            Severity.Info => SarifLevel.Note,
            _ => SarifLevel.None
        };
    }

    public static double MapToSecuritySeverity(double cvssScore)
    {
        // GitHub uses security-severity for ordering
        // Map CVSS 0-10 scale directly
        return Math.Clamp(cvssScore, 0.0, 10.0);
    }
}

Determinism Requirements

Following CLAUDE.md rules:

  1. Canonical JSON: RFC 8785 sorted keys, no nulls
  2. Stable Rule Ordering: Rules sorted by ID
  3. Stable Result Ordering: Results sorted by (ruleId, location, fingerprint)
  4. Time Injection: Use TimeProvider for timestamps
  5. Culture Invariance: InvariantCulture for all string operations
  6. Immutable Collections: All outputs use ImmutableArray, ImmutableDictionary

API Endpoints

Scanner Export Endpoints

public static class SarifExportEndpoints
{
    public static void MapSarifEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/v1/scans/{scanId}/exports")
            .RequireAuthorization("scanner:read");

        // SARIF export
        group.MapGet("/sarif", ExportSarif)
            .WithName("ExportScanSarif")
            .Produces<string>(StatusCodes.Status200OK, "application/sarif+json");

        // SARIF with options
        group.MapPost("/sarif", ExportSarifWithOptions)
            .WithName("ExportScanSarifWithOptions")
            .Produces<string>(StatusCodes.Status200OK, "application/sarif+json");
    }

    private static async Task<IResult> ExportSarif(
        Guid scanId,
        [FromQuery] string? minSeverity,
        [FromQuery] bool pretty = false,
        [FromQuery] bool includeReachability = true,
        ISarifExportService sarifService,
        IFindingsService findingsService,
        CancellationToken ct)
    {
        var findings = await findingsService.GetByScanIdAsync(scanId, ct);

        var options = new SarifExportOptions
        {
            ToolVersion = GetToolVersion(),
            MinimumSeverity = ParseSeverity(minSeverity),
            IncludeReachability = includeReachability,
            IndentedJson = pretty
        };

        var json = await sarifService.ExportToJsonAsync(findings, options, ct);

        return Results.Content(json, "application/sarif+json");
    }
}

Integration with GitHub

See src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitHubApp/ for GitHub connector.

New GitHub Code Scanning client extends existing infrastructure:

public interface IGitHubCodeScanningClient
{
    /// <summary>Upload SARIF to GitHub Code Scanning.</summary>
    Task<SarifUploadResult> UploadSarifAsync(
        string owner,
        string repo,
        SarifUploadRequest request,
        CancellationToken ct);

    /// <summary>Get upload status.</summary>
    Task<SarifUploadStatus> GetUploadStatusAsync(
        string owner,
        string repo,
        string sarifId,
        CancellationToken ct);

    /// <summary>List code scanning alerts.</summary>
    Task<IReadOnlyList<CodeScanningAlert>> ListAlertsAsync(
        string owner,
        string repo,
        AlertFilter? filter,
        CancellationToken ct);
}

Performance Targets

Operation Target P95 Notes
Export 100 findings < 100ms In-memory
Export 10,000 findings < 5s Streaming
SARIF serialization < 50ms/MB RFC 8785
Schema validation < 200ms JSON Schema
Fingerprint generation < 1ms/finding SHA-256


Last updated: 09-Jan-2026