sprints and audit work
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
379
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGateOptions.cs
Normal file
379
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGateOptions.cs
Normal 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;
|
||||
}
|
||||
201
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGatePolicy.cs
Normal file
201
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGatePolicy.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
144
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGateResult.cs
Normal file
144
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGateResult.cs
Normal 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; }
|
||||
}
|
||||
249
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGateService.cs
Normal file
249
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexGateService.cs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
78
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexTypes.cs
Normal file
78
src/Scanner/__Libraries/StellaOps.Scanner.Gate/VexTypes.cs
Normal 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
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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; } = [];
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})"
|
||||
: "";
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Facet\\StellaOps.Facet.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user