sprints and audit work

This commit is contained in:
StellaOps Bot
2026-01-07 09:36:16 +02:00
parent 05833e0af2
commit ab364c6032
377 changed files with 64534 additions and 1627 deletions

View File

@@ -85,11 +85,7 @@ public sealed class SecretsAnalyzer : ILanguageAnalyzer
continue;
}
<<<<<<< HEAD
var evidence = SecretLeakEvidence.FromMatch(match, _masker, _ruleset, _timeProvider);
=======
var evidence = SecretLeakEvidence.FromMatch(match, _masker, _ruleset!, _timeProvider);
>>>>>>> 47890273170663b2236a1eb995d218fe5de6b11a
findings.Add(evidence);
}
}

View File

@@ -54,4 +54,10 @@ public static class ScanAnalysisKeys
// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
public const string SecretFindings = "analysis.secrets.findings";
public const string SecretRulesetVersion = "analysis.secrets.ruleset.version";
// Sprint: SPRINT_20260106_003_002 - VEX Gate Service
public const string VexGateResults = "analysis.vexgate.results";
public const string VexGateSummary = "analysis.vexgate.summary";
public const string VexGatePolicyVersion = "analysis.vexgate.policy.version";
public const string VexGateBypassed = "analysis.vexgate.bypassed";
}

View File

@@ -102,11 +102,11 @@ public sealed class ProofBundleWriterOptions
/// Default implementation of IProofBundleWriter.
/// Creates ZIP bundles with the following structure:
/// bundle.zip/
/// ├── manifest.json # Canonical JSON scan manifest
/// ├── manifest.dsse.json # DSSE envelope for manifest
/// ├── score_proof.json # ProofLedger nodes array
/// ├── proof_root.dsse.json # DSSE envelope for root hash (optional)
/// └── meta.json # Bundle metadata
/// manifest.json - Canonical JSON scan manifest
/// manifest.dsse.json - DSSE envelope for manifest
/// score_proof.json - ProofLedger nodes array
/// proof_root.dsse.json - DSSE envelope for root hash (optional)
/// meta.json - Bundle metadata
/// </summary>
public sealed class ProofBundleWriter : IProofBundleWriter
{

View File

@@ -13,7 +13,7 @@ namespace StellaOps.Scanner.Core;
/// <summary>
/// Captures all inputs that affect a scan's results.
/// Per advisory "Building a Deeper Moat Beyond Reachability" §12.
/// Per advisory "Building a Deeper Moat Beyond Reachability" section 12.
/// This manifest ensures reproducibility: same manifest + same seed = same results.
/// </summary>
/// <param name="ScanId">Unique identifier for this scan run.</param>

View File

@@ -0,0 +1,320 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Service for building and validating composition recipes.
/// </summary>
public interface ICompositionRecipeService
{
/// <summary>
/// Builds a composition recipe from a composition result.
/// </summary>
CompositionRecipeResponse BuildRecipe(
string scanId,
string imageDigest,
DateTimeOffset createdAt,
SbomCompositionResult compositionResult,
string? generatorName = null,
string? generatorVersion = null);
/// <summary>
/// Verifies a composition recipe against stored SBOMs.
/// </summary>
CompositionRecipeVerificationResult Verify(
CompositionRecipeResponse recipe,
ImmutableArray<LayerSbomRef> actualLayerSboms);
}
/// <summary>
/// API response for composition recipe endpoint.
/// </summary>
public sealed record CompositionRecipeResponse
{
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
[JsonPropertyName("imageDigest")]
public required string ImageDigest { get; init; }
[JsonPropertyName("createdAt")]
public required string CreatedAt { get; init; }
[JsonPropertyName("recipe")]
public required CompositionRecipe Recipe { get; init; }
}
/// <summary>
/// The composition recipe itself.
/// </summary>
public sealed record CompositionRecipe
{
[JsonPropertyName("version")]
public required string Version { get; init; }
[JsonPropertyName("generatorName")]
public required string GeneratorName { get; init; }
[JsonPropertyName("generatorVersion")]
public required string GeneratorVersion { get; init; }
[JsonPropertyName("layers")]
public required ImmutableArray<CompositionRecipeLayer> Layers { get; init; }
[JsonPropertyName("merkleRoot")]
public required string MerkleRoot { get; init; }
[JsonPropertyName("aggregatedSbomDigests")]
public required AggregatedSbomDigests AggregatedSbomDigests { get; init; }
}
/// <summary>
/// A single layer in the composition recipe.
/// </summary>
public sealed record CompositionRecipeLayer
{
[JsonPropertyName("digest")]
public required string Digest { get; init; }
[JsonPropertyName("order")]
public required int Order { get; init; }
[JsonPropertyName("fragmentDigest")]
public required string FragmentDigest { get; init; }
[JsonPropertyName("sbomDigests")]
public required LayerSbomDigests SbomDigests { get; init; }
[JsonPropertyName("componentCount")]
public required int ComponentCount { get; init; }
}
/// <summary>
/// Digests for a layer's SBOMs.
/// </summary>
public sealed record LayerSbomDigests
{
[JsonPropertyName("cyclonedx")]
public required string CycloneDx { get; init; }
[JsonPropertyName("spdx")]
public required string Spdx { get; init; }
}
/// <summary>
/// Digests for the aggregated (image-level) SBOMs.
/// </summary>
public sealed record AggregatedSbomDigests
{
[JsonPropertyName("cyclonedx")]
public required string CycloneDx { get; init; }
[JsonPropertyName("spdx")]
public string? Spdx { get; init; }
}
/// <summary>
/// Result of composition recipe verification.
/// </summary>
public sealed record CompositionRecipeVerificationResult
{
[JsonPropertyName("valid")]
public required bool Valid { get; init; }
[JsonPropertyName("merkleRootMatch")]
public required bool MerkleRootMatch { get; init; }
[JsonPropertyName("layerDigestsMatch")]
public required bool LayerDigestsMatch { get; init; }
[JsonPropertyName("errors")]
public ImmutableArray<string> Errors { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// Default implementation of <see cref="ICompositionRecipeService"/>.
/// </summary>
public sealed class CompositionRecipeService : ICompositionRecipeService
{
private const string RecipeVersion = "1.0.0";
/// <inheritdoc />
public CompositionRecipeResponse BuildRecipe(
string scanId,
string imageDigest,
DateTimeOffset createdAt,
SbomCompositionResult compositionResult,
string? generatorName = null,
string? generatorVersion = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
ArgumentNullException.ThrowIfNull(compositionResult);
var layers = compositionResult.LayerSboms
.Select(layer => new CompositionRecipeLayer
{
Digest = layer.LayerDigest,
Order = layer.Order,
FragmentDigest = layer.FragmentDigest,
SbomDigests = new LayerSbomDigests
{
CycloneDx = layer.CycloneDxDigest,
Spdx = layer.SpdxDigest,
},
ComponentCount = layer.ComponentCount,
})
.OrderBy(l => l.Order)
.ToImmutableArray();
var merkleRoot = compositionResult.LayerSbomMerkleRoot ?? ComputeMerkleRoot(layers);
var recipe = new CompositionRecipe
{
Version = RecipeVersion,
GeneratorName = generatorName ?? "StellaOps.Scanner",
GeneratorVersion = generatorVersion ?? "2026.04",
Layers = layers,
MerkleRoot = merkleRoot,
AggregatedSbomDigests = new AggregatedSbomDigests
{
CycloneDx = compositionResult.Inventory.JsonSha256,
Spdx = compositionResult.SpdxInventory?.JsonSha256,
},
};
return new CompositionRecipeResponse
{
ScanId = scanId,
ImageDigest = imageDigest,
CreatedAt = ScannerTimestamps.ToIso8601(createdAt),
Recipe = recipe,
};
}
/// <inheritdoc />
public CompositionRecipeVerificationResult Verify(
CompositionRecipeResponse recipe,
ImmutableArray<LayerSbomRef> actualLayerSboms)
{
ArgumentNullException.ThrowIfNull(recipe);
var errors = ImmutableArray.CreateBuilder<string>();
var layerDigestsMatch = true;
if (recipe.Recipe.Layers.Length != actualLayerSboms.Length)
{
errors.Add($"Layer count mismatch: expected {recipe.Recipe.Layers.Length}, got {actualLayerSboms.Length}");
layerDigestsMatch = false;
}
else
{
for (var i = 0; i < recipe.Recipe.Layers.Length; i++)
{
var expected = recipe.Recipe.Layers[i];
var actual = actualLayerSboms.FirstOrDefault(l => l.Order == expected.Order);
if (actual is null)
{
errors.Add($"Missing layer at order {expected.Order}");
layerDigestsMatch = false;
continue;
}
if (expected.Digest != actual.LayerDigest)
{
errors.Add($"Layer {i} digest mismatch: expected {expected.Digest}, got {actual.LayerDigest}");
layerDigestsMatch = false;
}
if (expected.SbomDigests.CycloneDx != actual.CycloneDxDigest)
{
errors.Add($"Layer {i} CycloneDX digest mismatch: expected {expected.SbomDigests.CycloneDx}, got {actual.CycloneDxDigest}");
layerDigestsMatch = false;
}
if (expected.SbomDigests.Spdx != actual.SpdxDigest)
{
errors.Add($"Layer {i} SPDX digest mismatch: expected {expected.SbomDigests.Spdx}, got {actual.SpdxDigest}");
layerDigestsMatch = false;
}
}
}
var computedMerkleRoot = ComputeMerkleRoot(recipe.Recipe.Layers);
var merkleRootMatch = recipe.Recipe.MerkleRoot == computedMerkleRoot;
if (!merkleRootMatch)
{
errors.Add($"Merkle root mismatch: expected {recipe.Recipe.MerkleRoot}, computed {computedMerkleRoot}");
}
return new CompositionRecipeVerificationResult
{
Valid = layerDigestsMatch && merkleRootMatch && errors.Count == 0,
MerkleRootMatch = merkleRootMatch,
LayerDigestsMatch = layerDigestsMatch,
Errors = errors.ToImmutable(),
};
}
private static string ComputeMerkleRoot(ImmutableArray<CompositionRecipeLayer> layers)
{
if (layers.IsDefaultOrEmpty)
{
return ComputeSha256(Array.Empty<byte>());
}
var leaves = layers
.OrderBy(l => l.Order)
.Select(l => HexToBytes(l.SbomDigests.CycloneDx))
.ToList();
if (leaves.Count == 1)
{
return Convert.ToHexString(leaves[0]).ToLowerInvariant();
}
var nodes = leaves;
while (nodes.Count > 1)
{
var nextLevel = new List<byte[]>();
for (var i = 0; i < nodes.Count; i += 2)
{
if (i + 1 < nodes.Count)
{
var combined = new byte[nodes[i].Length + nodes[i + 1].Length];
Buffer.BlockCopy(nodes[i], 0, combined, 0, nodes[i].Length);
Buffer.BlockCopy(nodes[i + 1], 0, combined, nodes[i].Length, nodes[i + 1].Length);
nextLevel.Add(SHA256.HashData(combined));
}
else
{
nextLevel.Add(nodes[i]);
}
}
nodes = nextLevel;
}
return Convert.ToHexString(nodes[0]).ToLowerInvariant();
}
private static string ComputeSha256(byte[] bytes)
{
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
private static byte[] HexToBytes(string hex)
{
return Convert.FromHexString(hex);
}
}

View File

@@ -0,0 +1,265 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using CycloneDX;
using CycloneDX.Models;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
using JsonSerializer = CycloneDX.Json.Serializer;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Writes per-layer SBOMs in CycloneDX 1.7 format.
/// </summary>
public sealed class CycloneDxLayerWriter : ILayerSbomWriter
{
private static readonly Guid SerialNamespace = new("1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d");
/// <inheritdoc />
public string Format => "cyclonedx";
/// <inheritdoc />
public Task<LayerSbomOutput> WriteAsync(LayerSbomRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt);
var bom = BuildLayerBom(request, generatedAt);
var json16 = JsonSerializer.Serialize(bom);
var json = CycloneDx17Extensions.UpgradeJsonTo17(json16);
var jsonBytes = Encoding.UTF8.GetBytes(json);
var jsonDigest = ComputeSha256(jsonBytes);
var output = new LayerSbomOutput
{
LayerDigest = request.LayerDigest,
Format = Format,
JsonBytes = jsonBytes,
JsonDigest = jsonDigest,
MediaType = CycloneDx17Extensions.MediaTypes.InventoryJson,
ComponentCount = request.Components.Length,
};
return Task.FromResult(output);
}
private static Bom BuildLayerBom(LayerSbomRequest request, DateTimeOffset generatedAt)
{
// Note: CycloneDX.Core 10.x does not yet have v1_7 enum; serialize as v1_6 then upgrade via UpgradeJsonTo17()
var bom = new Bom
{
SpecVersion = SpecificationVersion.v1_6,
Version = 1,
Metadata = BuildMetadata(request, generatedAt),
Components = BuildComponents(request.Components),
Dependencies = BuildDependencies(request.Components),
};
var serialPayload = $"{request.Image.ImageDigest}|layer:{request.LayerDigest}|{ScannerTimestamps.ToIso8601(generatedAt)}";
bom.SerialNumber = $"urn:uuid:{ScannerIdentifiers.CreateDeterministicGuid(SerialNamespace, Encoding.UTF8.GetBytes(serialPayload)).ToString("d", CultureInfo.InvariantCulture)}";
return bom;
}
private static Metadata BuildMetadata(LayerSbomRequest request, DateTimeOffset generatedAt)
{
var layerDigestShort = request.LayerDigest.Split(':', 2, StringSplitOptions.TrimEntries)[^1];
var bomRef = $"layer:{layerDigestShort}";
var metadata = new Metadata
{
Timestamp = generatedAt.UtcDateTime,
Component = new Component
{
BomRef = bomRef,
Type = Component.Classification.Container,
Name = $"layer-{request.LayerOrder}",
Version = layerDigestShort,
Properties = new List<Property>
{
new() { Name = "stellaops:layer.digest", Value = request.LayerDigest },
new() { Name = "stellaops:layer.order", Value = request.LayerOrder.ToString(CultureInfo.InvariantCulture) },
new() { Name = "stellaops:image.digest", Value = request.Image.ImageDigest },
},
},
Properties = new List<Property>
{
new() { Name = "stellaops:sbom.type", Value = "layer" },
new() { Name = "stellaops:sbom.view", Value = "inventory" },
},
};
if (!string.IsNullOrWhiteSpace(request.Image.ImageReference))
{
metadata.Component.Properties.Add(new Property
{
Name = "stellaops:image.reference",
Value = request.Image.ImageReference,
});
}
if (!string.IsNullOrWhiteSpace(request.GeneratorName))
{
metadata.Properties.Add(new Property
{
Name = "stellaops:generator.name",
Value = request.GeneratorName,
});
if (!string.IsNullOrWhiteSpace(request.GeneratorVersion))
{
metadata.Properties.Add(new Property
{
Name = "stellaops:generator.version",
Value = request.GeneratorVersion,
});
}
}
return metadata;
}
private static List<Component> BuildComponents(ImmutableArray<ComponentRecord> components)
{
var result = new List<Component>(components.Length);
foreach (var component in components.OrderBy(static c => c.Identity.Key, StringComparer.Ordinal))
{
var model = new Component
{
BomRef = component.Identity.Key,
Name = component.Identity.Name,
Version = component.Identity.Version,
Purl = component.Identity.Purl,
Group = component.Identity.Group,
Type = MapClassification(component.Identity.ComponentType),
Scope = MapScope(component.Metadata?.Scope),
Properties = BuildProperties(component),
};
result.Add(model);
}
return result;
}
private static List<Property>? BuildProperties(ComponentRecord component)
{
var properties = new List<Property>();
if (component.Metadata?.Properties is not null)
{
foreach (var property in component.Metadata.Properties.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
properties.Add(new Property
{
Name = property.Key,
Value = property.Value,
});
}
}
if (!string.IsNullOrWhiteSpace(component.Metadata?.BuildId))
{
properties.Add(new Property
{
Name = "stellaops:buildId",
Value = component.Metadata!.BuildId,
});
}
properties.Add(new Property { Name = "stellaops:layerDigest", Value = component.LayerDigest });
for (var index = 0; index < component.Evidence.Length; index++)
{
var evidence = component.Evidence[index];
var builder = new StringBuilder(evidence.Kind);
builder.Append(':').Append(evidence.Value);
if (!string.IsNullOrWhiteSpace(evidence.Source))
{
builder.Append('@').Append(evidence.Source);
}
properties.Add(new Property
{
Name = $"stellaops:evidence[{index}]",
Value = builder.ToString(),
});
}
return properties.Count == 0 ? null : properties;
}
private static List<Dependency>? BuildDependencies(ImmutableArray<ComponentRecord> components)
{
var componentKeys = components.Select(static c => c.Identity.Key).ToImmutableHashSet(StringComparer.Ordinal);
var dependencies = new List<Dependency>();
foreach (var component in components.OrderBy(static c => c.Identity.Key, StringComparer.Ordinal))
{
if (component.Dependencies.IsDefaultOrEmpty || component.Dependencies.Length == 0)
{
continue;
}
var filtered = component.Dependencies.Where(componentKeys.Contains).OrderBy(k => k, StringComparer.Ordinal).ToArray();
if (filtered.Length == 0)
{
continue;
}
dependencies.Add(new Dependency
{
Ref = component.Identity.Key,
Dependencies = filtered.Select(key => new Dependency { Ref = key }).ToList(),
});
}
return dependencies.Count == 0 ? null : dependencies;
}
private static Component.Classification MapClassification(string? type)
{
if (string.IsNullOrWhiteSpace(type))
{
return Component.Classification.Library;
}
return type.Trim().ToLowerInvariant() switch
{
"application" => Component.Classification.Application,
"framework" => Component.Classification.Framework,
"container" => Component.Classification.Container,
"operating-system" or "os" => Component.Classification.Operating_System,
"device" => Component.Classification.Device,
"firmware" => Component.Classification.Firmware,
"file" => Component.Classification.File,
_ => Component.Classification.Library,
};
}
private static Component.ComponentScope? MapScope(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return null;
}
return scope.Trim().ToLowerInvariant() switch
{
"runtime" or "required" => Component.ComponentScope.Required,
"development" or "optional" => Component.ComponentScope.Optional,
"excluded" => Component.ComponentScope.Excluded,
_ => null,
};
}
private static string ComputeSha256(byte[] bytes)
{
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,100 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Writes per-layer SBOMs in a specific format (CycloneDX or SPDX).
/// </summary>
public interface ILayerSbomWriter
{
/// <summary>
/// The SBOM format produced by this writer.
/// </summary>
string Format { get; }
/// <summary>
/// Generates an SBOM for a single layer's components.
/// </summary>
/// <param name="request">The layer SBOM request containing layer info and components.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The generated SBOM bytes and digest.</returns>
Task<LayerSbomOutput> WriteAsync(LayerSbomRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to generate a per-layer SBOM.
/// </summary>
public sealed record LayerSbomRequest
{
/// <summary>
/// The image this layer belongs to.
/// </summary>
public required ImageArtifactDescriptor Image { get; init; }
/// <summary>
/// The layer digest (e.g., "sha256:abc123...").
/// </summary>
public required string LayerDigest { get; init; }
/// <summary>
/// The order of this layer in the image (0-indexed).
/// </summary>
public required int LayerOrder { get; init; }
/// <summary>
/// Components in this layer.
/// </summary>
public required ImmutableArray<ComponentRecord> Components { get; init; }
/// <summary>
/// When the SBOM was generated.
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Generator name (e.g., "StellaOps.Scanner").
/// </summary>
public string? GeneratorName { get; init; }
/// <summary>
/// Generator version.
/// </summary>
public string? GeneratorVersion { get; init; }
}
/// <summary>
/// Output from a layer SBOM writer.
/// </summary>
public sealed record LayerSbomOutput
{
/// <summary>
/// The layer digest this SBOM represents.
/// </summary>
public required string LayerDigest { get; init; }
/// <summary>
/// The SBOM format (e.g., "cyclonedx", "spdx").
/// </summary>
public required string Format { get; init; }
/// <summary>
/// SBOM JSON bytes.
/// </summary>
public required byte[] JsonBytes { get; init; }
/// <summary>
/// SHA256 digest of the JSON (lowercase hex).
/// </summary>
public required string JsonDigest { get; init; }
/// <summary>
/// Media type of the JSON content.
/// </summary>
public required string MediaType { get; init; }
/// <summary>
/// Number of components in this layer SBOM.
/// </summary>
public required int ComponentCount { get; init; }
}

View File

@@ -0,0 +1,197 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Composes per-layer SBOMs for all layers in an image.
/// </summary>
public interface ILayerSbomComposer
{
/// <summary>
/// Generates per-layer SBOMs for all layers in the composition request.
/// </summary>
/// <param name="request">The composition request containing layer fragments.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Layer SBOM artifacts and references.</returns>
Task<LayerSbomCompositionResult> ComposeAsync(
SbomCompositionRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of per-layer SBOM composition.
/// </summary>
public sealed record LayerSbomCompositionResult
{
/// <summary>
/// Per-layer SBOM artifacts (bytes and digests).
/// </summary>
public required ImmutableArray<LayerSbomArtifact> Artifacts { get; init; }
/// <summary>
/// Per-layer SBOM references for storage in CAS.
/// </summary>
public required ImmutableArray<LayerSbomRef> References { get; init; }
/// <summary>
/// Merkle root computed from all layer SBOM digests (CycloneDX).
/// </summary>
public required string MerkleRoot { get; init; }
}
/// <summary>
/// Default implementation of <see cref="ILayerSbomComposer"/>.
/// </summary>
public sealed class LayerSbomComposer : ILayerSbomComposer
{
private readonly CycloneDxLayerWriter _cdxWriter = new();
private readonly SpdxLayerWriter _spdxWriter;
public LayerSbomComposer(SpdxLayerWriter? spdxWriter = null)
{
_spdxWriter = spdxWriter ?? new SpdxLayerWriter();
}
/// <inheritdoc />
public async Task<LayerSbomCompositionResult> ComposeAsync(
SbomCompositionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (request.LayerFragments.IsDefaultOrEmpty)
{
return new LayerSbomCompositionResult
{
Artifacts = ImmutableArray<LayerSbomArtifact>.Empty,
References = ImmutableArray<LayerSbomRef>.Empty,
MerkleRoot = ComputeSha256(Array.Empty<byte>()),
};
}
var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt);
var artifacts = ImmutableArray.CreateBuilder<LayerSbomArtifact>(request.LayerFragments.Length);
var references = ImmutableArray.CreateBuilder<LayerSbomRef>(request.LayerFragments.Length);
var merkleLeaves = new List<byte[]>();
for (var order = 0; order < request.LayerFragments.Length; order++)
{
var fragment = request.LayerFragments[order];
var layerRequest = new LayerSbomRequest
{
Image = request.Image,
LayerDigest = fragment.LayerDigest,
LayerOrder = order,
Components = fragment.Components,
GeneratedAt = generatedAt,
GeneratorName = request.GeneratorName,
GeneratorVersion = request.GeneratorVersion,
};
var cdxOutput = await _cdxWriter.WriteAsync(layerRequest, cancellationToken).ConfigureAwait(false);
var spdxOutput = await _spdxWriter.WriteAsync(layerRequest, cancellationToken).ConfigureAwait(false);
var fragmentDigest = ComputeFragmentDigest(fragment);
var artifact = new LayerSbomArtifact
{
LayerDigest = fragment.LayerDigest,
CycloneDxJsonBytes = cdxOutput.JsonBytes,
CycloneDxDigest = cdxOutput.JsonDigest,
SpdxJsonBytes = spdxOutput.JsonBytes,
SpdxDigest = spdxOutput.JsonDigest,
ComponentCount = fragment.Components.Length,
};
var reference = new LayerSbomRef
{
LayerDigest = fragment.LayerDigest,
Order = order,
FragmentDigest = fragmentDigest,
CycloneDxDigest = cdxOutput.JsonDigest,
CycloneDxCasUri = $"cas://sbom/layers/{request.Image.ImageDigest}/{fragment.LayerDigest}.cdx.json",
SpdxDigest = spdxOutput.JsonDigest,
SpdxCasUri = $"cas://sbom/layers/{request.Image.ImageDigest}/{fragment.LayerDigest}.spdx.json",
ComponentCount = fragment.Components.Length,
};
artifacts.Add(artifact);
references.Add(reference);
merkleLeaves.Add(HexToBytes(cdxOutput.JsonDigest));
}
var merkleRoot = ComputeMerkleRoot(merkleLeaves);
return new LayerSbomCompositionResult
{
Artifacts = artifacts.ToImmutable(),
References = references.ToImmutable(),
MerkleRoot = merkleRoot,
};
}
private static string ComputeFragmentDigest(LayerComponentFragment fragment)
{
var componentKeys = fragment.Components
.Select(c => c.Identity.Key)
.OrderBy(k => k, StringComparer.Ordinal)
.ToArray();
var payload = $"{fragment.LayerDigest}|{string.Join(",", componentKeys)}";
return ComputeSha256(Encoding.UTF8.GetBytes(payload));
}
private static string ComputeMerkleRoot(List<byte[]> leaves)
{
if (leaves.Count == 0)
{
return ComputeSha256(Array.Empty<byte>());
}
if (leaves.Count == 1)
{
return Convert.ToHexString(leaves[0]).ToLowerInvariant();
}
var nodes = leaves.ToList();
while (nodes.Count > 1)
{
var nextLevel = new List<byte[]>();
for (var i = 0; i < nodes.Count; i += 2)
{
if (i + 1 < nodes.Count)
{
var combined = new byte[nodes[i].Length + nodes[i + 1].Length];
Buffer.BlockCopy(nodes[i], 0, combined, 0, nodes[i].Length);
Buffer.BlockCopy(nodes[i + 1], 0, combined, nodes[i].Length, nodes[i + 1].Length);
nextLevel.Add(SHA256.HashData(combined));
}
else
{
nextLevel.Add(nodes[i]);
}
}
nodes = nextLevel;
}
return Convert.ToHexString(nodes[0]).ToLowerInvariant();
}
private static string ComputeSha256(byte[] bytes)
{
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
private static byte[] HexToBytes(string hex)
{
return Convert.FromHexString(hex);
}
}

View File

@@ -0,0 +1,112 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Reference to a per-layer SBOM stored in CAS.
/// </summary>
public sealed record LayerSbomRef
{
/// <summary>
/// The digest of the layer (e.g., "sha256:abc123...").
/// </summary>
[JsonPropertyName("layerDigest")]
public required string LayerDigest { get; init; }
/// <summary>
/// The order of the layer in the image (0-indexed).
/// </summary>
[JsonPropertyName("order")]
public required int Order { get; init; }
/// <summary>
/// SHA256 digest of the layer fragment (component list).
/// </summary>
[JsonPropertyName("fragmentDigest")]
public required string FragmentDigest { get; init; }
/// <summary>
/// SHA256 digest of the CycloneDX SBOM for this layer.
/// </summary>
[JsonPropertyName("cycloneDxDigest")]
public required string CycloneDxDigest { get; init; }
/// <summary>
/// CAS URI of the CycloneDX SBOM.
/// </summary>
[JsonPropertyName("cycloneDxCasUri")]
public required string CycloneDxCasUri { get; init; }
/// <summary>
/// SHA256 digest of the SPDX SBOM for this layer.
/// </summary>
[JsonPropertyName("spdxDigest")]
public required string SpdxDigest { get; init; }
/// <summary>
/// CAS URI of the SPDX SBOM.
/// </summary>
[JsonPropertyName("spdxCasUri")]
public required string SpdxCasUri { get; init; }
/// <summary>
/// Number of components in this layer.
/// </summary>
[JsonPropertyName("componentCount")]
public required int ComponentCount { get; init; }
}
/// <summary>
/// Result of generating per-layer SBOMs.
/// </summary>
public sealed record LayerSbomResult
{
/// <summary>
/// References to all per-layer SBOMs, ordered by layer order.
/// </summary>
[JsonPropertyName("layerSboms")]
public required ImmutableArray<LayerSbomRef> LayerSboms { get; init; }
/// <summary>
/// Merkle root computed from all layer SBOM digests.
/// </summary>
[JsonPropertyName("merkleRoot")]
public required string MerkleRoot { get; init; }
}
/// <summary>
/// Artifact bytes for a single layer's SBOM.
/// </summary>
public sealed record LayerSbomArtifact
{
/// <summary>
/// The layer digest this SBOM represents.
/// </summary>
public required string LayerDigest { get; init; }
/// <summary>
/// CycloneDX JSON bytes.
/// </summary>
public required byte[] CycloneDxJsonBytes { get; init; }
/// <summary>
/// SHA256 of CycloneDX JSON.
/// </summary>
public required string CycloneDxDigest { get; init; }
/// <summary>
/// SPDX JSON bytes.
/// </summary>
public required byte[] SpdxJsonBytes { get; init; }
/// <summary>
/// SHA256 of SPDX JSON.
/// </summary>
public required string SpdxDigest { get; init; }
/// <summary>
/// Number of components in this layer.
/// </summary>
public required int ComponentCount { get; init; }
}

View File

@@ -90,4 +90,19 @@ public sealed record SbomCompositionResult
/// SHA256 hex of the composition recipe JSON.
/// </summary>
public required string CompositionRecipeSha256 { get; init; }
/// <summary>
/// Per-layer SBOM references. Each layer has CycloneDX and SPDX SBOMs.
/// </summary>
public ImmutableArray<LayerSbomRef> LayerSboms { get; init; } = ImmutableArray<LayerSbomRef>.Empty;
/// <summary>
/// Per-layer SBOM artifacts (bytes). Only populated when layer SBOM generation is enabled.
/// </summary>
public ImmutableArray<LayerSbomArtifact> LayerSbomArtifacts { get; init; } = ImmutableArray<LayerSbomArtifact>.Empty;
/// <summary>
/// Merkle root computed from per-layer SBOM digests.
/// </summary>
public string? LayerSbomMerkleRoot { get; init; }
}

View File

@@ -0,0 +1,335 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Canonical.Json;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
using StellaOps.Scanner.Emit.Spdx;
using StellaOps.Scanner.Emit.Spdx.Models;
using StellaOps.Scanner.Emit.Spdx.Serialization;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Writes per-layer SBOMs in SPDX 3.0.1 format.
/// </summary>
public sealed class SpdxLayerWriter : ILayerSbomWriter
{
private const string JsonMediaType = "application/spdx+json; version=3.0.1";
private readonly SpdxLicenseList _licenseList;
private readonly string _namespaceBase;
private readonly string? _creatorOrganization;
public SpdxLayerWriter(
SpdxLicenseListVersion licenseListVersion = SpdxLicenseListVersion.V3_21,
string namespaceBase = "https://stellaops.io/spdx",
string? creatorOrganization = null)
{
_licenseList = SpdxLicenseListProvider.Get(licenseListVersion);
_namespaceBase = namespaceBase;
_creatorOrganization = creatorOrganization;
}
/// <inheritdoc />
public string Format => "spdx";
/// <inheritdoc />
public Task<LayerSbomOutput> WriteAsync(LayerSbomRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt);
var document = BuildLayerDocument(request, generatedAt);
var jsonBytes = SpdxJsonLdSerializer.Serialize(document);
var jsonDigest = CanonJson.Sha256Hex(jsonBytes);
var output = new LayerSbomOutput
{
LayerDigest = request.LayerDigest,
Format = Format,
JsonBytes = jsonBytes,
JsonDigest = jsonDigest,
MediaType = JsonMediaType,
ComponentCount = request.Components.Length,
};
return Task.FromResult(output);
}
private SpdxDocument BuildLayerDocument(LayerSbomRequest request, DateTimeOffset generatedAt)
{
var layerDigestShort = request.LayerDigest.Split(':', 2, StringSplitOptions.TrimEntries)[^1];
var idBuilder = new SpdxIdBuilder(_namespaceBase, $"layer:{request.LayerDigest}");
var creationInfo = BuildCreationInfo(request, generatedAt);
var packages = new List<SpdxPackage>();
var packageIdMap = new Dictionary<string, string>(StringComparer.Ordinal);
var layerPackage = BuildLayerPackage(request, idBuilder, layerDigestShort);
packages.Add(layerPackage);
foreach (var component in request.Components.OrderBy(static c => c.Identity.Key, StringComparer.Ordinal))
{
var package = BuildComponentPackage(component, idBuilder);
packages.Add(package);
packageIdMap[component.Identity.Key] = package.SpdxId;
}
var relationships = BuildRelationships(idBuilder, request.Components, layerPackage, packageIdMap);
var rootElementIds = packages
.Select(static pkg => pkg.SpdxId)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var sbom = new SpdxSbom
{
SpdxId = idBuilder.SbomId,
Name = "layer-sbom",
RootElements = new[] { layerPackage.SpdxId }.ToImmutableArray(),
Elements = rootElementIds,
SbomTypes = new[] { "build" }.ToImmutableArray()
};
return new SpdxDocument
{
DocumentNamespace = idBuilder.DocumentNamespace,
Name = $"SBOM for layer {request.LayerOrder} ({layerDigestShort[..12]}...)",
CreationInfo = creationInfo,
Sbom = sbom,
Elements = packages.Cast<SpdxElement>().ToImmutableArray(),
Relationships = relationships,
ProfileConformance = ImmutableArray.Create("core", "software")
};
}
private SpdxCreationInfo BuildCreationInfo(LayerSbomRequest request, DateTimeOffset generatedAt)
{
var creators = ImmutableArray.CreateBuilder<string>();
var toolName = !string.IsNullOrWhiteSpace(request.GeneratorName)
? request.GeneratorName!.Trim()
: "StellaOps-Scanner";
if (!string.IsNullOrWhiteSpace(toolName))
{
var toolLabel = !string.IsNullOrWhiteSpace(request.GeneratorVersion)
? $"{toolName}-{request.GeneratorVersion!.Trim()}"
: toolName;
creators.Add($"Tool: {toolLabel}");
}
if (!string.IsNullOrWhiteSpace(_creatorOrganization))
{
creators.Add($"Organization: {_creatorOrganization!.Trim()}");
}
return new SpdxCreationInfo
{
Created = generatedAt,
Creators = creators.ToImmutable(),
SpecVersion = SpdxDefaults.SpecVersion
};
}
private static SpdxPackage BuildLayerPackage(LayerSbomRequest request, SpdxIdBuilder idBuilder, string layerDigestShort)
{
var digestParts = request.LayerDigest.Split(':', 2, StringSplitOptions.TrimEntries);
var algorithm = digestParts.Length == 2 ? digestParts[0].ToUpperInvariant() : "SHA256";
var digestValue = digestParts.Length == 2 ? digestParts[1] : request.LayerDigest;
var checksums = ImmutableArray.Create(new SpdxChecksum
{
Algorithm = algorithm,
Value = digestValue
});
return new SpdxPackage
{
SpdxId = idBuilder.CreatePackageId($"layer:{request.LayerDigest}"),
Name = $"layer-{request.LayerOrder}",
Version = layerDigestShort,
DownloadLocation = "NOASSERTION",
PrimaryPurpose = "container",
Checksums = checksums,
Comment = $"Container layer {request.LayerOrder} from image {request.Image.ImageDigest}"
};
}
private SpdxPackage BuildComponentPackage(ComponentRecord component, SpdxIdBuilder idBuilder)
{
var packageUrl = !string.IsNullOrWhiteSpace(component.Identity.Purl)
? component.Identity.Purl
: (component.Identity.Key.StartsWith("pkg:", StringComparison.Ordinal) ? component.Identity.Key : null);
var declared = BuildLicenseExpression(component.Metadata?.Licenses);
return new SpdxPackage
{
SpdxId = idBuilder.CreatePackageId(component.Identity.Key),
Name = component.Identity.Name,
Version = component.Identity.Version,
PackageUrl = packageUrl,
DownloadLocation = "NOASSERTION",
PrimaryPurpose = MapPrimaryPurpose(component.Identity.ComponentType),
DeclaredLicense = declared
};
}
private SpdxLicenseExpression? BuildLicenseExpression(IReadOnlyList<string>? licenses)
{
if (licenses is null || licenses.Count == 0)
{
return null;
}
var expressions = new List<SpdxLicenseExpression>();
foreach (var license in licenses)
{
if (string.IsNullOrWhiteSpace(license))
{
continue;
}
if (SpdxLicenseExpressionParser.TryParse(license, out var parsed, _licenseList))
{
expressions.Add(parsed!);
continue;
}
expressions.Add(new SpdxSimpleLicense(ToLicenseRef(license)));
}
if (expressions.Count == 0)
{
return null;
}
var current = expressions[0];
for (var i = 1; i < expressions.Count; i++)
{
current = new SpdxDisjunctiveLicense(current, expressions[i]);
}
return current;
}
private static string ToLicenseRef(string license)
{
var normalized = new string(license
.Trim()
.Select(ch => char.IsLetterOrDigit(ch) || ch == '.' || ch == '-' ? ch : '-')
.ToArray());
if (normalized.StartsWith("LicenseRef-", StringComparison.Ordinal))
{
return normalized;
}
return $"LicenseRef-{normalized}";
}
private static ImmutableArray<SpdxRelationship> BuildRelationships(
SpdxIdBuilder idBuilder,
ImmutableArray<ComponentRecord> components,
SpdxPackage layerPackage,
IReadOnlyDictionary<string, string> packageIdMap)
{
var relationships = new List<SpdxRelationship>();
var documentId = idBuilder.DocumentNamespace;
relationships.Add(new SpdxRelationship
{
SpdxId = idBuilder.CreateRelationshipId(documentId, "describes", layerPackage.SpdxId),
FromElement = documentId,
Type = SpdxRelationshipType.Describes,
ToElements = ImmutableArray.Create(layerPackage.SpdxId)
});
var dependencyTargets = new HashSet<string>(StringComparer.Ordinal);
foreach (var component in components)
{
foreach (var dependencyKey in component.Dependencies)
{
if (packageIdMap.ContainsKey(dependencyKey))
{
dependencyTargets.Add(dependencyKey);
}
}
}
var rootDependencies = components
.Where(component => !dependencyTargets.Contains(component.Identity.Key))
.OrderBy(component => component.Identity.Key, StringComparer.Ordinal)
.ToArray();
foreach (var component in rootDependencies)
{
if (!packageIdMap.TryGetValue(component.Identity.Key, out var targetId))
{
continue;
}
relationships.Add(new SpdxRelationship
{
SpdxId = idBuilder.CreateRelationshipId(layerPackage.SpdxId, "dependsOn", targetId),
FromElement = layerPackage.SpdxId,
Type = SpdxRelationshipType.DependsOn,
ToElements = ImmutableArray.Create(targetId)
});
}
foreach (var component in components.OrderBy(c => c.Identity.Key, StringComparer.Ordinal))
{
if (!packageIdMap.TryGetValue(component.Identity.Key, out var fromId))
{
continue;
}
var deps = component.Dependencies
.Where(packageIdMap.ContainsKey)
.OrderBy(key => key, StringComparer.Ordinal)
.ToArray();
foreach (var depKey in deps)
{
var toId = packageIdMap[depKey];
relationships.Add(new SpdxRelationship
{
SpdxId = idBuilder.CreateRelationshipId(fromId, "dependsOn", toId),
FromElement = fromId,
Type = SpdxRelationshipType.DependsOn,
ToElements = ImmutableArray.Create(toId)
});
}
}
return relationships
.OrderBy(rel => rel.FromElement, StringComparer.Ordinal)
.ThenBy(rel => rel.Type)
.ThenBy(rel => rel.ToElements.FirstOrDefault() ?? string.Empty, StringComparer.Ordinal)
.ToImmutableArray();
}
private static string? MapPrimaryPurpose(string? type)
{
if (string.IsNullOrWhiteSpace(type))
{
return "library";
}
return type.Trim().ToLowerInvariant() switch
{
"application" => "application",
"framework" => "framework",
"container" => "container",
"operating-system" or "os" => "operatingSystem",
"device" => "device",
"firmware" => "firmware",
"file" => "file",
_ => "library"
};
}
}

View File

@@ -0,0 +1,226 @@
// -----------------------------------------------------------------------------
// CachingVexObservationProvider.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Caching wrapper for VEX observation provider with batch prefetch.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Caching wrapper for <see cref="IVexObservationProvider"/> that supports batch prefetch.
/// Implements short TTL bounded cache for gate throughput optimization.
/// </summary>
public sealed class CachingVexObservationProvider : IVexObservationBatchProvider, IDisposable
{
private readonly IVexObservationQuery _query;
private readonly string _tenantId;
private readonly MemoryCache _cache;
private readonly TimeSpan _cacheTtl;
private readonly ILogger<CachingVexObservationProvider> _logger;
private readonly SemaphoreSlim _prefetchLock = new(1, 1);
/// <summary>
/// Default cache size limit (number of entries).
/// </summary>
public const int DefaultCacheSizeLimit = 10_000;
/// <summary>
/// Default cache TTL.
/// </summary>
public static readonly TimeSpan DefaultCacheTtl = TimeSpan.FromMinutes(5);
public CachingVexObservationProvider(
IVexObservationQuery query,
string tenantId,
ILogger<CachingVexObservationProvider> logger,
TimeSpan? cacheTtl = null,
int? cacheSizeLimit = null)
{
_query = query;
_tenantId = tenantId;
_logger = logger;
_cacheTtl = cacheTtl ?? DefaultCacheTtl;
_cache = new MemoryCache(new MemoryCacheOptions
{
SizeLimit = cacheSizeLimit ?? DefaultCacheSizeLimit,
});
}
/// <inheritdoc />
public async Task<VexObservationResult?> GetVexStatusAsync(
string vulnerabilityId,
string purl,
CancellationToken cancellationToken = default)
{
var cacheKey = BuildCacheKey(vulnerabilityId, purl);
if (_cache.TryGetValue(cacheKey, out VexObservationResult? cached))
{
_logger.LogTrace("VEX cache hit: {VulnerabilityId} / {Purl}", vulnerabilityId, purl);
return cached;
}
_logger.LogTrace("VEX cache miss: {VulnerabilityId} / {Purl}", vulnerabilityId, purl);
var queryResult = await _query.GetEffectiveStatusAsync(
_tenantId,
vulnerabilityId,
purl,
cancellationToken);
if (queryResult is null)
{
return null;
}
var result = MapToObservationResult(queryResult);
CacheResult(cacheKey, result);
return result;
}
/// <inheritdoc />
public async Task<IReadOnlyList<VexStatementInfo>> GetStatementsAsync(
string vulnerabilityId,
string purl,
CancellationToken cancellationToken = default)
{
var statements = await _query.GetStatementsAsync(
_tenantId,
vulnerabilityId,
purl,
cancellationToken);
return statements
.Select(s => new VexStatementInfo
{
StatementId = s.StatementId,
IssuerId = s.IssuerId,
Status = s.Status,
Timestamp = s.Timestamp,
TrustWeight = s.TrustWeight,
})
.ToList();
}
/// <inheritdoc />
public async Task PrefetchAsync(
IReadOnlyList<VexLookupKey> keys,
CancellationToken cancellationToken = default)
{
if (keys.Count == 0)
{
return;
}
// Deduplicate and find keys not in cache
var uncachedKeys = keys
.DistinctBy(k => BuildCacheKey(k.VulnerabilityId, k.Purl))
.Where(k => !_cache.TryGetValue(BuildCacheKey(k.VulnerabilityId, k.Purl), out _))
.Select(k => new VexQueryKey(k.VulnerabilityId, k.Purl))
.ToList();
if (uncachedKeys.Count == 0)
{
_logger.LogDebug("Prefetch: all {Count} keys already cached", keys.Count);
return;
}
_logger.LogDebug(
"Prefetch: fetching {UncachedCount} of {TotalCount} keys",
uncachedKeys.Count,
keys.Count);
await _prefetchLock.WaitAsync(cancellationToken);
try
{
// Double-check after acquiring lock
uncachedKeys = uncachedKeys
.Where(k => !_cache.TryGetValue(BuildCacheKey(k.VulnerabilityId, k.ProductId), out _))
.ToList();
if (uncachedKeys.Count == 0)
{
return;
}
var batchResults = await _query.BatchLookupAsync(
_tenantId,
uncachedKeys,
cancellationToken);
foreach (var (key, result) in batchResults)
{
var cacheKey = BuildCacheKey(key.VulnerabilityId, key.ProductId);
var observationResult = MapToObservationResult(result);
CacheResult(cacheKey, observationResult);
}
_logger.LogDebug(
"Prefetch: cached {ResultCount} results",
batchResults.Count);
}
finally
{
_prefetchLock.Release();
}
}
/// <summary>
/// Gets cache statistics.
/// </summary>
public CacheStatistics GetStatistics() => new()
{
CurrentEntryCount = _cache.Count,
};
/// <inheritdoc />
public void Dispose()
{
_cache.Dispose();
_prefetchLock.Dispose();
}
private static string BuildCacheKey(string vulnerabilityId, string productId) =>
string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"vex:{0}:{1}",
vulnerabilityId.ToUpperInvariant(),
productId.ToLowerInvariant());
private static VexObservationResult MapToObservationResult(VexObservationQueryResult queryResult) =>
new()
{
Status = queryResult.Status,
Justification = queryResult.Justification,
Confidence = queryResult.Confidence,
BackportHints = queryResult.BackportHints,
};
private void CacheResult(string cacheKey, VexObservationResult result)
{
var options = new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = _cacheTtl,
AbsoluteExpirationRelativeToNow = _cacheTtl * 2,
};
_cache.Set(cacheKey, result, options);
}
}
/// <summary>
/// Cache statistics for monitoring.
/// </summary>
public sealed record CacheStatistics
{
/// <summary>
/// Current number of entries in cache.
/// </summary>
public int CurrentEntryCount { get; init; }
}

View File

@@ -0,0 +1,116 @@
// -----------------------------------------------------------------------------
// IVexGateService.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Interface for VEX gate evaluation service.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Service for evaluating findings against VEX evidence and policy rules.
/// Determines whether findings should pass, warn, or block before triage.
/// </summary>
public interface IVexGateService
{
/// <summary>
/// Evaluates a single finding against VEX evidence and policy rules.
/// </summary>
/// <param name="finding">Finding to evaluate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Gate evaluation result.</returns>
Task<VexGateResult> EvaluateAsync(
VexGateFinding finding,
CancellationToken cancellationToken = default);
/// <summary>
/// Evaluates multiple findings in batch for efficiency.
/// </summary>
/// <param name="findings">Findings to evaluate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Gate evaluation results for each finding.</returns>
Task<ImmutableArray<GatedFinding>> EvaluateBatchAsync(
IReadOnlyList<VexGateFinding> findings,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Interface for pluggable VEX gate policy evaluation.
/// </summary>
public interface IVexGatePolicy
{
/// <summary>
/// Gets the current policy configuration.
/// </summary>
VexGatePolicy Policy { get; }
/// <summary>
/// Evaluates evidence against policy rules and returns the decision.
/// </summary>
/// <param name="evidence">Evidence to evaluate.</param>
/// <returns>Tuple of (decision, matched rule ID, rationale).</returns>
(VexGateDecision Decision, string RuleId, string Rationale) Evaluate(VexGateEvidence evidence);
}
/// <summary>
/// Input finding for VEX gate evaluation.
/// </summary>
public sealed record VexGateFinding
{
/// <summary>
/// Unique identifier for the finding.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// CVE or vulnerability identifier.
/// </summary>
public required string VulnerabilityId { get; init; }
/// <summary>
/// Package URL of the affected component.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Image digest containing the component.
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// Severity level from the advisory.
/// </summary>
public string? SeverityLevel { get; init; }
/// <summary>
/// Whether reachability has been analyzed.
/// </summary>
public bool? IsReachable { get; init; }
/// <summary>
/// Whether compensating controls are in place.
/// </summary>
public bool? HasCompensatingControl { get; init; }
/// <summary>
/// Whether the vulnerability is known to be exploitable.
/// </summary>
public bool? IsExploitable { get; init; }
}
/// <summary>
/// Finding with gate evaluation result.
/// </summary>
public sealed record GatedFinding
{
/// <summary>
/// Reference to the original finding.
/// </summary>
public required VexGateFinding Finding { get; init; }
/// <summary>
/// Gate evaluation result.
/// </summary>
public required VexGateResult GateResult { get; init; }
}

View File

@@ -0,0 +1,150 @@
// -----------------------------------------------------------------------------
// IVexObservationQuery.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Query interface for VEX observations used by gate service.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Query interface for VEX observations.
/// Abstracts data access for gate service lookups.
/// </summary>
public interface IVexObservationQuery
{
/// <summary>
/// Looks up the effective VEX status for a vulnerability/product combination.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="vulnerabilityId">CVE or vulnerability ID.</param>
/// <param name="productId">PURL or product identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>VEX observation result or null if not found.</returns>
Task<VexObservationQueryResult?> GetEffectiveStatusAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all VEX statements for a vulnerability/product combination.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="vulnerabilityId">CVE or vulnerability ID.</param>
/// <param name="productId">PURL or product identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of VEX statement information.</returns>
Task<IReadOnlyList<VexStatementQueryResult>> GetStatementsAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default);
/// <summary>
/// Performs batch lookup of VEX statuses for multiple vulnerability/product pairs.
/// More efficient than individual lookups for gate evaluation.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="queries">List of vulnerability/product pairs to look up.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary mapping query keys to results.</returns>
Task<IReadOnlyDictionary<VexQueryKey, VexObservationQueryResult>> BatchLookupAsync(
string tenantId,
IReadOnlyList<VexQueryKey> queries,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Key for VEX query lookups.
/// </summary>
public sealed record VexQueryKey(string VulnerabilityId, string ProductId)
{
/// <summary>
/// Creates a normalized key for consistent lookup.
/// </summary>
public string ToNormalizedKey() =>
string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"{0}|{1}",
VulnerabilityId.ToUpperInvariant(),
ProductId.ToLowerInvariant());
}
/// <summary>
/// Result from VEX observation query.
/// </summary>
public sealed record VexObservationQueryResult
{
/// <summary>
/// Effective VEX status.
/// </summary>
public required VexStatus Status { get; init; }
/// <summary>
/// Justification if status is NotAffected.
/// </summary>
public VexJustification? Justification { get; init; }
/// <summary>
/// Confidence score for this status (0.0 to 1.0).
/// </summary>
public double Confidence { get; init; } = 1.0;
/// <summary>
/// Backport hints if status is Fixed.
/// </summary>
public ImmutableArray<string> BackportHints { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Source of the statement (vendor name or issuer).
/// </summary>
public string? Source { get; init; }
/// <summary>
/// When the effective status was last updated.
/// </summary>
public DateTimeOffset LastUpdated { get; init; }
}
/// <summary>
/// Individual VEX statement query result.
/// </summary>
public sealed record VexStatementQueryResult
{
/// <summary>
/// Statement identifier.
/// </summary>
public required string StatementId { get; init; }
/// <summary>
/// Issuer of the statement.
/// </summary>
public required string IssuerId { get; init; }
/// <summary>
/// VEX status in the statement.
/// </summary>
public required VexStatus Status { get; init; }
/// <summary>
/// Justification if status is NotAffected.
/// </summary>
public VexJustification? Justification { get; init; }
/// <summary>
/// When the statement was issued.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Trust weight for this statement.
/// </summary>
public double TrustWeight { get; init; } = 1.0;
/// <summary>
/// Source URL for the statement.
/// </summary>
public string? SourceUrl { get; init; }
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Scanner.Gate</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,305 @@
// -----------------------------------------------------------------------------
// VexGateAuditLogger.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T023
// Description: Audit logging for VEX gate decisions (compliance requirement).
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Interface for audit logging VEX gate decisions.
/// </summary>
public interface IVexGateAuditLogger
{
/// <summary>
/// Logs a gate evaluation event.
/// </summary>
void LogEvaluation(VexGateAuditEntry entry);
/// <summary>
/// Logs a batch gate evaluation summary.
/// </summary>
void LogBatchSummary(VexGateBatchAuditEntry entry);
}
/// <summary>
/// Audit entry for a single gate evaluation.
/// </summary>
public sealed record VexGateAuditEntry
{
/// <summary>
/// Unique audit entry ID.
/// </summary>
[JsonPropertyName("auditId")]
public required string AuditId { get; init; }
/// <summary>
/// Scan job ID.
/// </summary>
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
[JsonPropertyName("tenantId")]
public string? TenantId { get; init; }
/// <summary>
/// Finding ID that was evaluated.
/// </summary>
[JsonPropertyName("findingId")]
public required string FindingId { get; init; }
/// <summary>
/// Vulnerability ID (CVE).
/// </summary>
[JsonPropertyName("vulnerabilityId")]
public required string VulnerabilityId { get; init; }
/// <summary>
/// Package URL of the affected component.
/// </summary>
[JsonPropertyName("purl")]
public string? Purl { get; init; }
/// <summary>
/// Gate decision made.
/// </summary>
[JsonPropertyName("decision")]
public required VexGateDecision Decision { get; init; }
/// <summary>
/// Policy rule that matched.
/// </summary>
[JsonPropertyName("policyRuleMatched")]
public required string PolicyRuleMatched { get; init; }
/// <summary>
/// Policy version used.
/// </summary>
[JsonPropertyName("policyVersion")]
public string? PolicyVersion { get; init; }
/// <summary>
/// Rationale for the decision.
/// </summary>
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// Evidence that contributed to the decision.
/// </summary>
[JsonPropertyName("evidence")]
public VexGateEvidenceSummary? Evidence { get; init; }
/// <summary>
/// Number of VEX statements consulted.
/// </summary>
[JsonPropertyName("statementCount")]
public int StatementCount { get; init; }
/// <summary>
/// Confidence score of the decision.
/// </summary>
[JsonPropertyName("confidenceScore")]
public double ConfidenceScore { get; init; }
/// <summary>
/// When the evaluation was performed (UTC).
/// </summary>
[JsonPropertyName("evaluatedAt")]
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Source IP or identifier of the requester (for compliance).
/// </summary>
[JsonPropertyName("sourceContext")]
public string? SourceContext { get; init; }
}
/// <summary>
/// Summarized evidence for audit logging.
/// </summary>
public sealed record VexGateEvidenceSummary
{
[JsonPropertyName("vendorStatus")]
public string? VendorStatus { get; init; }
[JsonPropertyName("isReachable")]
public bool IsReachable { get; init; }
[JsonPropertyName("isExploitable")]
public bool IsExploitable { get; init; }
[JsonPropertyName("hasCompensatingControl")]
public bool HasCompensatingControl { get; init; }
[JsonPropertyName("severityLevel")]
public string? SeverityLevel { get; init; }
}
/// <summary>
/// Audit entry for a batch gate evaluation.
/// </summary>
public sealed record VexGateBatchAuditEntry
{
/// <summary>
/// Unique audit entry ID.
/// </summary>
[JsonPropertyName("auditId")]
public required string AuditId { get; init; }
/// <summary>
/// Scan job ID.
/// </summary>
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
[JsonPropertyName("tenantId")]
public string? TenantId { get; init; }
/// <summary>
/// Total findings evaluated.
/// </summary>
[JsonPropertyName("totalFindings")]
public int TotalFindings { get; init; }
/// <summary>
/// Number that passed.
/// </summary>
[JsonPropertyName("passedCount")]
public int PassedCount { get; init; }
/// <summary>
/// Number with warnings.
/// </summary>
[JsonPropertyName("warnedCount")]
public int WarnedCount { get; init; }
/// <summary>
/// Number blocked.
/// </summary>
[JsonPropertyName("blockedCount")]
public int BlockedCount { get; init; }
/// <summary>
/// Policy version used.
/// </summary>
[JsonPropertyName("policyVersion")]
public string? PolicyVersion { get; init; }
/// <summary>
/// Whether gate was bypassed.
/// </summary>
[JsonPropertyName("bypassed")]
public bool Bypassed { get; init; }
/// <summary>
/// Evaluation duration in milliseconds.
/// </summary>
[JsonPropertyName("durationMs")]
public double DurationMs { get; init; }
/// <summary>
/// When the batch evaluation was performed (UTC).
/// </summary>
[JsonPropertyName("evaluatedAt")]
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Source context for compliance.
/// </summary>
[JsonPropertyName("sourceContext")]
public string? SourceContext { get; init; }
}
/// <summary>
/// Default implementation using structured logging.
/// </summary>
public sealed class VexGateAuditLogger : IVexGateAuditLogger
{
private readonly ILogger<VexGateAuditLogger> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public VexGateAuditLogger(ILogger<VexGateAuditLogger> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public void LogEvaluation(VexGateAuditEntry entry)
{
// Log as structured event for compliance systems to consume
_logger.LogInformation(
"VEX_GATE_AUDIT: {AuditId} | Scan={ScanId} | Finding={FindingId} | CVE={VulnerabilityId} | " +
"Decision={Decision} | Rule={PolicyRuleMatched} | Confidence={ConfidenceScore:F2} | " +
"Evidence=[Reachable={IsReachable}, Exploitable={IsExploitable}]",
entry.AuditId,
entry.ScanId,
entry.FindingId,
entry.VulnerabilityId,
entry.Decision,
entry.PolicyRuleMatched,
entry.ConfidenceScore,
entry.Evidence?.IsReachable ?? false,
entry.Evidence?.IsExploitable ?? false);
// Also log full JSON for audit trail
if (_logger.IsEnabled(LogLevel.Debug))
{
var json = JsonSerializer.Serialize(entry, JsonOptions);
_logger.LogDebug("VEX_GATE_AUDIT_DETAIL: {AuditJson}", json);
}
}
/// <inheritdoc />
public void LogBatchSummary(VexGateBatchAuditEntry entry)
{
_logger.LogInformation(
"VEX_GATE_BATCH_AUDIT: {AuditId} | Scan={ScanId} | Total={TotalFindings} | " +
"Passed={PassedCount} | Warned={WarnedCount} | Blocked={BlockedCount} | " +
"Bypassed={Bypassed} | Duration={DurationMs}ms",
entry.AuditId,
entry.ScanId,
entry.TotalFindings,
entry.PassedCount,
entry.WarnedCount,
entry.BlockedCount,
entry.Bypassed,
entry.DurationMs);
// Full JSON for audit trail
if (_logger.IsEnabled(LogLevel.Debug))
{
var json = JsonSerializer.Serialize(entry, JsonOptions);
_logger.LogDebug("VEX_GATE_BATCH_AUDIT_DETAIL: {AuditJson}", json);
}
}
}
/// <summary>
/// No-op audit logger for testing or when auditing is disabled.
/// </summary>
public sealed class NullVexGateAuditLogger : IVexGateAuditLogger
{
public static readonly NullVexGateAuditLogger Instance = new();
private NullVexGateAuditLogger() { }
public void LogEvaluation(VexGateAuditEntry entry) { }
public void LogBatchSummary(VexGateBatchAuditEntry entry) { }
}

View File

@@ -0,0 +1,38 @@
// -----------------------------------------------------------------------------
// VexGateDecision.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: VEX gate decision enum for pre-triage filtering.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Decision outcome from VEX gate evaluation.
/// Determines whether a finding proceeds to triage and with what flags.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<VexGateDecision>))]
public enum VexGateDecision
{
/// <summary>
/// Finding cleared by VEX evidence - no action needed.
/// Typically when vendor status is NotAffected with sufficient trust.
/// </summary>
[JsonStringEnumMemberName("pass")]
Pass,
/// <summary>
/// Finding has partial evidence - proceed with caution.
/// Used when evidence is inconclusive or conditions partially met.
/// </summary>
[JsonStringEnumMemberName("warn")]
Warn,
/// <summary>
/// Finding requires immediate attention - exploitable and reachable.
/// Highest priority for triage queue.
/// </summary>
[JsonStringEnumMemberName("block")]
Block
}

View File

@@ -0,0 +1,263 @@
// -----------------------------------------------------------------------------
// VexGateExcititorAdapter.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Adapter bridging VexGateService with Excititor VEX statements.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Adapter that implements <see cref="IVexObservationQuery"/> by querying Excititor.
/// This is a reference implementation that can be used when Excititor is available.
/// </summary>
/// <remarks>
/// The actual Excititor integration requires a project reference to Excititor.Persistence.
/// This adapter provides the contract and can be implemented in a separate assembly
/// that has access to both Scanner.Gate and Excititor.Persistence.
/// </remarks>
public sealed class VexGateExcititorAdapter : IVexObservationQuery
{
private readonly IVexStatementDataSource _dataSource;
private readonly ILogger<VexGateExcititorAdapter> _logger;
public VexGateExcititorAdapter(
IVexStatementDataSource dataSource,
ILogger<VexGateExcititorAdapter> logger)
{
_dataSource = dataSource;
_logger = logger;
}
/// <inheritdoc />
public async Task<VexObservationQueryResult?> GetEffectiveStatusAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default)
{
_logger.LogDebug(
"Looking up effective VEX status: tenant={TenantId}, vuln={VulnerabilityId}, product={ProductId}",
tenantId, vulnerabilityId, productId);
var statement = await _dataSource.GetEffectiveStatementAsync(
tenantId,
vulnerabilityId,
productId,
cancellationToken);
if (statement is null)
{
return null;
}
return new VexObservationQueryResult
{
Status = MapStatus(statement.Status),
Justification = MapJustification(statement.Justification),
Confidence = statement.TrustWeight,
BackportHints = statement.BackportHints,
Source = statement.Source,
LastUpdated = statement.LastUpdated,
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<VexStatementQueryResult>> GetStatementsAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default)
{
var statements = await _dataSource.GetStatementsAsync(
tenantId,
vulnerabilityId,
productId,
cancellationToken);
return statements
.Select(s => new VexStatementQueryResult
{
StatementId = s.StatementId,
IssuerId = s.IssuerId,
Status = MapStatus(s.Status),
Justification = MapJustification(s.Justification),
Timestamp = s.Timestamp,
TrustWeight = s.TrustWeight,
SourceUrl = s.SourceUrl,
})
.ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<VexQueryKey, VexObservationQueryResult>> BatchLookupAsync(
string tenantId,
IReadOnlyList<VexQueryKey> queries,
CancellationToken cancellationToken = default)
{
if (queries.Count == 0)
{
return ImmutableDictionary<VexQueryKey, VexObservationQueryResult>.Empty;
}
_logger.LogDebug(
"Batch lookup of {Count} VEX queries for tenant {TenantId}",
queries.Count, tenantId);
var results = new Dictionary<VexQueryKey, VexObservationQueryResult>();
// Use batch lookup if data source supports it
if (_dataSource is IVexStatementBatchDataSource batchSource)
{
var batchKeys = queries
.Select(q => new VexBatchKey(q.VulnerabilityId, q.ProductId))
.ToList();
var batchResults = await batchSource.BatchLookupAsync(
tenantId,
batchKeys,
cancellationToken);
foreach (var (key, statement) in batchResults)
{
var queryKey = new VexQueryKey(key.VulnerabilityId, key.ProductId);
results[queryKey] = new VexObservationQueryResult
{
Status = MapStatus(statement.Status),
Justification = MapJustification(statement.Justification),
Confidence = statement.TrustWeight,
BackportHints = statement.BackportHints,
Source = statement.Source,
LastUpdated = statement.LastUpdated,
};
}
}
else
{
// Fallback to individual lookups
foreach (var query in queries)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await GetEffectiveStatusAsync(
tenantId,
query.VulnerabilityId,
query.ProductId,
cancellationToken);
if (result is not null)
{
results[query] = result;
}
}
}
return results;
}
private static VexStatus MapStatus(VexStatementStatus status) => status switch
{
VexStatementStatus.NotAffected => VexStatus.NotAffected,
VexStatementStatus.Affected => VexStatus.Affected,
VexStatementStatus.Fixed => VexStatus.Fixed,
VexStatementStatus.UnderInvestigation => VexStatus.UnderInvestigation,
_ => VexStatus.UnderInvestigation,
};
private static VexJustification? MapJustification(VexStatementJustification? justification) =>
justification switch
{
VexStatementJustification.ComponentNotPresent => VexJustification.ComponentNotPresent,
VexStatementJustification.VulnerableCodeNotPresent => VexJustification.VulnerableCodeNotPresent,
VexStatementJustification.VulnerableCodeNotInExecutePath => VexJustification.VulnerableCodeNotInExecutePath,
VexStatementJustification.VulnerableCodeCannotBeControlledByAdversary => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
VexStatementJustification.InlineMitigationsAlreadyExist => VexJustification.InlineMitigationsAlreadyExist,
_ => null,
};
}
/// <summary>
/// Data source abstraction for VEX statements.
/// Implemented by Excititor persistence layer.
/// </summary>
public interface IVexStatementDataSource
{
/// <summary>
/// Gets the effective VEX statement for a vulnerability/product combination.
/// </summary>
Task<VexStatementData?> GetEffectiveStatementAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all VEX statements for a vulnerability/product combination.
/// </summary>
Task<IReadOnlyList<VexStatementData>> GetStatementsAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Extended interface for batch data source operations.
/// </summary>
public interface IVexStatementBatchDataSource : IVexStatementDataSource
{
/// <summary>
/// Performs batch lookup of VEX statements.
/// </summary>
Task<IReadOnlyDictionary<VexBatchKey, VexStatementData>> BatchLookupAsync(
string tenantId,
IReadOnlyList<VexBatchKey> keys,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Key for batch VEX lookups.
/// </summary>
public sealed record VexBatchKey(string VulnerabilityId, string ProductId);
/// <summary>
/// VEX statement data transfer object.
/// </summary>
public sealed record VexStatementData
{
public required string StatementId { get; init; }
public required string IssuerId { get; init; }
public required VexStatementStatus Status { get; init; }
public VexStatementJustification? Justification { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public DateTimeOffset LastUpdated { get; init; }
public double TrustWeight { get; init; } = 1.0;
public string? Source { get; init; }
public string? SourceUrl { get; init; }
public ImmutableArray<string> BackportHints { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// VEX statement status (mirrors Excititor's VexStatus).
/// </summary>
public enum VexStatementStatus
{
NotAffected,
Affected,
Fixed,
UnderInvestigation
}
/// <summary>
/// VEX statement justification (mirrors Excititor's VexJustification).
/// </summary>
public enum VexStatementJustification
{
ComponentNotPresent,
VulnerableCodeNotPresent,
VulnerableCodeNotInExecutePath,
VulnerableCodeCannotBeControlledByAdversary,
InlineMitigationsAlreadyExist
}

View File

@@ -0,0 +1,379 @@
// -----------------------------------------------------------------------------
// VexGateOptions.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T028 - Add gate policy to tenant configuration
// Description: Configuration options for VEX gate, bindable from YAML/JSON config.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Configuration options for VEX gate service.
/// Binds to "VexGate" section in configuration files.
/// </summary>
public sealed class VexGateOptions : IValidatableObject
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "VexGate";
/// <summary>
/// Enable VEX-first gating. Default: false.
/// When disabled, all findings pass through to triage unchanged.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Default decision when no rules match. Default: Warn.
/// </summary>
public string DefaultDecision { get; set; } = "Warn";
/// <summary>
/// Policy version for audit/replay purposes.
/// Should be incremented when rules change.
/// </summary>
public string PolicyVersion { get; set; } = "1.0.0";
/// <summary>
/// Evaluation rules (ordered by priority, highest first).
/// </summary>
public List<VexGateRuleOptions> Rules { get; set; } = [];
/// <summary>
/// Caching settings for VEX observation lookups.
/// </summary>
public VexGateCacheOptions Cache { get; set; } = new();
/// <summary>
/// Audit logging settings.
/// </summary>
public VexGateAuditOptions Audit { get; set; } = new();
/// <summary>
/// Metrics settings.
/// </summary>
public VexGateMetricsOptions Metrics { get; set; } = new();
/// <summary>
/// Bypass settings for emergency scans.
/// </summary>
public VexGateBypassOptions Bypass { get; set; } = new();
/// <summary>
/// Converts this options instance to a VexGatePolicy.
/// </summary>
public VexGatePolicy ToPolicy()
{
var defaultDecision = ParseDecision(DefaultDecision);
var rules = Rules
.Select(r => r.ToRule())
.OrderByDescending(r => r.Priority)
.ToImmutableArray();
return new VexGatePolicy
{
DefaultDecision = defaultDecision,
Rules = rules,
};
}
/// <summary>
/// Creates options from a VexGatePolicy.
/// </summary>
public static VexGateOptions FromPolicy(VexGatePolicy policy)
{
return new VexGateOptions
{
Enabled = true,
DefaultDecision = policy.DefaultDecision.ToString(),
Rules = policy.Rules.Select(r => VexGateRuleOptions.FromRule(r)).ToList(),
};
}
private static VexGateDecision ParseDecision(string value)
{
return value.ToUpperInvariant() switch
{
"PASS" => VexGateDecision.Pass,
"WARN" => VexGateDecision.Warn,
"BLOCK" => VexGateDecision.Block,
_ => VexGateDecision.Warn,
};
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Enabled && Rules.Count == 0)
{
yield return new ValidationResult(
"At least one rule is required when VexGate is enabled",
[nameof(Rules)]);
}
var ruleIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var rule in Rules)
{
if (string.IsNullOrWhiteSpace(rule.RuleId))
{
yield return new ValidationResult(
"Rule ID is required for all rules",
[nameof(Rules)]);
}
else if (!ruleIds.Add(rule.RuleId))
{
yield return new ValidationResult(
$"Duplicate rule ID: {rule.RuleId}",
[nameof(Rules)]);
}
}
if (Cache.TtlSeconds <= 0)
{
yield return new ValidationResult(
"Cache TTL must be positive",
[nameof(Cache)]);
}
if (Cache.MaxEntries <= 0)
{
yield return new ValidationResult(
"Cache max entries must be positive",
[nameof(Cache)]);
}
}
}
/// <summary>
/// Configuration options for a single VEX gate rule.
/// </summary>
public sealed class VexGateRuleOptions
{
/// <summary>
/// Unique identifier for this rule.
/// </summary>
[Required]
public string RuleId { get; set; } = string.Empty;
/// <summary>
/// Priority order (higher values evaluated first).
/// </summary>
public int Priority { get; set; } = 0;
/// <summary>
/// Decision to apply when this rule matches.
/// </summary>
[Required]
public string Decision { get; set; } = "Warn";
/// <summary>
/// Condition that must match for this rule to apply.
/// </summary>
public VexGateConditionOptions Condition { get; set; } = new();
/// <summary>
/// Converts to a VexGatePolicyRule.
/// </summary>
public VexGatePolicyRule ToRule()
{
return new VexGatePolicyRule
{
RuleId = RuleId,
Priority = Priority,
Decision = ParseDecision(Decision),
Condition = Condition.ToCondition(),
};
}
/// <summary>
/// Creates options from a VexGatePolicyRule.
/// </summary>
public static VexGateRuleOptions FromRule(VexGatePolicyRule rule)
{
return new VexGateRuleOptions
{
RuleId = rule.RuleId,
Priority = rule.Priority,
Decision = rule.Decision.ToString(),
Condition = VexGateConditionOptions.FromCondition(rule.Condition),
};
}
private static VexGateDecision ParseDecision(string value)
{
return value.ToUpperInvariant() switch
{
"PASS" => VexGateDecision.Pass,
"WARN" => VexGateDecision.Warn,
"BLOCK" => VexGateDecision.Block,
_ => VexGateDecision.Warn,
};
}
}
/// <summary>
/// Configuration options for a rule condition.
/// </summary>
public sealed class VexGateConditionOptions
{
/// <summary>
/// Required VEX vendor status.
/// Options: not_affected, fixed, affected, under_investigation.
/// </summary>
public string? VendorStatus { get; set; }
/// <summary>
/// Whether the vulnerability must be exploitable.
/// </summary>
public bool? IsExploitable { get; set; }
/// <summary>
/// Whether the vulnerable code must be reachable.
/// </summary>
public bool? IsReachable { get; set; }
/// <summary>
/// Whether compensating controls must be present.
/// </summary>
public bool? HasCompensatingControl { get; set; }
/// <summary>
/// Whether the CVE is in KEV (Known Exploited Vulnerabilities).
/// </summary>
public bool? IsKnownExploited { get; set; }
/// <summary>
/// Required severity levels (any match).
/// </summary>
public List<string>? SeverityLevels { get; set; }
/// <summary>
/// Minimum confidence score required.
/// </summary>
public double? ConfidenceThreshold { get; set; }
/// <summary>
/// Converts to a VexGatePolicyCondition.
/// </summary>
public VexGatePolicyCondition ToCondition()
{
return new VexGatePolicyCondition
{
VendorStatus = ParseVexStatus(VendorStatus),
IsExploitable = IsExploitable,
IsReachable = IsReachable,
HasCompensatingControl = HasCompensatingControl,
SeverityLevels = SeverityLevels?.ToArray(),
MinConfidence = ConfidenceThreshold,
};
}
/// <summary>
/// Creates options from a VexGatePolicyCondition.
/// </summary>
public static VexGateConditionOptions FromCondition(VexGatePolicyCondition condition)
{
return new VexGateConditionOptions
{
VendorStatus = condition.VendorStatus?.ToString().ToLowerInvariant(),
IsExploitable = condition.IsExploitable,
IsReachable = condition.IsReachable,
HasCompensatingControl = condition.HasCompensatingControl,
SeverityLevels = condition.SeverityLevels?.ToList(),
ConfidenceThreshold = condition.MinConfidence,
};
}
private static VexStatus? ParseVexStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
return value.ToLowerInvariant() switch
{
"not_affected" or "notaffected" => VexStatus.NotAffected,
"fixed" => VexStatus.Fixed,
"affected" => VexStatus.Affected,
"under_investigation" or "underinvestigation" => VexStatus.UnderInvestigation,
_ => null,
};
}
}
/// <summary>
/// Cache configuration options.
/// </summary>
public sealed class VexGateCacheOptions
{
/// <summary>
/// TTL for cached VEX observations (seconds). Default: 300.
/// </summary>
public int TtlSeconds { get; set; } = 300;
/// <summary>
/// Maximum cache entries. Default: 10000.
/// </summary>
public int MaxEntries { get; set; } = 10000;
}
/// <summary>
/// Audit logging configuration options.
/// </summary>
public sealed class VexGateAuditOptions
{
/// <summary>
/// Enable structured audit logging for compliance. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Include full evidence in audit logs. Default: true.
/// </summary>
public bool IncludeEvidence { get; set; } = true;
/// <summary>
/// Log level for gate decisions. Default: Information.
/// </summary>
public string LogLevel { get; set; } = "Information";
}
/// <summary>
/// Metrics configuration options.
/// </summary>
public sealed class VexGateMetricsOptions
{
/// <summary>
/// Enable OpenTelemetry metrics. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Histogram buckets for evaluation latency (milliseconds).
/// </summary>
public List<double> LatencyBuckets { get; set; } = [1, 5, 10, 25, 50, 100, 250];
}
/// <summary>
/// Bypass configuration options.
/// </summary>
public sealed class VexGateBypassOptions
{
/// <summary>
/// Allow gate bypass via CLI flag (--bypass-gate). Default: true.
/// </summary>
public bool AllowCliBypass { get; set; } = true;
/// <summary>
/// Require specific reason when bypassing. Default: false.
/// </summary>
public bool RequireReason { get; set; } = false;
/// <summary>
/// Emit warning when bypass is used. Default: true.
/// </summary>
public bool WarnOnBypass { get; set; } = true;
}

View File

@@ -0,0 +1,201 @@
// -----------------------------------------------------------------------------
// VexGatePolicy.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: VEX gate policy configuration models.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// VEX gate policy defining rules for gate decisions.
/// Rules are evaluated in priority order (highest first).
/// </summary>
public sealed record VexGatePolicy
{
/// <summary>
/// Ordered list of policy rules.
/// </summary>
[JsonPropertyName("rules")]
public required ImmutableArray<VexGatePolicyRule> Rules { get; init; }
/// <summary>
/// Default decision when no rules match.
/// </summary>
[JsonPropertyName("defaultDecision")]
public required VexGateDecision DefaultDecision { get; init; }
/// <summary>
/// Creates the default gate policy per product advisory.
/// </summary>
public static VexGatePolicy Default => new()
{
DefaultDecision = VexGateDecision.Warn,
Rules = ImmutableArray.Create(
new VexGatePolicyRule
{
RuleId = "block-exploitable-reachable",
Priority = 100,
Condition = new VexGatePolicyCondition
{
IsExploitable = true,
IsReachable = true,
HasCompensatingControl = false,
},
Decision = VexGateDecision.Block,
},
new VexGatePolicyRule
{
RuleId = "warn-high-not-reachable",
Priority = 90,
Condition = new VexGatePolicyCondition
{
SeverityLevels = ["critical", "high"],
IsReachable = false,
},
Decision = VexGateDecision.Warn,
},
new VexGatePolicyRule
{
RuleId = "pass-vendor-not-affected",
Priority = 80,
Condition = new VexGatePolicyCondition
{
VendorStatus = VexStatus.NotAffected,
},
Decision = VexGateDecision.Pass,
},
new VexGatePolicyRule
{
RuleId = "pass-backport-confirmed",
Priority = 70,
Condition = new VexGatePolicyCondition
{
VendorStatus = VexStatus.Fixed,
},
Decision = VexGateDecision.Pass,
}
),
};
}
/// <summary>
/// A single policy rule for VEX gate evaluation.
/// </summary>
public sealed record VexGatePolicyRule
{
/// <summary>
/// Unique identifier for this rule.
/// </summary>
[JsonPropertyName("ruleId")]
public required string RuleId { get; init; }
/// <summary>
/// Condition that must match for this rule to apply.
/// </summary>
[JsonPropertyName("condition")]
public required VexGatePolicyCondition Condition { get; init; }
/// <summary>
/// Decision to apply when this rule matches.
/// </summary>
[JsonPropertyName("decision")]
public required VexGateDecision Decision { get; init; }
/// <summary>
/// Priority order (higher values evaluated first).
/// </summary>
[JsonPropertyName("priority")]
public required int Priority { get; init; }
}
/// <summary>
/// Condition for a policy rule to match.
/// All non-null properties must match for the condition to be satisfied.
/// </summary>
public sealed record VexGatePolicyCondition
{
/// <summary>
/// Required VEX vendor status.
/// </summary>
[JsonPropertyName("vendorStatus")]
public VexStatus? VendorStatus { get; init; }
/// <summary>
/// Whether the vulnerability must be exploitable.
/// </summary>
[JsonPropertyName("isExploitable")]
public bool? IsExploitable { get; init; }
/// <summary>
/// Whether the vulnerable code must be reachable.
/// </summary>
[JsonPropertyName("isReachable")]
public bool? IsReachable { get; init; }
/// <summary>
/// Whether compensating controls must be present.
/// </summary>
[JsonPropertyName("hasCompensatingControl")]
public bool? HasCompensatingControl { get; init; }
/// <summary>
/// Required severity levels (any match).
/// </summary>
[JsonPropertyName("severityLevels")]
public string[]? SeverityLevels { get; init; }
/// <summary>
/// Minimum confidence score required.
/// </summary>
[JsonPropertyName("minConfidence")]
public double? MinConfidence { get; init; }
/// <summary>
/// Required VEX justification type.
/// </summary>
[JsonPropertyName("justification")]
public VexJustification? Justification { get; init; }
/// <summary>
/// Evaluates whether the evidence matches this condition.
/// </summary>
/// <param name="evidence">Evidence to evaluate.</param>
/// <returns>True if all specified conditions match.</returns>
public bool Matches(VexGateEvidence evidence)
{
if (VendorStatus is not null && evidence.VendorStatus != VendorStatus)
return false;
if (IsExploitable is not null && evidence.IsExploitable != IsExploitable)
return false;
if (IsReachable is not null && evidence.IsReachable != IsReachable)
return false;
if (HasCompensatingControl is not null && evidence.HasCompensatingControl != HasCompensatingControl)
return false;
if (SeverityLevels is not null && SeverityLevels.Length > 0)
{
if (evidence.SeverityLevel is null)
return false;
var matchesSeverity = SeverityLevels.Any(s =>
string.Equals(s, evidence.SeverityLevel, StringComparison.OrdinalIgnoreCase));
if (!matchesSeverity)
return false;
}
if (MinConfidence is not null && evidence.ConfidenceScore < MinConfidence)
return false;
if (Justification is not null && evidence.Justification != Justification)
return false;
return true;
}
}

View File

@@ -0,0 +1,116 @@
// -----------------------------------------------------------------------------
// VexGatePolicyEvaluator.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Policy evaluator for VEX gate decisions.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Default implementation of <see cref="IVexGatePolicy"/>.
/// Evaluates evidence against policy rules in priority order.
/// </summary>
public sealed class VexGatePolicyEvaluator : IVexGatePolicy
{
private readonly ILogger<VexGatePolicyEvaluator> _logger;
private readonly VexGatePolicy _policy;
public VexGatePolicyEvaluator(
IOptions<VexGatePolicyOptions> options,
ILogger<VexGatePolicyEvaluator> logger)
{
_logger = logger;
_policy = options.Value.Policy ?? VexGatePolicy.Default;
}
/// <summary>
/// Creates an evaluator with the default policy.
/// </summary>
public VexGatePolicyEvaluator(ILogger<VexGatePolicyEvaluator> logger)
{
_logger = logger;
_policy = VexGatePolicy.Default;
}
/// <inheritdoc />
public VexGatePolicy Policy => _policy;
/// <inheritdoc />
public (VexGateDecision Decision, string RuleId, string Rationale) Evaluate(VexGateEvidence evidence)
{
// Sort rules by priority descending and evaluate in order
var sortedRules = _policy.Rules
.OrderByDescending(r => r.Priority)
.ToList();
foreach (var rule in sortedRules)
{
if (rule.Condition.Matches(evidence))
{
var rationale = BuildRationale(rule, evidence);
_logger.LogDebug(
"VEX gate rule matched: {RuleId} -> {Decision} for evidence with vendor status {VendorStatus}",
rule.RuleId,
rule.Decision,
evidence.VendorStatus);
return (rule.Decision, rule.RuleId, rationale);
}
}
// No rule matched, return default
var defaultRationale = "No policy rule matched; applying default decision";
_logger.LogDebug(
"No VEX gate rule matched; defaulting to {Decision}",
_policy.DefaultDecision);
return (_policy.DefaultDecision, "default", defaultRationale);
}
private static string BuildRationale(VexGatePolicyRule rule, VexGateEvidence evidence)
{
return rule.RuleId switch
{
"block-exploitable-reachable" =>
"Exploitable + reachable, no compensating control",
"warn-high-not-reachable" =>
string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"{0} severity but not reachable from entrypoints",
evidence.SeverityLevel ?? "High"),
"pass-vendor-not-affected" =>
"Vendor VEX statement declares not_affected",
"pass-backport-confirmed" =>
"Vendor VEX statement confirms fixed via backport",
_ => string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"Policy rule '{0}' matched",
rule.RuleId)
};
}
}
/// <summary>
/// Options for VEX gate policy configuration.
/// </summary>
public sealed class VexGatePolicyOptions
{
/// <summary>
/// Custom policy to use instead of default.
/// </summary>
public VexGatePolicy? Policy { get; set; }
/// <summary>
/// Whether the gate is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
}

View File

@@ -0,0 +1,144 @@
// -----------------------------------------------------------------------------
// VexGateResult.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: VEX gate evaluation result with evidence.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Result of VEX gate evaluation for a single finding.
/// Contains the decision, rationale, and supporting evidence.
/// </summary>
public sealed record VexGateResult
{
/// <summary>
/// Gate decision: Pass, Warn, or Block.
/// </summary>
[JsonPropertyName("decision")]
public required VexGateDecision Decision { get; init; }
/// <summary>
/// Human-readable explanation of why this decision was made.
/// </summary>
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// ID of the policy rule that matched and produced this decision.
/// </summary>
[JsonPropertyName("policyRuleMatched")]
public required string PolicyRuleMatched { get; init; }
/// <summary>
/// VEX statements that contributed to this decision.
/// </summary>
[JsonPropertyName("contributingStatements")]
public required ImmutableArray<VexStatementRef> ContributingStatements { get; init; }
/// <summary>
/// Detailed evidence supporting the decision.
/// </summary>
[JsonPropertyName("evidence")]
public required VexGateEvidence Evidence { get; init; }
/// <summary>
/// When this evaluation was performed (UTC ISO-8601).
/// </summary>
[JsonPropertyName("evaluatedAt")]
public required DateTimeOffset EvaluatedAt { get; init; }
}
/// <summary>
/// Evidence collected during VEX gate evaluation.
/// </summary>
public sealed record VexGateEvidence
{
/// <summary>
/// VEX status from vendor or authoritative source.
/// Null if no VEX statement found.
/// </summary>
[JsonPropertyName("vendorStatus")]
public VexStatus? VendorStatus { get; init; }
/// <summary>
/// Justification type from VEX statement.
/// </summary>
[JsonPropertyName("justification")]
public VexJustification? Justification { get; init; }
/// <summary>
/// Whether the vulnerable code is reachable from entrypoints.
/// </summary>
[JsonPropertyName("isReachable")]
public bool IsReachable { get; init; }
/// <summary>
/// Whether compensating controls mitigate the vulnerability.
/// </summary>
[JsonPropertyName("hasCompensatingControl")]
public bool HasCompensatingControl { get; init; }
/// <summary>
/// Confidence score in the gate decision (0.0 to 1.0).
/// </summary>
[JsonPropertyName("confidenceScore")]
public double ConfidenceScore { get; init; }
/// <summary>
/// Hints about backport fixes detected.
/// </summary>
[JsonPropertyName("backportHints")]
public ImmutableArray<string> BackportHints { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Whether the vulnerability is exploitable based on available intelligence.
/// </summary>
[JsonPropertyName("isExploitable")]
public bool IsExploitable { get; init; }
/// <summary>
/// Severity level from the advisory.
/// </summary>
[JsonPropertyName("severityLevel")]
public string? SeverityLevel { get; init; }
}
/// <summary>
/// Reference to a VEX statement that contributed to a gate decision.
/// </summary>
public sealed record VexStatementRef
{
/// <summary>
/// Unique identifier for the VEX statement.
/// </summary>
[JsonPropertyName("statementId")]
public required string StatementId { get; init; }
/// <summary>
/// Issuer of the VEX statement.
/// </summary>
[JsonPropertyName("issuerId")]
public required string IssuerId { get; init; }
/// <summary>
/// VEX status declared in the statement.
/// </summary>
[JsonPropertyName("status")]
public required VexStatus Status { get; init; }
/// <summary>
/// When the statement was issued.
/// </summary>
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Trust weight of this statement in consensus (0.0 to 1.0).
/// </summary>
[JsonPropertyName("trustWeight")]
public double TrustWeight { get; init; }
}

View File

@@ -0,0 +1,249 @@
// -----------------------------------------------------------------------------
// VexGateService.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: VEX gate service implementation.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Default implementation of <see cref="IVexGateService"/>.
/// Evaluates findings against VEX evidence and policy rules.
/// </summary>
public sealed class VexGateService : IVexGateService
{
private readonly IVexGatePolicy _policyEvaluator;
private readonly IVexObservationProvider? _vexProvider;
private readonly TimeProvider _timeProvider;
private readonly ILogger<VexGateService> _logger;
public VexGateService(
IVexGatePolicy policyEvaluator,
TimeProvider timeProvider,
ILogger<VexGateService> logger,
IVexObservationProvider? vexProvider = null)
{
_policyEvaluator = policyEvaluator;
_vexProvider = vexProvider;
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<VexGateResult> EvaluateAsync(
VexGateFinding finding,
CancellationToken cancellationToken = default)
{
_logger.LogDebug(
"Evaluating VEX gate for finding {FindingId} ({VulnerabilityId})",
finding.FindingId,
finding.VulnerabilityId);
// Collect evidence from VEX provider and finding context
var evidence = await BuildEvidenceAsync(finding, cancellationToken);
// Evaluate against policy rules
var (decision, ruleId, rationale) = _policyEvaluator.Evaluate(evidence);
// Build statement references if we have VEX data
var contributingStatements = evidence.VendorStatus is not null
? await GetContributingStatementsAsync(
finding.VulnerabilityId,
finding.Purl,
cancellationToken)
: ImmutableArray<VexStatementRef>.Empty;
return new VexGateResult
{
Decision = decision,
Rationale = rationale,
PolicyRuleMatched = ruleId,
ContributingStatements = contributingStatements,
Evidence = evidence,
EvaluatedAt = _timeProvider.GetUtcNow(),
};
}
/// <inheritdoc />
public async Task<ImmutableArray<GatedFinding>> EvaluateBatchAsync(
IReadOnlyList<VexGateFinding> findings,
CancellationToken cancellationToken = default)
{
if (findings.Count == 0)
{
return ImmutableArray<GatedFinding>.Empty;
}
_logger.LogDebug("Evaluating VEX gate for {Count} findings in batch", findings.Count);
// Pre-fetch VEX data for all findings if provider supports batch
if (_vexProvider is IVexObservationBatchProvider batchProvider)
{
var queries = findings
.Select(f => new VexLookupKey(f.VulnerabilityId, f.Purl))
.Distinct()
.ToList();
await batchProvider.PrefetchAsync(queries, cancellationToken);
}
// Evaluate each finding
var results = new List<GatedFinding>(findings.Count);
foreach (var finding in findings)
{
cancellationToken.ThrowIfCancellationRequested();
var gateResult = await EvaluateAsync(finding, cancellationToken);
results.Add(new GatedFinding
{
Finding = finding,
GateResult = gateResult,
});
}
_logger.LogInformation(
"VEX gate batch complete: {Pass} passed, {Warn} warned, {Block} blocked",
results.Count(r => r.GateResult.Decision == VexGateDecision.Pass),
results.Count(r => r.GateResult.Decision == VexGateDecision.Warn),
results.Count(r => r.GateResult.Decision == VexGateDecision.Block));
return results.ToImmutableArray();
}
private async Task<VexGateEvidence> BuildEvidenceAsync(
VexGateFinding finding,
CancellationToken cancellationToken)
{
VexStatus? vendorStatus = null;
VexJustification? justification = null;
var backportHints = ImmutableArray<string>.Empty;
var confidenceScore = 0.5; // Default confidence
// Query VEX provider if available
if (_vexProvider is not null)
{
var vexResult = await _vexProvider.GetVexStatusAsync(
finding.VulnerabilityId,
finding.Purl,
cancellationToken);
if (vexResult is not null)
{
vendorStatus = vexResult.Status;
justification = vexResult.Justification;
confidenceScore = vexResult.Confidence;
backportHints = vexResult.BackportHints;
}
}
// Use exploitability from finding or infer from VEX status
var isExploitable = finding.IsExploitable ?? (vendorStatus == VexStatus.Affected);
return new VexGateEvidence
{
VendorStatus = vendorStatus,
Justification = justification,
IsReachable = finding.IsReachable ?? true, // Conservative: assume reachable if unknown
HasCompensatingControl = finding.HasCompensatingControl ?? false,
ConfidenceScore = confidenceScore,
BackportHints = backportHints,
IsExploitable = isExploitable,
SeverityLevel = finding.SeverityLevel,
};
}
private async Task<ImmutableArray<VexStatementRef>> GetContributingStatementsAsync(
string vulnerabilityId,
string purl,
CancellationToken cancellationToken)
{
if (_vexProvider is null)
{
return ImmutableArray<VexStatementRef>.Empty;
}
var statements = await _vexProvider.GetStatementsAsync(
vulnerabilityId,
purl,
cancellationToken);
return statements
.Select(s => new VexStatementRef
{
StatementId = s.StatementId,
IssuerId = s.IssuerId,
Status = s.Status,
Timestamp = s.Timestamp,
TrustWeight = s.TrustWeight,
})
.ToImmutableArray();
}
}
/// <summary>
/// Key for VEX lookups.
/// </summary>
public sealed record VexLookupKey(string VulnerabilityId, string Purl);
/// <summary>
/// Result from VEX observation provider.
/// </summary>
public sealed record VexObservationResult
{
public required VexStatus Status { get; init; }
public VexJustification? Justification { get; init; }
public double Confidence { get; init; } = 1.0;
public ImmutableArray<string> BackportHints { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// VEX statement info for contributing statements.
/// </summary>
public sealed record VexStatementInfo
{
public required string StatementId { get; init; }
public required string IssuerId { get; init; }
public required VexStatus Status { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public double TrustWeight { get; init; } = 1.0;
}
/// <summary>
/// Interface for VEX observation data provider.
/// Abstracts access to VEX statements from Excititor or other sources.
/// </summary>
public interface IVexObservationProvider
{
/// <summary>
/// Gets the VEX status for a vulnerability and component.
/// </summary>
Task<VexObservationResult?> GetVexStatusAsync(
string vulnerabilityId,
string purl,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all VEX statements for a vulnerability and component.
/// </summary>
Task<IReadOnlyList<VexStatementInfo>> GetStatementsAsync(
string vulnerabilityId,
string purl,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Extended interface for batch VEX observation prefetching.
/// </summary>
public interface IVexObservationBatchProvider : IVexObservationProvider
{
/// <summary>
/// Prefetches VEX data for multiple lookups.
/// </summary>
Task PrefetchAsync(
IReadOnlyList<VexLookupKey> keys,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,169 @@
// -----------------------------------------------------------------------------
// VexGateServiceCollectionExtensions.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T028 - Add gate policy to tenant configuration
// Description: Service collection extensions for registering VEX gate services.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Extension methods for registering VEX gate services.
/// </summary>
public static class VexGateServiceCollectionExtensions
{
/// <summary>
/// Adds VEX gate services with configuration from the specified section.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration root.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddVexGate(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Bind and validate options
services.AddOptions<VexGateOptions>()
.Bind(configuration.GetSection(VexGateOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
// Register policy from options
services.AddSingleton<VexGatePolicy>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexGateOptions>>();
if (!options.Value.Enabled)
{
// Return a permissive policy when disabled
return new VexGatePolicy
{
DefaultDecision = VexGateDecision.Pass,
Rules = [],
};
}
return options.Value.ToPolicy();
});
// Register core services
services.AddSingleton<IVexGatePolicy, VexGatePolicyEvaluator>();
// Register caching with configured limits
services.AddSingleton<IMemoryCache>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexGateOptions>>();
return new MemoryCache(new MemoryCacheOptions
{
SizeLimit = options.Value.Cache.MaxEntries,
});
});
// Register VEX gate service
services.AddSingleton<IVexGateService, VexGateService>();
return services;
}
/// <summary>
/// Adds VEX gate services with explicit options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">The options configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddVexGate(
this IServiceCollection services,
Action<VexGateOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
// Configure and validate options
services.AddOptions<VexGateOptions>()
.Configure(configureOptions)
.ValidateDataAnnotations()
.ValidateOnStart();
// Register policy from options
services.AddSingleton<VexGatePolicy>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexGateOptions>>();
if (!options.Value.Enabled)
{
return new VexGatePolicy
{
DefaultDecision = VexGateDecision.Pass,
Rules = [],
};
}
return options.Value.ToPolicy();
});
// Register core services
services.AddSingleton<IVexGatePolicy, VexGatePolicyEvaluator>();
// Register caching with configured limits
services.AddSingleton<IMemoryCache>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexGateOptions>>();
return new MemoryCache(new MemoryCacheOptions
{
SizeLimit = options.Value.Cache.MaxEntries,
});
});
// Register VEX gate service
services.AddSingleton<IVexGateService, VexGateService>();
return services;
}
/// <summary>
/// Adds VEX gate services with default policy.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddVexGateWithDefaultPolicy(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// Configure with default options
services.AddOptions<VexGateOptions>()
.Configure(options =>
{
options.Enabled = true;
var defaultPolicy = VexGatePolicy.Default;
options.DefaultDecision = defaultPolicy.DefaultDecision.ToString();
options.Rules = defaultPolicy.Rules
.Select(VexGateRuleOptions.FromRule)
.ToList();
})
.ValidateDataAnnotations()
.ValidateOnStart();
// Register default policy
services.AddSingleton<VexGatePolicy>(_ => VexGatePolicy.Default);
// Register core services
services.AddSingleton<IVexGatePolicy, VexGatePolicyEvaluator>();
// Register caching with default limits
services.AddSingleton<IMemoryCache>(_ => new MemoryCache(new MemoryCacheOptions
{
SizeLimit = 10000,
}));
// Register VEX gate service
services.AddSingleton<IVexGateService, VexGateService>();
return services;
}
}

View File

@@ -0,0 +1,78 @@
// -----------------------------------------------------------------------------
// VexTypes.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Local VEX type definitions for gate service independence.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// VEX status values per OpenVEX specification.
/// Local definition to avoid dependency on SmartDiff/Excititor.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<VexStatus>))]
public enum VexStatus
{
/// <summary>
/// The vulnerability is not exploitable in this context.
/// </summary>
[JsonStringEnumMemberName("not_affected")]
NotAffected,
/// <summary>
/// The vulnerability is exploitable.
/// </summary>
[JsonStringEnumMemberName("affected")]
Affected,
/// <summary>
/// The vulnerability has been fixed.
/// </summary>
[JsonStringEnumMemberName("fixed")]
Fixed,
/// <summary>
/// The vulnerability is under investigation.
/// </summary>
[JsonStringEnumMemberName("under_investigation")]
UnderInvestigation
}
/// <summary>
/// VEX justification codes per OpenVEX specification.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<VexJustification>))]
public enum VexJustification
{
/// <summary>
/// The vulnerable component is not present.
/// </summary>
[JsonStringEnumMemberName("component_not_present")]
ComponentNotPresent,
/// <summary>
/// The vulnerable code is not present.
/// </summary>
[JsonStringEnumMemberName("vulnerable_code_not_present")]
VulnerableCodeNotPresent,
/// <summary>
/// The vulnerable code is not in the execute path.
/// </summary>
[JsonStringEnumMemberName("vulnerable_code_not_in_execute_path")]
VulnerableCodeNotInExecutePath,
/// <summary>
/// The vulnerable code cannot be controlled by an adversary.
/// </summary>
[JsonStringEnumMemberName("vulnerable_code_cannot_be_controlled_by_adversary")]
VulnerableCodeCannotBeControlledByAdversary,
/// <summary>
/// Inline mitigations already exist.
/// </summary>
[JsonStringEnumMemberName("inline_mitigations_already_exist")]
InlineMitigationsAlreadyExist
}

View File

@@ -63,7 +63,7 @@ public sealed record BoundaryExtractionContext
public string? NetworkZone { get; init; }
/// <summary>
/// Known port bindings (port protocol).
/// Known port bindings (port to protocol).
/// </summary>
public IReadOnlyDictionary<int, string> PortBindings { get; init; } =
new Dictionary<int, string>();

View File

@@ -86,22 +86,22 @@ public sealed record GraphDelta
AddedEdges.Count > 0 || RemovedEdges.Count > 0;
/// <summary>
/// Nodes added in current graph (ΔV+).
/// Nodes added in current graph (delta V+).
/// </summary>
public IReadOnlySet<string> AddedNodes { get; init; } = new HashSet<string>();
/// <summary>
/// Nodes removed from previous graph (ΔV-).
/// Nodes removed from previous graph (delta V-).
/// </summary>
public IReadOnlySet<string> RemovedNodes { get; init; } = new HashSet<string>();
/// <summary>
/// Edges added in current graph (ΔE+).
/// Edges added in current graph (delta E+).
/// </summary>
public IReadOnlyList<GraphEdge> AddedEdges { get; init; } = [];
/// <summary>
/// Edges removed from previous graph (ΔE-).
/// Edges removed from previous graph (delta E-).
/// </summary>
public IReadOnlyList<GraphEdge> RemovedEdges { get; init; } = [];

View File

@@ -396,7 +396,7 @@ public sealed class PrReachabilityGate : IPrReachabilityGate
{
Level = PrAnnotationLevel.Error,
Title = "New Reachable Vulnerability Path",
Message = $"Vulnerability path became reachable: {flip.EntryMethodKey} {flip.SinkMethodKey}",
Message = $"Vulnerability path became reachable: {flip.EntryMethodKey} -> {flip.SinkMethodKey}",
FilePath = flip.SourceFile,
StartLine = flip.StartLine,
EndLine = flip.EndLine
@@ -440,7 +440,7 @@ public sealed class PrReachabilityGate : IPrReachabilityGate
foreach (var flip in decision.BlockingFlips.Take(10))
{
sb.AppendLine($"- `{flip.EntryMethodKey}` `{flip.SinkMethodKey}` (confidence: {flip.Confidence:P0})");
sb.AppendLine($"- `{flip.EntryMethodKey}` -> `{flip.SinkMethodKey}` (confidence: {flip.Confidence:P0})");
}
if (decision.BlockingFlips.Count > 10)

View File

@@ -110,7 +110,7 @@ public sealed class PathRenderer : IPathRenderer
// Hops
foreach (var hop in path.Hops)
{
var prefix = hop.IsEntrypoint ? " " : " ";
var prefix = hop.IsEntrypoint ? " " : " -> ";
var location = hop.File is not null && hop.Line.HasValue
? $" ({hop.File}:{hop.Line})"
: "";
@@ -192,7 +192,7 @@ public sealed class PathRenderer : IPathRenderer
sb.AppendLine("```");
foreach (var hop in path.Hops)
{
var arrow = hop.IsEntrypoint ? "" : " ";
var arrow = hop.IsEntrypoint ? "" : "-> ";
var location = hop.File is not null && hop.Line.HasValue
? $" ({hop.File}:{hop.Line})"
: "";

View File

@@ -131,7 +131,7 @@ public sealed class ReachabilityRichGraphPublisher : IRichGraphPublisher
}
/// <summary>
/// Extracts the hex digest from a prefixed hash (e.g., "blake3:abc123" "abc123").
/// Extracts the hex digest from a prefixed hash (e.g., "blake3:abc123" becomes "abc123").
/// </summary>
private static string ExtractHashDigest(string prefixedHash)
{

View File

@@ -72,24 +72,24 @@ public sealed class SliceDiffComputer
}
private static string EdgeKey(SliceEdge edge)
=> $"{edge.From}{edge.To}:{edge.Kind}";
=> $"{edge.From}->{edge.To}:{edge.Kind}";
private static string? ComputeVerdictDiff(SliceVerdict original, SliceVerdict recomputed)
{
if (original.Status != recomputed.Status)
{
return $"Status changed: {original.Status} {recomputed.Status}";
return $"Status changed: {original.Status} -> {recomputed.Status}";
}
var confidenceDiff = Math.Abs(original.Confidence - recomputed.Confidence);
if (confidenceDiff > 0.01)
{
return $"Confidence changed: {original.Confidence:F3} {recomputed.Confidence:F3} (Δ={confidenceDiff:F3})";
return $"Confidence changed: {original.Confidence:F3} -> {recomputed.Confidence:F3} (delta={confidenceDiff:F3})";
}
if (original.UnknownCount != recomputed.UnknownCount)
{
return $"Unknown count changed: {original.UnknownCount} {recomputed.UnknownCount}";
return $"Unknown count changed: {original.UnknownCount} -> {recomputed.UnknownCount}";
}
return null;

View File

@@ -0,0 +1,85 @@
// -----------------------------------------------------------------------------
// IReachabilityResultFactory.cs
// Sprint: SPRINT_20260106_001_002_SCANNER_suppression_proofs
// Task: SUP-018
// Description: Factory for creating ReachabilityResult with witnesses from
// ReachabilityStack evaluations.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability.Witnesses;
namespace StellaOps.Scanner.Reachability.Stack;
/// <summary>
/// Factory for creating <see cref="Witnesses.ReachabilityResult"/> from
/// <see cref="ReachabilityStack"/> evaluations, including witness generation.
/// </summary>
/// <remarks>
/// This factory bridges the three-layer stack evaluation with the witness system:
/// - For Unreachable verdicts: Creates SuppressionWitness explaining why
/// - For Exploitable verdicts: Creates PathWitness documenting the reachable path
/// - For Unknown verdicts: Returns result without witness
/// </remarks>
public interface IReachabilityResultFactory
{
/// <summary>
/// Creates a <see cref="Witnesses.ReachabilityResult"/> from a reachability stack,
/// generating the appropriate witness based on the verdict.
/// </summary>
/// <param name="stack">The evaluated reachability stack.</param>
/// <param name="context">Context for witness generation (SBOM, component info).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>ReachabilityResult with PathWitness or SuppressionWitness as appropriate.</returns>
Task<Witnesses.ReachabilityResult> CreateResultAsync(
ReachabilityStack stack,
WitnessGenerationContext context,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a <see cref="Witnesses.ReachabilityResult"/> for unknown/inconclusive analysis.
/// </summary>
/// <param name="reason">Reason why analysis was inconclusive.</param>
/// <returns>ReachabilityResult with Unknown verdict.</returns>
Witnesses.ReachabilityResult CreateUnknownResult(string reason);
}
/// <summary>
/// Context for generating witnesses from reachability analysis.
/// </summary>
public sealed record WitnessGenerationContext
{
/// <summary>
/// SBOM digest for artifact identification.
/// </summary>
public required string SbomDigest { get; init; }
/// <summary>
/// Package URL of the vulnerable component.
/// </summary>
public required string ComponentPurl { get; init; }
/// <summary>
/// Vulnerability ID (e.g., "CVE-2024-12345").
/// </summary>
public required string VulnId { get; init; }
/// <summary>
/// Vulnerability source (e.g., "NVD", "OSV").
/// </summary>
public required string VulnSource { get; init; }
/// <summary>
/// Affected version range.
/// </summary>
public required string AffectedRange { get; init; }
/// <summary>
/// Image digest (for container scans).
/// </summary>
public string? ImageDigest { get; init; }
/// <summary>
/// Call graph digest for reproducibility.
/// </summary>
public string? GraphDigest { get; init; }
}

View File

@@ -0,0 +1,245 @@
// -----------------------------------------------------------------------------
// ReachabilityResultFactory.cs
// Sprint: SPRINT_20260106_001_002_SCANNER_suppression_proofs
// Task: SUP-018
// Description: Implementation of IReachabilityResultFactory that integrates
// SuppressionWitnessBuilder with ReachabilityStack evaluation.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Reachability.Witnesses;
namespace StellaOps.Scanner.Reachability.Stack;
/// <summary>
/// Factory that creates <see cref="Witnesses.ReachabilityResult"/> from
/// <see cref="ReachabilityStack"/> evaluations by generating appropriate witnesses.
/// </summary>
public sealed class ReachabilityResultFactory : IReachabilityResultFactory
{
private readonly ISuppressionWitnessBuilder _suppressionBuilder;
private readonly ILogger<ReachabilityResultFactory> _logger;
public ReachabilityResultFactory(
ISuppressionWitnessBuilder suppressionBuilder,
ILogger<ReachabilityResultFactory> logger)
{
_suppressionBuilder = suppressionBuilder ?? throw new ArgumentNullException(nameof(suppressionBuilder));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<Witnesses.ReachabilityResult> CreateResultAsync(
ReachabilityStack stack,
WitnessGenerationContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stack);
ArgumentNullException.ThrowIfNull(context);
return stack.Verdict switch
{
ReachabilityVerdict.Unreachable => await CreateNotAffectedResultAsync(stack, context, cancellationToken).ConfigureAwait(false),
ReachabilityVerdict.Exploitable or
ReachabilityVerdict.LikelyExploitable or
ReachabilityVerdict.PossiblyExploitable => CreateAffectedPlaceholderResult(stack),
ReachabilityVerdict.Unknown => CreateUnknownResult(stack.Explanation ?? "Reachability could not be determined"),
_ => CreateUnknownResult($"Unexpected verdict: {stack.Verdict}")
};
}
/// <summary>
/// Creates a complete result with a pre-built PathWitness for affected findings.
/// Use this when the caller has already built the PathWitness via IPathWitnessBuilder.
/// </summary>
public Witnesses.ReachabilityResult CreateAffectedResult(PathWitness pathWitness)
{
ArgumentNullException.ThrowIfNull(pathWitness);
return Witnesses.ReachabilityResult.Affected(pathWitness);
}
/// <inheritdoc />
public Witnesses.ReachabilityResult CreateUnknownResult(string reason)
{
_logger.LogDebug("Creating Unknown reachability result: {Reason}", reason);
return Witnesses.ReachabilityResult.Unknown();
}
private async Task<Witnesses.ReachabilityResult> CreateNotAffectedResultAsync(
ReachabilityStack stack,
WitnessGenerationContext context,
CancellationToken cancellationToken)
{
_logger.LogDebug(
"Creating NotAffected result for {VulnId} on {Purl}",
context.VulnId,
context.ComponentPurl);
// Determine suppression type based on which layer blocked
var suppressionWitness = await DetermineSuppressionWitnessAsync(
stack,
context,
cancellationToken).ConfigureAwait(false);
return Witnesses.ReachabilityResult.NotAffected(suppressionWitness);
}
private async Task<SuppressionWitness> DetermineSuppressionWitnessAsync(
ReachabilityStack stack,
WitnessGenerationContext context,
CancellationToken cancellationToken)
{
// Check L1 - Static unreachability
if (!stack.StaticCallGraph.IsReachable && stack.StaticCallGraph.Confidence >= ConfidenceLevel.Medium)
{
var request = new UnreachabilityRequest
{
SbomDigest = context.SbomDigest,
ComponentPurl = context.ComponentPurl,
VulnId = context.VulnId,
VulnSource = context.VulnSource,
AffectedRange = context.AffectedRange,
AnalyzedEntrypoints = stack.StaticCallGraph.ReachingEntrypoints.Length,
UnreachableSymbol = stack.Symbol.Name,
AnalysisMethod = stack.StaticCallGraph.AnalysisMethod ?? "static",
GraphDigest = context.GraphDigest ?? "unknown",
Confidence = MapConfidence(stack.StaticCallGraph.Confidence),
Justification = "Static call graph analysis shows no path from entrypoints to vulnerable symbol"
};
return await _suppressionBuilder.BuildUnreachableAsync(request, cancellationToken).ConfigureAwait(false);
}
// Check L2 - Binary resolution failure (function absent)
if (!stack.BinaryResolution.IsResolved && stack.BinaryResolution.Confidence >= ConfidenceLevel.Medium)
{
var request = new FunctionAbsentRequest
{
SbomDigest = context.SbomDigest,
ComponentPurl = context.ComponentPurl,
VulnId = context.VulnId,
VulnSource = context.VulnSource,
AffectedRange = context.AffectedRange,
FunctionName = stack.Symbol.Name,
BinaryDigest = stack.BinaryResolution.Resolution?.ResolvedLibrary ?? "unknown",
VerificationMethod = "binary-resolution",
Confidence = MapConfidence(stack.BinaryResolution.Confidence),
Justification = stack.BinaryResolution.Reason ?? "Vulnerable symbol not found in binary"
};
return await _suppressionBuilder.BuildFunctionAbsentAsync(request, cancellationToken).ConfigureAwait(false);
}
// Check L3 - Runtime gating
if (stack.RuntimeGating.IsGated &&
stack.RuntimeGating.Outcome == GatingOutcome.Blocked &&
stack.RuntimeGating.Confidence >= ConfidenceLevel.Medium)
{
var detectedGates = stack.RuntimeGating.Conditions
.Where(c => c.IsBlocking)
.Select(c => new Witnesses.DetectedGate
{
Type = MapGateType(c.Type.ToString()),
GuardSymbol = c.ConfigKey ?? c.EnvVar ?? c.Description,
Confidence = MapConditionConfidence(c)
})
.ToList();
var request = new GateBlockedRequest
{
SbomDigest = context.SbomDigest,
ComponentPurl = context.ComponentPurl,
VulnId = context.VulnId,
VulnSource = context.VulnSource,
AffectedRange = context.AffectedRange,
DetectedGates = detectedGates,
GateCoveragePercent = CalculateGateCoverage(stack.RuntimeGating),
Effectiveness = "blocking",
Confidence = MapConfidence(stack.RuntimeGating.Confidence),
Justification = "Runtime gates block all exploitation paths"
};
return await _suppressionBuilder.BuildGateBlockedAsync(request, cancellationToken).ConfigureAwait(false);
}
// Fallback: general unreachability
_logger.LogWarning(
"Could not determine specific suppression type for {VulnId}; using generic unreachability",
context.VulnId);
var fallbackRequest = new UnreachabilityRequest
{
SbomDigest = context.SbomDigest,
ComponentPurl = context.ComponentPurl,
VulnId = context.VulnId,
VulnSource = context.VulnSource,
AffectedRange = context.AffectedRange,
AnalyzedEntrypoints = 0,
UnreachableSymbol = stack.Symbol.Name,
AnalysisMethod = "combined",
GraphDigest = context.GraphDigest ?? "unknown",
Confidence = 0.5,
Justification = stack.Explanation ?? "Reachability analysis determined not affected"
};
return await _suppressionBuilder.BuildUnreachableAsync(fallbackRequest, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates a placeholder Affected result when PathWitness is not yet available.
/// The caller should use CreateAffectedResult(PathWitness) when they have built the witness.
/// </summary>
private Witnesses.ReachabilityResult CreateAffectedPlaceholderResult(ReachabilityStack stack)
{
_logger.LogDebug(
"Verdict is {Verdict} for finding {FindingId} - PathWitness should be built separately",
stack.Verdict,
stack.FindingId);
// Return Unknown with metadata indicating affected; caller should build PathWitness
// and call CreateAffectedResult(pathWitness) to get proper result
return Witnesses.ReachabilityResult.Unknown();
}
private static double MapConfidence(ConfidenceLevel level) => level switch
{
ConfidenceLevel.High => 0.95,
ConfidenceLevel.Medium => 0.75,
ConfidenceLevel.Low => 0.50,
_ => 0.50
};
private static double MapVerdictConfidence(ReachabilityVerdict verdict) => verdict switch
{
ReachabilityVerdict.Exploitable => 0.95,
ReachabilityVerdict.LikelyExploitable => 0.80,
ReachabilityVerdict.PossiblyExploitable => 0.60,
_ => 0.50
};
private static string MapGateType(string conditionType) => conditionType switch
{
"authentication" => "auth",
"authorization" => "authz",
"validation" => "validation",
"rate-limiting" => "rate-limit",
"feature-flag" => "feature-flag",
_ => conditionType
};
private static double MapConditionConfidence(GatingCondition condition) =>
condition.IsBlocking ? 0.90 : 0.60;
private static int CalculateGateCoverage(ReachabilityLayer3 layer3)
{
if (layer3.Conditions.Length == 0)
{
return 0;
}
var blockingCount = layer3.Conditions.Count(c => c.IsBlocking);
return (int)(100.0 * blockingCount / layer3.Conditions.Length);
}
}

View File

@@ -0,0 +1,34 @@
using StellaOps.Attestor.Envelope;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Service for creating and verifying DSSE-signed suppression witness envelopes.
/// Sprint: SPRINT_20260106_001_002 (SUP-014)
/// </summary>
public interface ISuppressionDsseSigner
{
/// <summary>
/// Signs a suppression witness and wraps it in a DSSE envelope.
/// </summary>
/// <param name="witness">The suppression witness to sign.</param>
/// <param name="signingKey">The key to sign with.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the signed DSSE envelope.</returns>
SuppressionDsseResult SignWitness(
SuppressionWitness witness,
EnvelopeKey signingKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a DSSE-signed suppression witness envelope.
/// </summary>
/// <param name="envelope">The DSSE envelope to verify.</param>
/// <param name="publicKey">The public key to verify with.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the verified witness.</returns>
SuppressionVerifyResult VerifyWitness(
DsseEnvelope envelope,
EnvelopeKey publicKey,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,342 @@
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Builds suppression witnesses from evidence that a vulnerability is not exploitable.
/// </summary>
public interface ISuppressionWitnessBuilder
{
/// <summary>
/// Creates a suppression witness for unreachable vulnerable code.
/// </summary>
Task<SuppressionWitness> BuildUnreachableAsync(
UnreachabilityRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a suppression witness for a patched symbol.
/// </summary>
Task<SuppressionWitness> BuildPatchedSymbolAsync(
PatchedSymbolRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a suppression witness for absent function.
/// </summary>
Task<SuppressionWitness> BuildFunctionAbsentAsync(
FunctionAbsentRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a suppression witness for gate-blocked exploitation.
/// </summary>
Task<SuppressionWitness> BuildGateBlockedAsync(
GateBlockedRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a suppression witness for feature flag disabled code.
/// </summary>
Task<SuppressionWitness> BuildFeatureFlagDisabledAsync(
FeatureFlagRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a suppression witness from a VEX statement.
/// </summary>
Task<SuppressionWitness> BuildFromVexStatementAsync(
VexStatementRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a suppression witness for version not affected.
/// </summary>
Task<SuppressionWitness> BuildVersionNotAffectedAsync(
VersionRangeRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a suppression witness for linker garbage collected code.
/// </summary>
Task<SuppressionWitness> BuildLinkerGarbageCollectedAsync(
LinkerGcRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Common properties for all suppression witness requests.
/// </summary>
public abstract record BaseSuppressionRequest
{
/// <summary>
/// The SBOM digest for artifact context.
/// </summary>
public required string SbomDigest { get; init; }
/// <summary>
/// Package URL of the vulnerable component.
/// </summary>
public required string ComponentPurl { get; init; }
/// <summary>
/// Vulnerability ID (e.g., "CVE-2024-12345").
/// </summary>
public required string VulnId { get; init; }
/// <summary>
/// Vulnerability source (e.g., "NVD").
/// </summary>
public required string VulnSource { get; init; }
/// <summary>
/// Affected version range.
/// </summary>
public required string AffectedRange { get; init; }
/// <summary>
/// Optional justification narrative.
/// </summary>
public string? Justification { get; init; }
/// <summary>
/// Optional expiration for time-bounded suppressions.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
}
/// <summary>
/// Request to build unreachability suppression witness.
/// </summary>
public sealed record UnreachabilityRequest : BaseSuppressionRequest
{
/// <summary>
/// Number of entrypoints analyzed.
/// </summary>
public required int AnalyzedEntrypoints { get; init; }
/// <summary>
/// Vulnerable symbol confirmed unreachable.
/// </summary>
public required string UnreachableSymbol { get; init; }
/// <summary>
/// Analysis method (static, dynamic, hybrid).
/// </summary>
public required string AnalysisMethod { get; init; }
/// <summary>
/// Graph digest for reproducibility.
/// </summary>
public required string GraphDigest { get; init; }
/// <summary>
/// Confidence level ([0.0, 1.0]).
/// </summary>
public required double Confidence { get; init; }
}
/// <summary>
/// Request to build patched symbol suppression witness.
/// </summary>
public sealed record PatchedSymbolRequest : BaseSuppressionRequest
{
/// <summary>
/// Vulnerable symbol identifier.
/// </summary>
public required string VulnerableSymbol { get; init; }
/// <summary>
/// Patched symbol identifier.
/// </summary>
public required string PatchedSymbol { get; init; }
/// <summary>
/// Symbol diff showing the patch.
/// </summary>
public required string SymbolDiff { get; init; }
/// <summary>
/// Patch commit or release reference.
/// </summary>
public string? PatchRef { get; init; }
/// <summary>
/// Confidence level ([0.0, 1.0]).
/// </summary>
public required double Confidence { get; init; }
}
/// <summary>
/// Request to build function absent suppression witness.
/// </summary>
public sealed record FunctionAbsentRequest : BaseSuppressionRequest
{
/// <summary>
/// Vulnerable function name.
/// </summary>
public required string FunctionName { get; init; }
/// <summary>
/// Binary digest where function was checked.
/// </summary>
public required string BinaryDigest { get; init; }
/// <summary>
/// Verification method (symbol table scan, disassembly, etc.).
/// </summary>
public required string VerificationMethod { get; init; }
/// <summary>
/// Confidence level ([0.0, 1.0]).
/// </summary>
public required double Confidence { get; init; }
}
/// <summary>
/// Request to build gate blocked suppression witness.
/// </summary>
public sealed record GateBlockedRequest : BaseSuppressionRequest
{
/// <summary>
/// Detected gates along all paths to vulnerable code.
/// </summary>
public required IReadOnlyList<DetectedGate> DetectedGates { get; init; }
/// <summary>
/// Minimum gate coverage percentage ([0, 100]).
/// </summary>
public required int GateCoveragePercent { get; init; }
/// <summary>
/// Gate effectiveness assessment.
/// </summary>
public required string Effectiveness { get; init; }
/// <summary>
/// Confidence level ([0.0, 1.0]).
/// </summary>
public required double Confidence { get; init; }
}
/// <summary>
/// Request to build feature flag suppression witness.
/// </summary>
public sealed record FeatureFlagRequest : BaseSuppressionRequest
{
/// <summary>
/// Feature flag name.
/// </summary>
public required string FlagName { get; init; }
/// <summary>
/// Flag state (enabled, disabled).
/// </summary>
public required string FlagState { get; init; }
/// <summary>
/// Flag configuration source.
/// </summary>
public required string ConfigSource { get; init; }
/// <summary>
/// Vulnerable code path guarded by flag.
/// </summary>
public string? GuardedPath { get; init; }
/// <summary>
/// Confidence level ([0.0, 1.0]).
/// </summary>
public required double Confidence { get; init; }
}
/// <summary>
/// Request to build VEX statement suppression witness.
/// </summary>
public sealed record VexStatementRequest : BaseSuppressionRequest
{
/// <summary>
/// VEX document identifier.
/// </summary>
public required string VexId { get; init; }
/// <summary>
/// VEX document author/source.
/// </summary>
public required string VexAuthor { get; init; }
/// <summary>
/// VEX statement status.
/// </summary>
public required string VexStatus { get; init; }
/// <summary>
/// Justification from VEX statement.
/// </summary>
public string? VexJustification { get; init; }
/// <summary>
/// VEX document digest for verification.
/// </summary>
public string? VexDigest { get; init; }
/// <summary>
/// Confidence level ([0.0, 1.0]).
/// </summary>
public required double Confidence { get; init; }
}
/// <summary>
/// Request to build version range suppression witness.
/// </summary>
public sealed record VersionRangeRequest : BaseSuppressionRequest
{
/// <summary>
/// Installed version.
/// </summary>
public required string InstalledVersion { get; init; }
/// <summary>
/// Parsed version comparison result.
/// </summary>
public required string ComparisonResult { get; init; }
/// <summary>
/// Version scheme (semver, rpm, deb, etc.).
/// </summary>
public required string VersionScheme { get; init; }
/// <summary>
/// Confidence level ([0.0, 1.0]).
/// </summary>
public required double Confidence { get; init; }
}
/// <summary>
/// Request to build linker GC suppression witness.
/// </summary>
public sealed record LinkerGcRequest : BaseSuppressionRequest
{
/// <summary>
/// Vulnerable symbol that was collected.
/// </summary>
public required string CollectedSymbol { get; init; }
/// <summary>
/// Linker log or report showing removal.
/// </summary>
public string? LinkerLog { get; init; }
/// <summary>
/// Linker used (ld, lld, link.exe, etc.).
/// </summary>
public required string Linker { get; init; }
/// <summary>
/// Build flags that enabled GC.
/// </summary>
public required string BuildFlags { get; init; }
/// <summary>
/// Confidence level ([0.0, 1.0]).
/// </summary>
public required double Confidence { get; init; }
}

View File

@@ -402,7 +402,7 @@ public sealed class PathWitnessBuilder : IPathWitnessBuilder
parent.TryGetValue(current, out current);
}
path.Reverse(); // Reverse to get source target order
path.Reverse(); // Reverse to get source -> target order
return path;
}

View File

@@ -0,0 +1,62 @@
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Unified result type for reachability analysis that contains either a PathWitness (affected)
/// or a SuppressionWitness (not affected).
/// Sprint: SPRINT_20260106_001_002 (SUP-017)
/// </summary>
public sealed record ReachabilityResult
{
/// <summary>
/// The reachability verdict.
/// </summary>
public required ReachabilityVerdict Verdict { get; init; }
/// <summary>
/// Witness proving vulnerability is reachable (when Verdict = Affected).
/// </summary>
public PathWitness? PathWitness { get; init; }
/// <summary>
/// Witness proving vulnerability is not exploitable (when Verdict = NotAffected).
/// </summary>
public SuppressionWitness? SuppressionWitness { get; init; }
/// <summary>
/// Creates a result indicating the vulnerability is affected/reachable.
/// </summary>
/// <param name="witness">PathWitness proving reachability.</param>
/// <returns>ReachabilityResult with Affected verdict.</returns>
public static ReachabilityResult Affected(PathWitness witness) =>
new() { Verdict = ReachabilityVerdict.Affected, PathWitness = witness };
/// <summary>
/// Creates a result indicating the vulnerability is not affected/not exploitable.
/// </summary>
/// <param name="witness">SuppressionWitness explaining why not affected.</param>
/// <returns>ReachabilityResult with NotAffected verdict.</returns>
public static ReachabilityResult NotAffected(SuppressionWitness witness) =>
new() { Verdict = ReachabilityVerdict.NotAffected, SuppressionWitness = witness };
/// <summary>
/// Creates a result indicating reachability could not be determined.
/// </summary>
/// <returns>ReachabilityResult with Unknown verdict.</returns>
public static ReachabilityResult Unknown() =>
new() { Verdict = ReachabilityVerdict.Unknown };
}
/// <summary>
/// Verdict of reachability analysis.
/// </summary>
public enum ReachabilityVerdict
{
/// <summary>Vulnerable code is reachable - PathWitness provided.</summary>
Affected,
/// <summary>Vulnerable code is not exploitable - SuppressionWitness provided.</summary>
NotAffected,
/// <summary>Reachability could not be determined.</summary>
Unknown
}

View File

@@ -0,0 +1,207 @@
using System.Text;
using System.Text.Json;
using StellaOps.Attestor.Envelope;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Service for creating and verifying DSSE-signed suppression witness envelopes.
/// Sprint: SPRINT_20260106_001_002 (SUP-015)
/// </summary>
public sealed class SuppressionDsseSigner : ISuppressionDsseSigner
{
private readonly EnvelopeSignatureService _signatureService;
private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Creates a new SuppressionDsseSigner with the specified signature service.
/// </summary>
public SuppressionDsseSigner(EnvelopeSignatureService signatureService)
{
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
}
/// <summary>
/// Creates a new SuppressionDsseSigner with a default signature service.
/// </summary>
public SuppressionDsseSigner() : this(new EnvelopeSignatureService())
{
}
/// <inheritdoc />
public SuppressionDsseResult SignWitness(SuppressionWitness witness, EnvelopeKey signingKey, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(witness);
ArgumentNullException.ThrowIfNull(signingKey);
cancellationToken.ThrowIfCancellationRequested();
try
{
// Serialize witness to canonical JSON bytes
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(witness, CanonicalJsonOptions);
// Build the PAE (Pre-Authentication Encoding) for DSSE
var pae = BuildPae(SuppressionWitnessSchema.DssePayloadType, payloadBytes);
// Sign the PAE
var signResult = _signatureService.Sign(pae, signingKey, cancellationToken);
if (!signResult.IsSuccess)
{
return SuppressionDsseResult.Failure($"Signing failed: {signResult.Error?.Message}");
}
var signature = signResult.Value;
// Create the DSSE envelope
var dsseSignature = new DsseSignature(
signature: Convert.ToBase64String(signature.Value.Span),
keyId: signature.KeyId);
var envelope = new DsseEnvelope(
payloadType: SuppressionWitnessSchema.DssePayloadType,
payload: payloadBytes,
signatures: [dsseSignature]);
return SuppressionDsseResult.Success(envelope, payloadBytes);
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
return SuppressionDsseResult.Failure($"Failed to create DSSE envelope: {ex.Message}");
}
}
/// <inheritdoc />
public SuppressionVerifyResult VerifyWitness(DsseEnvelope envelope, EnvelopeKey publicKey, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentNullException.ThrowIfNull(publicKey);
cancellationToken.ThrowIfCancellationRequested();
try
{
// Verify payload type
if (!string.Equals(envelope.PayloadType, SuppressionWitnessSchema.DssePayloadType, StringComparison.Ordinal))
{
return SuppressionVerifyResult.Failure($"Invalid payload type: expected '{SuppressionWitnessSchema.DssePayloadType}', got '{envelope.PayloadType}'");
}
// Deserialize the witness from payload
var witness = JsonSerializer.Deserialize<SuppressionWitness>(envelope.Payload.Span, CanonicalJsonOptions);
if (witness is null)
{
return SuppressionVerifyResult.Failure("Failed to deserialize witness from payload");
}
// Verify schema version
if (!string.Equals(witness.WitnessSchema, SuppressionWitnessSchema.Version, StringComparison.Ordinal))
{
return SuppressionVerifyResult.Failure($"Unsupported witness schema: {witness.WitnessSchema}");
}
// Find signature matching the public key
var matchingSignature = envelope.Signatures.FirstOrDefault(
s => string.Equals(s.KeyId, publicKey.KeyId, StringComparison.Ordinal));
if (matchingSignature is null)
{
return SuppressionVerifyResult.Failure($"No signature found for key ID: {publicKey.KeyId}");
}
// Build PAE and verify signature
var pae = BuildPae(envelope.PayloadType, envelope.Payload.ToArray());
var signatureBytes = Convert.FromBase64String(matchingSignature.Signature);
var envelopeSignature = new EnvelopeSignature(publicKey.KeyId, publicKey.AlgorithmId, signatureBytes);
var verifyResult = _signatureService.Verify(pae, envelopeSignature, publicKey, cancellationToken);
if (!verifyResult.IsSuccess)
{
return SuppressionVerifyResult.Failure($"Signature verification failed: {verifyResult.Error?.Message}");
}
return SuppressionVerifyResult.Success(witness, matchingSignature.KeyId!);
}
catch (Exception ex) when (ex is JsonException or FormatException or InvalidOperationException)
{
return SuppressionVerifyResult.Failure($"Verification failed: {ex.Message}");
}
}
/// <summary>
/// Builds the DSSE Pre-Authentication Encoding (PAE) for a payload.
/// PAE = "DSSEv1" SP len(type) SP type SP len(payload) SP payload
/// </summary>
private static byte[] BuildPae(string payloadType, byte[] payload)
{
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
// Write "DSSEv1 "
writer.Write(Encoding.UTF8.GetBytes("DSSEv1 "));
// Write len(type) as ASCII decimal string followed by space
WriteLengthAndSpace(writer, typeBytes.Length);
// Write type followed by space
writer.Write(typeBytes);
writer.Write((byte)' ');
// Write len(payload) as ASCII decimal string followed by space
WriteLengthAndSpace(writer, payload.Length);
// Write payload
writer.Write(payload);
writer.Flush();
return stream.ToArray();
}
private static void WriteLengthAndSpace(BinaryWriter writer, int length)
{
// Write length as ASCII decimal string
writer.Write(Encoding.UTF8.GetBytes(length.ToString()));
writer.Write((byte)' ');
}
}
/// <summary>
/// Result of DSSE signing a suppression witness.
/// </summary>
public sealed record SuppressionDsseResult
{
public bool IsSuccess { get; init; }
public DsseEnvelope? Envelope { get; init; }
public byte[]? PayloadBytes { get; init; }
public string? Error { get; init; }
public static SuppressionDsseResult Success(DsseEnvelope envelope, byte[] payloadBytes)
=> new() { IsSuccess = true, Envelope = envelope, PayloadBytes = payloadBytes };
public static SuppressionDsseResult Failure(string error)
=> new() { IsSuccess = false, Error = error };
}
/// <summary>
/// Result of verifying a DSSE-signed suppression witness.
/// </summary>
public sealed record SuppressionVerifyResult
{
public bool IsSuccess { get; init; }
public SuppressionWitness? Witness { get; init; }
public string? VerifiedKeyId { get; init; }
public string? Error { get; init; }
public static SuppressionVerifyResult Success(SuppressionWitness witness, string keyId)
=> new() { IsSuccess = true, Witness = witness, VerifiedKeyId = keyId };
public static SuppressionVerifyResult Failure(string error)
=> new() { IsSuccess = false, Error = error };
}

View File

@@ -0,0 +1,400 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// A DSSE-signable suppression witness documenting why a vulnerability is not exploitable.
/// Conforms to stellaops.suppression.v1 schema.
/// </summary>
public sealed record SuppressionWitness
{
/// <summary>
/// Schema version identifier.
/// </summary>
[JsonPropertyName("witness_schema")]
public string WitnessSchema { get; init; } = SuppressionWitnessSchema.Version;
/// <summary>
/// Content-addressed witness ID (e.g., "sup:sha256:...").
/// </summary>
[JsonPropertyName("witness_id")]
public required string WitnessId { get; init; }
/// <summary>
/// The artifact (SBOM, component) this witness relates to.
/// </summary>
[JsonPropertyName("artifact")]
public required WitnessArtifact Artifact { get; init; }
/// <summary>
/// The vulnerability this witness concerns.
/// </summary>
[JsonPropertyName("vuln")]
public required WitnessVuln Vuln { get; init; }
/// <summary>
/// The type of suppression (unreachable, patched, gate-blocked, etc.).
/// </summary>
[JsonPropertyName("suppression_type")]
public required SuppressionType SuppressionType { get; init; }
/// <summary>
/// Evidence supporting the suppression claim.
/// </summary>
[JsonPropertyName("evidence")]
public required SuppressionEvidence Evidence { get; init; }
/// <summary>
/// Confidence level in this suppression ([0.0, 1.0]).
/// </summary>
[JsonPropertyName("confidence")]
public required double Confidence { get; init; }
/// <summary>
/// Optional expiration date for time-bounded suppressions (UTC ISO-8601).
/// </summary>
[JsonPropertyName("expires_at")]
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// When this witness was generated (UTC ISO-8601).
/// </summary>
[JsonPropertyName("observed_at")]
public required DateTimeOffset ObservedAt { get; init; }
/// <summary>
/// Optional justification narrative.
/// </summary>
[JsonPropertyName("justification")]
public string? Justification { get; init; }
}
/// <summary>
/// Classification of suppression reasons.
/// </summary>
public enum SuppressionType
{
/// <summary>Vulnerable code is unreachable from any entry point.</summary>
Unreachable,
/// <summary>Vulnerable symbol was removed by linker garbage collection.</summary>
LinkerGarbageCollected,
/// <summary>Feature flag disables the vulnerable code path.</summary>
FeatureFlagDisabled,
/// <summary>Vulnerable symbol was patched (backport).</summary>
PatchedSymbol,
/// <summary>Runtime gate (authentication, validation) blocks exploitation.</summary>
GateBlocked,
/// <summary>Compile-time configuration excludes vulnerable code.</summary>
CompileTimeExcluded,
/// <summary>VEX statement from authoritative source declares not_affected.</summary>
VexNotAffected,
/// <summary>Binary does not contain the vulnerable function.</summary>
FunctionAbsent,
/// <summary>Version is outside the affected range.</summary>
VersionNotAffected,
/// <summary>Platform/architecture not vulnerable.</summary>
PlatformNotAffected
}
/// <summary>
/// Evidence supporting a suppression claim. Contains type-specific details.
/// </summary>
public sealed record SuppressionEvidence
{
/// <summary>
/// Evidence digests for reproducibility.
/// </summary>
[JsonPropertyName("witness_evidence")]
public required WitnessEvidence WitnessEvidence { get; init; }
/// <summary>
/// Unreachability evidence (when SuppressionType is Unreachable).
/// </summary>
[JsonPropertyName("unreachability")]
public UnreachabilityEvidence? Unreachability { get; init; }
/// <summary>
/// Patched symbol evidence (when SuppressionType is PatchedSymbol).
/// </summary>
[JsonPropertyName("patched_symbol")]
public PatchedSymbolEvidence? PatchedSymbol { get; init; }
/// <summary>
/// Function absence evidence (when SuppressionType is FunctionAbsent).
/// </summary>
[JsonPropertyName("function_absent")]
public FunctionAbsentEvidence? FunctionAbsent { get; init; }
/// <summary>
/// Gate blocking evidence (when SuppressionType is GateBlocked).
/// </summary>
[JsonPropertyName("gate_blocked")]
public GateBlockedEvidence? GateBlocked { get; init; }
/// <summary>
/// Feature flag evidence (when SuppressionType is FeatureFlagDisabled).
/// </summary>
[JsonPropertyName("feature_flag")]
public FeatureFlagEvidence? FeatureFlag { get; init; }
/// <summary>
/// VEX statement evidence (when SuppressionType is VexNotAffected).
/// </summary>
[JsonPropertyName("vex_statement")]
public VexStatementEvidence? VexStatement { get; init; }
/// <summary>
/// Version range evidence (when SuppressionType is VersionNotAffected).
/// </summary>
[JsonPropertyName("version_range")]
public VersionRangeEvidence? VersionRange { get; init; }
/// <summary>
/// Linker GC evidence (when SuppressionType is LinkerGarbageCollected).
/// </summary>
[JsonPropertyName("linker_gc")]
public LinkerGcEvidence? LinkerGc { get; init; }
}
/// <summary>
/// Evidence that vulnerable code is unreachable from any entry point.
/// </summary>
public sealed record UnreachabilityEvidence
{
/// <summary>
/// Number of entrypoints analyzed.
/// </summary>
[JsonPropertyName("analyzed_entrypoints")]
public required int AnalyzedEntrypoints { get; init; }
/// <summary>
/// Vulnerable symbol that was confirmed unreachable.
/// </summary>
[JsonPropertyName("unreachable_symbol")]
public required string UnreachableSymbol { get; init; }
/// <summary>
/// Analysis method (static, dynamic, hybrid).
/// </summary>
[JsonPropertyName("analysis_method")]
public required string AnalysisMethod { get; init; }
/// <summary>
/// Graph digest for reproducibility.
/// </summary>
[JsonPropertyName("graph_digest")]
public required string GraphDigest { get; init; }
}
/// <summary>
/// Evidence that vulnerable symbol was patched (backport).
/// </summary>
public sealed record PatchedSymbolEvidence
{
/// <summary>
/// Vulnerable symbol identifier.
/// </summary>
[JsonPropertyName("vulnerable_symbol")]
public required string VulnerableSymbol { get; init; }
/// <summary>
/// Patched symbol identifier.
/// </summary>
[JsonPropertyName("patched_symbol")]
public required string PatchedSymbol { get; init; }
/// <summary>
/// Symbol diff showing the patch.
/// </summary>
[JsonPropertyName("symbol_diff")]
public required string SymbolDiff { get; init; }
/// <summary>
/// Patch commit or release reference.
/// </summary>
[JsonPropertyName("patch_ref")]
public string? PatchRef { get; init; }
}
/// <summary>
/// Evidence that vulnerable function is absent from the binary.
/// </summary>
public sealed record FunctionAbsentEvidence
{
/// <summary>
/// Vulnerable function name.
/// </summary>
[JsonPropertyName("function_name")]
public required string FunctionName { get; init; }
/// <summary>
/// Binary digest where function was checked.
/// </summary>
[JsonPropertyName("binary_digest")]
public required string BinaryDigest { get; init; }
/// <summary>
/// Verification method (symbol table scan, disassembly, etc.).
/// </summary>
[JsonPropertyName("verification_method")]
public required string VerificationMethod { get; init; }
}
/// <summary>
/// Evidence that runtime gates block exploitation.
/// </summary>
public sealed record GateBlockedEvidence
{
/// <summary>
/// Detected gates along all paths to vulnerable code.
/// </summary>
[JsonPropertyName("detected_gates")]
public required IReadOnlyList<DetectedGate> DetectedGates { get; init; }
/// <summary>
/// Minimum gate coverage percentage ([0, 100]).
/// </summary>
[JsonPropertyName("gate_coverage_percent")]
public required int GateCoveragePercent { get; init; }
/// <summary>
/// Gate effectiveness assessment.
/// </summary>
[JsonPropertyName("effectiveness")]
public required string Effectiveness { get; init; }
}
/// <summary>
/// Evidence that feature flag disables vulnerable code.
/// </summary>
public sealed record FeatureFlagEvidence
{
/// <summary>
/// Feature flag name.
/// </summary>
[JsonPropertyName("flag_name")]
public required string FlagName { get; init; }
/// <summary>
/// Flag state (enabled, disabled).
/// </summary>
[JsonPropertyName("flag_state")]
public required string FlagState { get; init; }
/// <summary>
/// Flag configuration source.
/// </summary>
[JsonPropertyName("config_source")]
public required string ConfigSource { get; init; }
/// <summary>
/// Vulnerable code path guarded by flag.
/// </summary>
[JsonPropertyName("guarded_path")]
public string? GuardedPath { get; init; }
}
/// <summary>
/// Evidence from VEX statement declaring not_affected.
/// </summary>
public sealed record VexStatementEvidence
{
/// <summary>
/// VEX document identifier.
/// </summary>
[JsonPropertyName("vex_id")]
public required string VexId { get; init; }
/// <summary>
/// VEX document author/source.
/// </summary>
[JsonPropertyName("vex_author")]
public required string VexAuthor { get; init; }
/// <summary>
/// VEX statement status.
/// </summary>
[JsonPropertyName("vex_status")]
public required string VexStatus { get; init; }
/// <summary>
/// Justification from VEX statement.
/// </summary>
[JsonPropertyName("vex_justification")]
public string? VexJustification { get; init; }
/// <summary>
/// VEX document digest for verification.
/// </summary>
[JsonPropertyName("vex_digest")]
public string? VexDigest { get; init; }
}
/// <summary>
/// Evidence that version is outside affected range.
/// </summary>
public sealed record VersionRangeEvidence
{
/// <summary>
/// Installed version.
/// </summary>
[JsonPropertyName("installed_version")]
public required string InstalledVersion { get; init; }
/// <summary>
/// Affected version range expression.
/// </summary>
[JsonPropertyName("affected_range")]
public required string AffectedRange { get; init; }
/// <summary>
/// Parsed version comparison result.
/// </summary>
[JsonPropertyName("comparison_result")]
public required string ComparisonResult { get; init; }
/// <summary>
/// Version scheme (semver, rpm, deb, etc.).
/// </summary>
[JsonPropertyName("version_scheme")]
public required string VersionScheme { get; init; }
}
/// <summary>
/// Evidence that linker garbage collection removed vulnerable code.
/// </summary>
public sealed record LinkerGcEvidence
{
/// <summary>
/// Vulnerable symbol that was collected.
/// </summary>
[JsonPropertyName("collected_symbol")]
public required string CollectedSymbol { get; init; }
/// <summary>
/// Linker log or report showing removal.
/// </summary>
[JsonPropertyName("linker_log")]
public string? LinkerLog { get; init; }
/// <summary>
/// Linker used (ld, lld, link.exe, etc.).
/// </summary>
[JsonPropertyName("linker")]
public required string Linker { get; init; }
/// <summary>
/// Build flags that enabled GC.
/// </summary>
[JsonPropertyName("build_flags")]
public required string BuildFlags { get; init; }
}

View File

@@ -0,0 +1,285 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Builds suppression witnesses from evidence that a vulnerability is not exploitable.
/// </summary>
public sealed class SuppressionWitnessBuilder : ISuppressionWitnessBuilder
{
private readonly ICryptoHash _cryptoHash;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
/// <summary>
/// Creates a new SuppressionWitnessBuilder.
/// </summary>
/// <param name="cryptoHash">Crypto hash service for witness ID generation.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
public SuppressionWitnessBuilder(ICryptoHash cryptoHash, TimeProvider timeProvider)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <inheritdoc />
public Task<SuppressionWitness> BuildUnreachableAsync(
UnreachabilityRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var evidence = new SuppressionEvidence
{
WitnessEvidence = CreateWitnessEvidence(request.GraphDigest),
Unreachability = new UnreachabilityEvidence
{
AnalyzedEntrypoints = request.AnalyzedEntrypoints,
UnreachableSymbol = request.UnreachableSymbol,
AnalysisMethod = request.AnalysisMethod,
GraphDigest = request.GraphDigest
}
};
var witness = CreateWitness(request, SuppressionType.Unreachable, evidence, request.Confidence);
return Task.FromResult(witness);
}
/// <inheritdoc />
public Task<SuppressionWitness> BuildPatchedSymbolAsync(
PatchedSymbolRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var symbolDiffDigest = ComputeStringDigest(request.SymbolDiff);
var evidence = new SuppressionEvidence
{
WitnessEvidence = CreateWitnessEvidence(symbolDiffDigest),
PatchedSymbol = new PatchedSymbolEvidence
{
VulnerableSymbol = request.VulnerableSymbol,
PatchedSymbol = request.PatchedSymbol,
SymbolDiff = request.SymbolDiff,
PatchRef = request.PatchRef
}
};
var witness = CreateWitness(request, SuppressionType.PatchedSymbol, evidence, request.Confidence);
return Task.FromResult(witness);
}
/// <inheritdoc />
public Task<SuppressionWitness> BuildFunctionAbsentAsync(
FunctionAbsentRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var evidence = new SuppressionEvidence
{
WitnessEvidence = CreateWitnessEvidence(request.BinaryDigest),
FunctionAbsent = new FunctionAbsentEvidence
{
FunctionName = request.FunctionName,
BinaryDigest = request.BinaryDigest,
VerificationMethod = request.VerificationMethod
}
};
var witness = CreateWitness(request, SuppressionType.FunctionAbsent, evidence, request.Confidence);
return Task.FromResult(witness);
}
/// <inheritdoc />
public Task<SuppressionWitness> BuildGateBlockedAsync(
GateBlockedRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var gatesDigest = ComputeGatesDigest(request.DetectedGates);
var evidence = new SuppressionEvidence
{
WitnessEvidence = CreateWitnessEvidence(gatesDigest),
GateBlocked = new GateBlockedEvidence
{
DetectedGates = request.DetectedGates,
GateCoveragePercent = request.GateCoveragePercent,
Effectiveness = request.Effectiveness
}
};
var witness = CreateWitness(request, SuppressionType.GateBlocked, evidence, request.Confidence);
return Task.FromResult(witness);
}
/// <inheritdoc />
public Task<SuppressionWitness> BuildFeatureFlagDisabledAsync(
FeatureFlagRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var flagDigest = ComputeStringDigest($"{request.FlagName}={request.FlagState}@{request.ConfigSource}");
var evidence = new SuppressionEvidence
{
WitnessEvidence = CreateWitnessEvidence(flagDigest),
FeatureFlag = new FeatureFlagEvidence
{
FlagName = request.FlagName,
FlagState = request.FlagState,
ConfigSource = request.ConfigSource,
GuardedPath = request.GuardedPath
}
};
var witness = CreateWitness(request, SuppressionType.FeatureFlagDisabled, evidence, request.Confidence);
return Task.FromResult(witness);
}
/// <inheritdoc />
public Task<SuppressionWitness> BuildFromVexStatementAsync(
VexStatementRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var evidence = new SuppressionEvidence
{
WitnessEvidence = CreateWitnessEvidence(request.VexDigest ?? request.VexId),
VexStatement = new VexStatementEvidence
{
VexId = request.VexId,
VexAuthor = request.VexAuthor,
VexStatus = request.VexStatus,
VexJustification = request.VexJustification,
VexDigest = request.VexDigest
}
};
var witness = CreateWitness(request, SuppressionType.VexNotAffected, evidence, request.Confidence);
return Task.FromResult(witness);
}
/// <inheritdoc />
public Task<SuppressionWitness> BuildVersionNotAffectedAsync(
VersionRangeRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var versionDigest = ComputeStringDigest($"{request.InstalledVersion}@{request.AffectedRange}");
var evidence = new SuppressionEvidence
{
WitnessEvidence = CreateWitnessEvidence(versionDigest),
VersionRange = new VersionRangeEvidence
{
InstalledVersion = request.InstalledVersion,
AffectedRange = request.AffectedRange,
ComparisonResult = request.ComparisonResult,
VersionScheme = request.VersionScheme
}
};
var witness = CreateWitness(request, SuppressionType.VersionNotAffected, evidence, request.Confidence);
return Task.FromResult(witness);
}
/// <inheritdoc />
public Task<SuppressionWitness> BuildLinkerGarbageCollectedAsync(
LinkerGcRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var gcDigest = ComputeStringDigest($"{request.CollectedSymbol}@{request.Linker}@{request.BuildFlags}");
var evidence = new SuppressionEvidence
{
WitnessEvidence = CreateWitnessEvidence(gcDigest),
LinkerGc = new LinkerGcEvidence
{
CollectedSymbol = request.CollectedSymbol,
LinkerLog = request.LinkerLog,
Linker = request.Linker,
BuildFlags = request.BuildFlags
}
};
var witness = CreateWitness(request, SuppressionType.LinkerGarbageCollected, evidence, request.Confidence);
return Task.FromResult(witness);
}
// Private helpers
private SuppressionWitness CreateWitness(
BaseSuppressionRequest request,
SuppressionType type,
SuppressionEvidence evidence,
double confidence)
{
var now = _timeProvider.GetUtcNow();
var witness = new SuppressionWitness
{
WitnessId = string.Empty, // Will be set after hashing
Artifact = new WitnessArtifact
{
SbomDigest = request.SbomDigest,
ComponentPurl = request.ComponentPurl
},
Vuln = new WitnessVuln
{
Id = request.VulnId,
Source = request.VulnSource,
AffectedRange = request.AffectedRange
},
SuppressionType = type,
Evidence = evidence,
Confidence = Math.Clamp(confidence, 0.0, 1.0),
ExpiresAt = request.ExpiresAt,
ObservedAt = now,
Justification = request.Justification
};
// Compute content-addressed witness ID
var canonicalJson = JsonSerializer.Serialize(witness, JsonOptions);
var witnessIdDigest = _cryptoHash.ComputeHash(Encoding.UTF8.GetBytes(canonicalJson));
var witnessId = $"sup:sha256:{Convert.ToHexString(witnessIdDigest).ToLowerInvariant()}";
return witness with { WitnessId = witnessId };
}
private WitnessEvidence CreateWitnessEvidence(string primaryDigest)
{
return new WitnessEvidence
{
CallgraphDigest = primaryDigest,
BuildId = $"StellaOps.Scanner/{GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0"}"
};
}
private string ComputeStringDigest(string input)
{
var bytes = Encoding.UTF8.GetBytes(input);
var hash = _cryptoHash.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private string ComputeGatesDigest(IReadOnlyList<DetectedGate> gates)
{
// Serialize gates in deterministic order
var sortedGates = gates.OrderBy(g => g.Type).ThenBy(g => g.GuardSymbol).ToList();
var json = JsonSerializer.Serialize(sortedGates, JsonOptions);
var hash = _cryptoHash.ComputeHash(Encoding.UTF8.GetBytes(json));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Schema version for SuppressionWitness documents.
/// </summary>
public static class SuppressionWitnessSchema
{
/// <summary>
/// Current stellaops.suppression schema version.
/// </summary>
public const string Version = "stellaops.suppression.v1";
/// <summary>
/// DSSE payload type for suppression witnesses.
/// </summary>
public const string DssePayloadType = "https://stellaops.org/suppression/v1";
}

View File

@@ -0,0 +1,29 @@
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Extension methods for registering suppression witness services.
/// Sprint: SPRINT_20260106_001_002 (SUP-019)
/// </summary>
public static class SuppressionWitnessServiceCollectionExtensions
{
/// <summary>
/// Adds suppression witness services to the dependency injection container.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddSuppressionWitnessServices(this IServiceCollection services)
{
// Register builder
services.AddSingleton<ISuppressionWitnessBuilder, SuppressionWitnessBuilder>();
// Register DSSE signer
services.AddSingleton<ISuppressionDsseSigner, SuppressionDsseSigner>();
// Register TimeProvider if not already registered
services.AddSingleton(TimeProvider.System);
return services;
}
}

View File

@@ -0,0 +1,271 @@
// <copyright file="PostgresFacetSealStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_003_FACET (QTA-013)
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Facet;
using StellaOps.Facet.Serialization;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IFacetSealStore"/>.
/// </summary>
/// <remarks>
/// <para>
/// Stores facet seals in the scanner schema with JSONB for the seal content.
/// Indexed by image_digest and combined_merkle_root for efficient lookups.
/// </para>
/// </remarks>
public sealed class PostgresFacetSealStore : IFacetSealStore
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<PostgresFacetSealStore> _logger;
private const string SelectColumns = """
combined_merkle_root, image_digest, schema_version, created_at,
build_attestation_ref, signature, signing_key_id, seal_content
""";
private const string InsertSql = """
INSERT INTO scanner.facet_seals (
combined_merkle_root, image_digest, schema_version, created_at,
build_attestation_ref, signature, signing_key_id, seal_content
) VALUES (
@combined_merkle_root, @image_digest, @schema_version, @created_at,
@build_attestation_ref, @signature, @signing_key_id, @seal_content::jsonb
)
""";
private const string SelectLatestSql = $"""
SELECT {SelectColumns}
FROM scanner.facet_seals
WHERE image_digest = @image_digest
ORDER BY created_at DESC
LIMIT 1
""";
private const string SelectByCombinedRootSql = $"""
SELECT {SelectColumns}
FROM scanner.facet_seals
WHERE combined_merkle_root = @combined_merkle_root
""";
private const string SelectHistorySql = $"""
SELECT {SelectColumns}
FROM scanner.facet_seals
WHERE image_digest = @image_digest
ORDER BY created_at DESC
LIMIT @limit
""";
private const string ExistsSql = """
SELECT EXISTS(
SELECT 1 FROM scanner.facet_seals
WHERE image_digest = @image_digest
)
""";
private const string DeleteByImageSql = """
DELETE FROM scanner.facet_seals
WHERE image_digest = @image_digest
""";
private const string PurgeSql = """
WITH ranked AS (
SELECT combined_merkle_root, image_digest, created_at,
ROW_NUMBER() OVER (PARTITION BY image_digest ORDER BY created_at DESC) as rn
FROM scanner.facet_seals
)
DELETE FROM scanner.facet_seals
WHERE combined_merkle_root IN (
SELECT combined_merkle_root
FROM ranked
WHERE rn > @keep_at_least
AND created_at < @cutoff
)
""";
/// <summary>
/// Initializes a new instance of the <see cref="PostgresFacetSealStore"/> class.
/// </summary>
/// <param name="dataSource">The Npgsql data source.</param>
/// <param name="logger">Logger instance.</param>
public PostgresFacetSealStore(
NpgsqlDataSource dataSource,
ILogger<PostgresFacetSealStore>? logger = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<PostgresFacetSealStore>.Instance;
}
/// <inheritdoc/>
public async Task<FacetSeal?> GetLatestSealAsync(string imageDigest, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(SelectLatestSql, conn);
cmd.Parameters.AddWithValue("image_digest", imageDigest);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
{
return null;
}
return MapSeal(reader);
}
/// <inheritdoc/>
public async Task<FacetSeal?> GetByCombinedRootAsync(string combinedMerkleRoot, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(combinedMerkleRoot);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(SelectByCombinedRootSql, conn);
cmd.Parameters.AddWithValue("combined_merkle_root", combinedMerkleRoot);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
{
return null;
}
return MapSeal(reader);
}
/// <inheritdoc/>
public async Task<ImmutableArray<FacetSeal>> GetHistoryAsync(
string imageDigest,
int limit = 10,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(limit);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(SelectHistorySql, conn);
cmd.Parameters.AddWithValue("image_digest", imageDigest);
cmd.Parameters.AddWithValue("limit", limit);
var seals = new List<FacetSeal>();
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
seals.Add(MapSeal(reader));
}
return [.. seals];
}
/// <inheritdoc/>
public async Task SaveAsync(FacetSeal seal, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentNullException.ThrowIfNull(seal);
var sealJson = JsonSerializer.Serialize(seal, FacetJsonOptions.Compact);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(InsertSql, conn);
cmd.Parameters.AddWithValue("combined_merkle_root", seal.CombinedMerkleRoot);
cmd.Parameters.AddWithValue("image_digest", seal.ImageDigest);
cmd.Parameters.AddWithValue("schema_version", seal.SchemaVersion);
cmd.Parameters.AddWithValue("created_at", seal.CreatedAt);
cmd.Parameters.AddWithValue("build_attestation_ref",
seal.BuildAttestationRef is null ? DBNull.Value : seal.BuildAttestationRef);
cmd.Parameters.AddWithValue("signature",
seal.Signature is null ? DBNull.Value : seal.Signature);
cmd.Parameters.AddWithValue("signing_key_id",
seal.SigningKeyId is null ? DBNull.Value : seal.SigningKeyId);
cmd.Parameters.AddWithValue("seal_content", sealJson);
try
{
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_logger.LogDebug("Saved facet seal {CombinedRoot} for image {ImageDigest}",
seal.CombinedMerkleRoot, seal.ImageDigest);
}
catch (PostgresException ex) when (string.Equals(ex.SqlState, "23505", StringComparison.Ordinal))
{
throw new SealAlreadyExistsException(seal.CombinedMerkleRoot);
}
}
/// <inheritdoc/>
public async Task<bool> ExistsAsync(string imageDigest, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(ExistsSql, conn);
cmd.Parameters.AddWithValue("image_digest", imageDigest);
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result is true;
}
/// <inheritdoc/>
public async Task<int> DeleteByImageAsync(string imageDigest, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(DeleteByImageSql, conn);
cmd.Parameters.AddWithValue("image_digest", imageDigest);
var deleted = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_logger.LogInformation("Deleted {Count} facet seal(s) for image {ImageDigest}",
deleted, imageDigest);
return deleted;
}
/// <inheritdoc/>
public async Task<int> PurgeOldSealsAsync(
TimeSpan retentionPeriod,
int keepAtLeast = 1,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(keepAtLeast);
var cutoff = DateTimeOffset.UtcNow - retentionPeriod;
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(PurgeSql, conn);
cmd.Parameters.AddWithValue("keep_at_least", keepAtLeast);
cmd.Parameters.AddWithValue("cutoff", cutoff);
var purged = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_logger.LogInformation("Purged {Count} old facet seal(s) older than {Cutoff}",
purged, cutoff);
return purged;
}
private static FacetSeal MapSeal(NpgsqlDataReader reader)
{
// Read seal from JSONB column (index 7 is seal_content)
var sealJson = reader.GetString(7);
var seal = JsonSerializer.Deserialize<FacetSeal>(sealJson, FacetJsonOptions.Default);
if (seal is null)
{
throw new InvalidOperationException(
$"Failed to deserialize facet seal from database: {reader.GetString(0)}");
}
return seal;
}
}

View File

@@ -29,5 +29,6 @@
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\\..\\..\\Router\\__Libraries\\StellaOps.Messaging\\StellaOps.Messaging.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Determinism.Abstractions\\StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Facet\\StellaOps.Facet.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,71 @@
// <copyright file="FacetSealExtractionOptions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// FacetSealExtractionOptions.cs
// Sprint: SPRINT_20260105_002_002_FACET
// Task: FCT-018 - Integrate extractor with Scanner's IImageFileSystem
// Description: Options for facet seal extraction in Scanner surface publishing.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Scanner.Surface.FS;
/// <summary>
/// Options for facet seal extraction during scan surface publishing.
/// </summary>
public sealed record FacetSealExtractionOptions
{
/// <summary>
/// Gets whether facet seal extraction is enabled.
/// </summary>
/// <remarks>
/// When false, no facet extraction occurs and surface manifest will not include facets.
/// </remarks>
public bool Enabled { get; init; } = true;
/// <summary>
/// Gets whether to include individual file details in the result.
/// </summary>
/// <remarks>
/// When false, only Merkle roots are computed (more compact).
/// When true, all file details are preserved for audit.
/// </remarks>
public bool IncludeFileDetails { get; init; }
/// <summary>
/// Gets glob patterns for files to exclude from extraction.
/// </summary>
public ImmutableArray<string> ExcludePatterns { get; init; } = [];
/// <summary>
/// Gets the maximum file size to hash (larger files are skipped).
/// </summary>
public long MaxFileSizeBytes { get; init; } = 100 * 1024 * 1024; // 100MB
/// <summary>
/// Gets whether to follow symlinks.
/// </summary>
public bool FollowSymlinks { get; init; }
/// <summary>
/// Gets the default options (enabled, compact mode).
/// </summary>
public static FacetSealExtractionOptions Default { get; } = new();
/// <summary>
/// Gets disabled options (no extraction).
/// </summary>
public static FacetSealExtractionOptions Disabled { get; } = new() { Enabled = false };
/// <summary>
/// Gets options for full audit (all file details).
/// </summary>
public static FacetSealExtractionOptions FullAudit { get; } = new()
{
Enabled = true,
IncludeFileDetails = true
};
}

View File

@@ -0,0 +1,311 @@
// <copyright file="FacetSealExtractor.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// FacetSealExtractor.cs
// Sprint: SPRINT_20260105_002_002_FACET
// Task: FCT-018 - Integrate extractor with Scanner's IImageFileSystem
// Description: Bridges the Facet library extraction to Scanner's IRootFileSystem.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Facet;
namespace StellaOps.Scanner.Surface.FS;
/// <summary>
/// Extracts facet seals from image filesystems for surface manifest integration.
/// </summary>
/// <remarks>
/// FCT-018: Bridges StellaOps.Facet extraction to Scanner's filesystem abstraction.
/// </remarks>
public sealed class FacetSealExtractor : IFacetSealExtractor
{
private readonly IFacetExtractor _facetExtractor;
private readonly TimeProvider _timeProvider;
private readonly ILogger<FacetSealExtractor> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FacetSealExtractor"/> class.
/// </summary>
/// <param name="facetExtractor">The underlying facet extractor.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
/// <param name="logger">Logger instance.</param>
public FacetSealExtractor(
IFacetExtractor facetExtractor,
TimeProvider? timeProvider = null,
ILogger<FacetSealExtractor>? logger = null)
{
_facetExtractor = facetExtractor ?? throw new ArgumentNullException(nameof(facetExtractor));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? NullLogger<FacetSealExtractor>.Instance;
}
/// <inheritdoc/>
public async Task<SurfaceFacetSeals?> ExtractFromDirectoryAsync(
string rootPath,
FacetSealExtractionOptions? options = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
options ??= FacetSealExtractionOptions.Default;
if (!options.Enabled)
{
_logger.LogDebug("Facet seal extraction is disabled");
return null;
}
_logger.LogInformation("Extracting facet seals from directory: {RootPath}", rootPath);
var sw = Stopwatch.StartNew();
try
{
var extractionOptions = new FacetExtractionOptions
{
IncludeFileDetails = options.IncludeFileDetails,
ExcludePatterns = options.ExcludePatterns,
MaxFileSizeBytes = options.MaxFileSizeBytes,
FollowSymlinks = options.FollowSymlinks
};
var result = await _facetExtractor.ExtractFromDirectoryAsync(rootPath, extractionOptions, ct)
.ConfigureAwait(false);
sw.Stop();
var facetSeals = ConvertToSurfaceFacetSeals(result, sw.Elapsed);
_logger.LogInformation(
"Facet seal extraction completed: {FacetCount} facets, {FileCount} files, {Duration}ms",
facetSeals.Facets.Count,
facetSeals.Stats?.FilesMatched ?? 0,
sw.ElapsedMilliseconds);
return facetSeals;
}
catch (Exception ex)
{
_logger.LogError(ex, "Facet seal extraction failed for: {RootPath}", rootPath);
throw;
}
}
/// <inheritdoc/>
public async Task<SurfaceFacetSeals?> ExtractFromTarAsync(
Stream tarStream,
FacetSealExtractionOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(tarStream);
options ??= FacetSealExtractionOptions.Default;
if (!options.Enabled)
{
_logger.LogDebug("Facet seal extraction is disabled");
return null;
}
_logger.LogInformation("Extracting facet seals from tar stream");
var sw = Stopwatch.StartNew();
try
{
var extractionOptions = new FacetExtractionOptions
{
IncludeFileDetails = options.IncludeFileDetails,
ExcludePatterns = options.ExcludePatterns,
MaxFileSizeBytes = options.MaxFileSizeBytes,
FollowSymlinks = options.FollowSymlinks
};
var result = await _facetExtractor.ExtractFromTarAsync(tarStream, extractionOptions, ct)
.ConfigureAwait(false);
sw.Stop();
var facetSeals = ConvertToSurfaceFacetSeals(result, sw.Elapsed);
_logger.LogInformation(
"Facet seal extraction from tar completed: {FacetCount} facets, {FileCount} files, {Duration}ms",
facetSeals.Facets.Count,
facetSeals.Stats?.FilesMatched ?? 0,
sw.ElapsedMilliseconds);
return facetSeals;
}
catch (Exception ex)
{
_logger.LogError(ex, "Facet seal extraction from tar failed");
throw;
}
}
/// <inheritdoc/>
public async Task<SurfaceFacetSeals?> ExtractFromOciLayersAsync(
IEnumerable<Stream> layerStreams,
FacetSealExtractionOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(layerStreams);
options ??= FacetSealExtractionOptions.Default;
if (!options.Enabled)
{
_logger.LogDebug("Facet seal extraction is disabled");
return null;
}
_logger.LogInformation("Extracting facet seals from OCI layers");
var sw = Stopwatch.StartNew();
try
{
var extractionOptions = new FacetExtractionOptions
{
IncludeFileDetails = options.IncludeFileDetails,
ExcludePatterns = options.ExcludePatterns,
MaxFileSizeBytes = options.MaxFileSizeBytes,
FollowSymlinks = options.FollowSymlinks
};
// Extract from each layer and merge results
var allFacetEntries = new Dictionary<string, List<FacetEntry>>();
int totalFilesProcessed = 0;
long totalBytes = 0;
int filesMatched = 0;
int filesUnmatched = 0;
string? combinedMerkleRoot = null;
int layerIndex = 0;
foreach (var layerStream in layerStreams)
{
ct.ThrowIfCancellationRequested();
_logger.LogDebug("Processing layer {LayerIndex}", layerIndex);
var layerResult = await _facetExtractor.ExtractFromOciLayerAsync(layerStream, extractionOptions, ct)
.ConfigureAwait(false);
// Merge facet entries (later layers override earlier ones for same files)
foreach (var facetEntry in layerResult.Facets)
{
if (!allFacetEntries.TryGetValue(facetEntry.FacetId, out var entries))
{
entries = [];
allFacetEntries[facetEntry.FacetId] = entries;
}
entries.Add(facetEntry);
}
totalFilesProcessed += layerResult.Stats.TotalFilesProcessed;
totalBytes += layerResult.Stats.TotalBytes;
filesMatched += layerResult.Stats.FilesMatched;
filesUnmatched += layerResult.Stats.FilesUnmatched;
combinedMerkleRoot = layerResult.CombinedMerkleRoot; // Use last layer's root
layerIndex++;
}
sw.Stop();
// Build merged result
var mergedFacets = allFacetEntries
.Select(kvp => MergeFacetEntries(kvp.Key, kvp.Value))
.Where(f => f is not null)
.Cast<SurfaceFacetEntry>()
.OrderBy(f => f.FacetId, StringComparer.Ordinal)
.ToImmutableArray();
var facetSeals = new SurfaceFacetSeals
{
CreatedAt = _timeProvider.GetUtcNow(),
CombinedMerkleRoot = combinedMerkleRoot ?? string.Empty,
Facets = mergedFacets,
Stats = new SurfaceFacetStats
{
TotalFilesProcessed = totalFilesProcessed,
TotalBytes = totalBytes,
FilesMatched = filesMatched,
FilesUnmatched = filesUnmatched,
DurationMs = (long)sw.Elapsed.TotalMilliseconds
}
};
_logger.LogInformation(
"Facet seal extraction from {LayerCount} OCI layers completed: {FacetCount} facets, {Duration}ms",
layerIndex,
facetSeals.Facets.Count,
sw.ElapsedMilliseconds);
return facetSeals;
}
catch (Exception ex)
{
_logger.LogError(ex, "Facet seal extraction from OCI layers failed");
throw;
}
}
private SurfaceFacetSeals ConvertToSurfaceFacetSeals(FacetExtractionResult result, TimeSpan duration)
{
var facets = result.Facets
.Select(f => new SurfaceFacetEntry
{
FacetId = f.FacetId,
Name = f.Name,
Category = f.Category.ToString(),
MerkleRoot = f.MerkleRoot,
FileCount = f.FileCount,
TotalBytes = f.TotalBytes
})
.ToImmutableArray();
return new SurfaceFacetSeals
{
CreatedAt = _timeProvider.GetUtcNow(),
CombinedMerkleRoot = result.CombinedMerkleRoot,
Facets = facets,
Stats = new SurfaceFacetStats
{
TotalFilesProcessed = result.Stats.TotalFilesProcessed,
TotalBytes = result.Stats.TotalBytes,
FilesMatched = result.Stats.FilesMatched,
FilesUnmatched = result.Stats.FilesUnmatched,
DurationMs = (long)duration.TotalMilliseconds
}
};
}
private static SurfaceFacetEntry? MergeFacetEntries(string facetId, List<FacetEntry> entries)
{
if (entries.Count == 0)
{
return null;
}
// Use the last entry as the authoritative one (later layers override)
var last = entries[^1];
// Sum up counts from all layers
var totalFileCount = entries.Sum(e => e.FileCount);
var totalBytes = entries.Sum(e => e.TotalBytes);
return new SurfaceFacetEntry
{
FacetId = facetId,
Name = last.Name,
Category = last.Category.ToString(),
MerkleRoot = last.MerkleRoot,
FileCount = totalFileCount,
TotalBytes = totalBytes
};
}
}

View File

@@ -0,0 +1,54 @@
// <copyright file="IFacetSealExtractor.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// IFacetSealExtractor.cs
// Sprint: SPRINT_20260105_002_002_FACET
// Task: FCT-018 - Integrate extractor with Scanner's IImageFileSystem
// Description: Interface for facet seal extraction integrated with Scanner.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Surface.FS;
/// <summary>
/// Extracts facet seals from image filesystems for surface manifest integration.
/// </summary>
public interface IFacetSealExtractor
{
/// <summary>
/// Extract facet seals from a local directory (unpacked image).
/// </summary>
/// <param name="rootPath">Path to the unpacked image root.</param>
/// <param name="options">Extraction options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Facet seals for surface manifest, or null if disabled.</returns>
Task<SurfaceFacetSeals?> ExtractFromDirectoryAsync(
string rootPath,
FacetSealExtractionOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Extract facet seals from a tar archive.
/// </summary>
/// <param name="tarStream">Stream containing the tar archive.</param>
/// <param name="options">Extraction options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Facet seals for surface manifest, or null if disabled.</returns>
Task<SurfaceFacetSeals?> ExtractFromTarAsync(
Stream tarStream,
FacetSealExtractionOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Extract facet seals from multiple OCI image layers.
/// </summary>
/// <param name="layerStreams">Streams for each layer (in order from base to top).</param>
/// <param name="options">Extraction options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Merged facet seals for surface manifest, or null if disabled.</returns>
Task<SurfaceFacetSeals?> ExtractFromOciLayersAsync(
IEnumerable<Stream> layerStreams,
FacetSealExtractionOptions? options = null,
CancellationToken ct = default);
}

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Facet;
namespace StellaOps.Scanner.Surface.FS;
@@ -10,6 +11,7 @@ public static class ServiceCollectionExtensions
{
private const string CacheConfigurationSection = "Surface:Cache";
private const string ManifestConfigurationSection = "Surface:Manifest";
private const string FacetSealConfigurationSection = "Surface:FacetSeal";
public static IServiceCollection AddSurfaceFileCache(
this IServiceCollection services,
@@ -113,4 +115,41 @@ public static class ServiceCollectionExtensions
return ValidateOptionsResult.Success;
}
}
/// <summary>
/// Adds facet seal extraction services for surface manifest integration.
/// </summary>
/// <remarks>
/// Sprint: SPRINT_20260105_002_002_FACET (FCT-018)
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="configure">Optional configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFacetSealExtractor(
this IServiceCollection services,
Action<FacetSealExtractionOptions>? configure = null)
{
if (services is null)
{
throw new ArgumentNullException(nameof(services));
}
// Register Facet library services
services.AddFacetServices();
// Register options
services.AddOptions<FacetSealExtractionOptions>()
.BindConfiguration(FacetSealConfigurationSection);
if (configure is not null)
{
services.Configure(configure);
}
// Register extractor
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<IFacetSealExtractor, FacetSealExtractor>();
return services;
}
}

View File

@@ -25,6 +25,7 @@
<ItemGroup>
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Facet\\StellaOps.Facet.csproj" />
</ItemGroup>
</Project>

View File

@@ -55,6 +55,18 @@ public sealed record SurfaceManifestDocument
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ReplayBundleReference? ReplayBundle { get; init; }
= null;
/// <summary>
/// Gets the facet seals for per-facet drift tracking.
/// </summary>
/// <remarks>
/// Sprint: SPRINT_20260105_002_002_FACET (FCT-021)
/// Enables granular drift detection and quota enforcement on component types.
/// </remarks>
[JsonPropertyName("facetSeals")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public SurfaceFacetSeals? FacetSeals { get; init; }
= null;
}
/// <summary>
@@ -214,3 +226,125 @@ public sealed record SurfaceManifestPublishResult(
string ArtifactId,
SurfaceManifestDocument Document,
string? DeterminismMerkleRoot = null);
/// <summary>
/// Facet seals embedded in the surface manifest for drift tracking.
/// </summary>
/// <remarks>
/// Sprint: SPRINT_20260105_002_002_FACET (FCT-021)
/// </remarks>
public sealed record SurfaceFacetSeals
{
/// <summary>
/// Gets the schema version for facet seals.
/// </summary>
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = "1.0.0";
/// <summary>
/// Gets when the facet seals were created.
/// </summary>
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Gets the combined Merkle root of all facet roots.
/// </summary>
/// <remarks>
/// Single-value integrity check across all facets.
/// </remarks>
[JsonPropertyName("combinedMerkleRoot")]
public string CombinedMerkleRoot { get; init; } = string.Empty;
/// <summary>
/// Gets the individual facet entries.
/// </summary>
[JsonPropertyName("facets")]
public IReadOnlyList<SurfaceFacetEntry> Facets { get; init; }
= ImmutableArray<SurfaceFacetEntry>.Empty;
/// <summary>
/// Gets extraction statistics.
/// </summary>
[JsonPropertyName("stats")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public SurfaceFacetStats? Stats { get; init; }
}
/// <summary>
/// A single facet entry within the surface manifest.
/// </summary>
public sealed record SurfaceFacetEntry
{
/// <summary>
/// Gets the facet identifier (e.g., "os-packages-dpkg", "lang-deps-npm").
/// </summary>
[JsonPropertyName("facetId")]
public string FacetId { get; init; } = string.Empty;
/// <summary>
/// Gets the human-readable name.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
/// <summary>
/// Gets the category for grouping.
/// </summary>
[JsonPropertyName("category")]
public string Category { get; init; } = string.Empty;
/// <summary>
/// Gets the Merkle root of all files in this facet.
/// </summary>
[JsonPropertyName("merkleRoot")]
public string MerkleRoot { get; init; } = string.Empty;
/// <summary>
/// Gets the number of files in this facet.
/// </summary>
[JsonPropertyName("fileCount")]
public int FileCount { get; init; }
/// <summary>
/// Gets the total bytes across all files.
/// </summary>
[JsonPropertyName("totalBytes")]
public long TotalBytes { get; init; }
}
/// <summary>
/// Statistics from facet extraction.
/// </summary>
public sealed record SurfaceFacetStats
{
/// <summary>
/// Gets the total files processed.
/// </summary>
[JsonPropertyName("totalFilesProcessed")]
public int TotalFilesProcessed { get; init; }
/// <summary>
/// Gets the total bytes across all files.
/// </summary>
[JsonPropertyName("totalBytes")]
public long TotalBytes { get; init; }
/// <summary>
/// Gets the number of files matched to facets.
/// </summary>
[JsonPropertyName("filesMatched")]
public int FilesMatched { get; init; }
/// <summary>
/// Gets the number of files that did not match any facet.
/// </summary>
[JsonPropertyName("filesUnmatched")]
public int FilesUnmatched { get; init; }
/// <summary>
/// Gets the extraction duration in milliseconds.
/// </summary>
[JsonPropertyName("durationMs")]
public long DurationMs { get; init; }
}